@blastin-dev/clocktopus-cli 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -6
- package/dist/src/commands/agent/disable.d.ts +14 -0
- package/dist/src/commands/agent/disable.d.ts.map +1 -0
- package/dist/src/commands/agent/disable.js +72 -0
- package/dist/src/commands/agent/doctor.d.ts +2 -0
- package/dist/src/commands/agent/doctor.d.ts.map +1 -0
- package/dist/src/commands/agent/doctor.js +235 -0
- package/dist/src/commands/agent/hook.d.ts +2 -0
- package/dist/src/commands/agent/hook.d.ts.map +1 -0
- package/dist/src/commands/agent/hook.js +231 -0
- package/dist/src/commands/agent/setup.d.ts +21 -0
- package/dist/src/commands/agent/setup.d.ts.map +1 -0
- package/dist/src/commands/agent/setup.js +194 -0
- package/dist/src/commands/agent/status.d.ts +2 -0
- package/dist/src/commands/agent/status.d.ts.map +1 -0
- package/dist/src/commands/agent/status.js +160 -0
- package/dist/src/commands/clock.d.ts +26 -4
- package/dist/src/commands/clock.d.ts.map +1 -1
- package/dist/src/commands/clock.js +99 -6
- package/dist/src/commands/login.d.ts.map +1 -1
- package/dist/src/commands/login.js +5 -5
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +45 -6
- package/dist/src/lib/agent-config.d.ts +41 -0
- package/dist/src/lib/agent-config.d.ts.map +1 -0
- package/dist/src/lib/agent-config.js +143 -0
- package/dist/src/lib/agent-hook-state.d.ts +36 -0
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
- package/dist/src/lib/agent-hook-state.js +136 -0
- package/dist/src/lib/agent-receiver.d.ts +26 -0
- package/dist/src/lib/agent-receiver.d.ts.map +1 -0
- package/dist/src/lib/agent-receiver.js +44 -0
- package/dist/src/lib/api.d.ts.map +1 -1
- package/dist/src/lib/api.js +28 -1
- package/dist/src/lib/claude-settings.d.ts +82 -0
- package/dist/src/lib/claude-settings.d.ts.map +1 -0
- package/dist/src/lib/claude-settings.js +271 -0
- package/dist/src/lib/claude-settings.test.d.ts +2 -0
- package/dist/src/lib/claude-settings.test.d.ts.map +1 -0
- package/dist/src/lib/claude-settings.test.js +193 -0
- package/dist/src/lib/config.d.ts +23 -0
- package/dist/src/lib/config.d.ts.map +1 -1
- package/dist/src/lib/config.js +14 -0
- package/dist/src/lib/format.d.ts +6 -0
- package/dist/src/lib/format.d.ts.map +1 -0
- package/dist/src/lib/format.js +19 -0
- package/dist/src/lib/git.d.ts +3 -0
- package/dist/src/lib/git.d.ts.map +1 -0
- package/dist/src/lib/git.js +30 -0
- package/dist/src/lib/repo-guidance.d.ts +40 -0
- package/dist/src/lib/repo-guidance.d.ts.map +1 -0
- package/dist/src/lib/repo-guidance.js +123 -0
- package/dist/src/lib/validators.d.ts +69 -0
- package/dist/src/lib/validators.d.ts.map +1 -1
- package/dist/src/lib/validators.js +69 -0
- package/package.json +7 -5
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talks to the OTLP receiver directly, which is the whole point.
|
|
3
|
+
*
|
|
4
|
+
* The receiver is a different host from the web app — its own Router, its
|
|
5
|
+
* own Lambda, its own domain — so a token that works against the dashboard
|
|
6
|
+
* proves nothing about whether telemetry can reach ingest. This check is
|
|
7
|
+
* the only local way to tell "nothing has happened yet" apart from "nothing
|
|
8
|
+
* can happen": a wrong endpoint, a revoked token and an idle afternoon all
|
|
9
|
+
* look identical otherwise.
|
|
10
|
+
*/
|
|
11
|
+
const TIMEOUT_MS = 8000;
|
|
12
|
+
export async function verifyReceiver(endpoint, token) {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetch(`${endpoint.replace(/\/$/, "")}/v1/verify`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
"content-type": "application/json",
|
|
20
|
+
authorization: `Bearer ${token}`,
|
|
21
|
+
},
|
|
22
|
+
body: "{}",
|
|
23
|
+
signal: controller.signal,
|
|
24
|
+
});
|
|
25
|
+
if (response.ok)
|
|
26
|
+
return { ok: true };
|
|
27
|
+
if (response.status === 401)
|
|
28
|
+
return { ok: false, reason: "invalid_token" };
|
|
29
|
+
// A 404 here is worth distinguishing in the caller's advice: it means the
|
|
30
|
+
// URL resolved to something that is not this receiver, or to a receiver
|
|
31
|
+
// deployed before /v1/verify existed.
|
|
32
|
+
return { ok: false, reason: "unexpected_status", status: response.status };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: "unreachable",
|
|
38
|
+
message: error instanceof Error ? error.message : "request failed",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/lib/api.ts"],"names":[],"mappings":"AAEA,KAAK,cAAc,GAAG;IACpB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,qBAAa,QAAS,SAAQ,KAAK;IAExB,MAAM,EAAE,MAAM;IACd,UAAU,EAAE,MAAM;gBADlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EACzB,OAAO,CAAC,EAAE,MAAM;CAKnB;AAED,wBAAsB,OAAO,CAAC,CAAC,EAC7B,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/lib/api.ts"],"names":[],"mappings":"AAEA,KAAK,cAAc,GAAG;IACpB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,qBAAa,QAAS,SAAQ,KAAK;IAExB,MAAM,EAAE,MAAM;IACd,UAAU,EAAE,MAAM;gBADlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EACzB,OAAO,CAAC,EAAE,MAAM;CAKnB;AAED,wBAAsB,OAAO,CAAC,CAAC,EAC7B,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,CAAC,CAAC,CAqCZ;AAuBD,wBAAsB,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,UAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAE3E;AAED,wBAAsB,IAAI,CAAC,CAAC,EAC1B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,aAAa,UAAO,GACnB,OAAO,CAAC,CAAC,CAAC,CAEZ"}
|
package/dist/src/lib/api.js
CHANGED
|
@@ -29,10 +29,37 @@ export async function request(path, options = {}) {
|
|
|
29
29
|
});
|
|
30
30
|
if (!response.ok) {
|
|
31
31
|
const errorText = await response.text();
|
|
32
|
-
|
|
32
|
+
// The frontend's `handleApiError` serializes errors as
|
|
33
|
+
// `{ error: { message, stack? }, timestamp, path }`. Pull the
|
|
34
|
+
// `message` out so the user sees the actual reason (e.g. "Signal
|
|
35
|
+
// time cannot be in the future…") rather than a raw JSON blob.
|
|
36
|
+
throw new ApiError(response.status, response.statusText, extractErrorMessage(errorText) ?? errorText);
|
|
33
37
|
}
|
|
34
38
|
return response.json();
|
|
35
39
|
}
|
|
40
|
+
function extractErrorMessage(body) {
|
|
41
|
+
if (!body)
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(body);
|
|
45
|
+
if (parsed && typeof parsed === "object") {
|
|
46
|
+
const record = parsed;
|
|
47
|
+
const err = record.error;
|
|
48
|
+
if (err && typeof err === "object") {
|
|
49
|
+
const msg = err.message;
|
|
50
|
+
if (typeof msg === "string" && msg.length > 0)
|
|
51
|
+
return msg;
|
|
52
|
+
}
|
|
53
|
+
if (typeof record.message === "string" && record.message.length > 0) {
|
|
54
|
+
return record.message;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Not JSON — fall through and let the caller show the raw text.
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
36
63
|
export async function get(path, authenticated = true) {
|
|
37
64
|
return request(path, { method: "GET", authenticated });
|
|
38
65
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads and edits `~/.claude/settings.json` on the user's behalf.
|
|
3
|
+
*
|
|
4
|
+
* Two rules govern everything here, both of them about not destroying a file
|
|
5
|
+
* we do not own:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Never write over JSON we could not parse.** A malformed settings file
|
|
8
|
+
* is far more likely to be a half-finished edit than something to
|
|
9
|
+
* overwrite, and overwriting it would lose the user's own hooks,
|
|
10
|
+
* permissions and MCP servers. Parse failures raise instead.
|
|
11
|
+
* 2. **Only ever touch keys we put there.** Merging into `env` and appending
|
|
12
|
+
* to `hooks` leaves everything else untouched, and removal matches our
|
|
13
|
+
* own hook command rather than clearing the arrays.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SETTINGS_FILENAME = "settings.json";
|
|
16
|
+
/** Env keys `clocktopus agent setup` owns — and the only ones it removes. */
|
|
17
|
+
export declare const TELEMETRY_ENV_KEYS: readonly ["CLAUDE_CODE_ENABLE_TELEMETRY", "OTEL_METRICS_EXPORTER", "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_METRICS_INCLUDE_SESSION_ID", "OTEL_METRIC_EXPORT_INTERVAL", "CLOCKTOPUS_INGEST_TOKEN", "CLOCKTOPUS_OTEL_ENDPOINT"];
|
|
18
|
+
/**
|
|
19
|
+
* How often Claude Code's exporter ships metrics.
|
|
20
|
+
*
|
|
21
|
+
* Also the resolution of every "last export received" answer the status
|
|
22
|
+
* command can give — a session that started 30s ago has genuinely not
|
|
23
|
+
* exported yet, which is why status treats silence under one interval as
|
|
24
|
+
* "waiting" rather than "broken".
|
|
25
|
+
*/
|
|
26
|
+
export declare const METRIC_EXPORT_INTERVAL_MS = 60000;
|
|
27
|
+
/**
|
|
28
|
+
* Seconds Claude Code will wait for the hook before giving up on it.
|
|
29
|
+
*
|
|
30
|
+
* Comfortably above the hook's own 4s request timeout, so a slow network
|
|
31
|
+
* produces the hook's own recorded failure — which `agent doctor` can read
|
|
32
|
+
* back — rather than a kill from the host, which leaves no trace anywhere.
|
|
33
|
+
*/
|
|
34
|
+
export declare const HOOK_TIMEOUT_SECONDS = 10;
|
|
35
|
+
export type ClaudeSettings = Record<string, unknown>;
|
|
36
|
+
export declare class SettingsParseError extends Error {
|
|
37
|
+
readonly path: string;
|
|
38
|
+
constructor(path: string);
|
|
39
|
+
}
|
|
40
|
+
export declare function claudeConfigDir(): string;
|
|
41
|
+
export declare function settingsPath(): string;
|
|
42
|
+
export declare function readSettings(path?: string): {
|
|
43
|
+
path: string;
|
|
44
|
+
exists: boolean;
|
|
45
|
+
modifiedAt: Date | null;
|
|
46
|
+
settings: ClaudeSettings;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Writes settings, keeping a one-deep backup of what was there before.
|
|
50
|
+
*
|
|
51
|
+
* The rename is what makes it atomic: a crash midway leaves either the old
|
|
52
|
+
* file or the new one, never a truncated file that Claude Code would refuse
|
|
53
|
+
* to start with.
|
|
54
|
+
*/
|
|
55
|
+
export declare function writeSettings(settings: ClaudeSettings, path?: string): {
|
|
56
|
+
backupPath: string | null;
|
|
57
|
+
};
|
|
58
|
+
export declare function buildTelemetryEnv(input: {
|
|
59
|
+
token: string;
|
|
60
|
+
endpoint: string;
|
|
61
|
+
}): Record<string, string>;
|
|
62
|
+
declare const HOOK_EVENTS: readonly ["SessionStart", "SessionEnd"];
|
|
63
|
+
export declare function applyTelemetrySettings(settings: ClaudeSettings, input: {
|
|
64
|
+
token: string;
|
|
65
|
+
endpoint: string;
|
|
66
|
+
hookCommand: string;
|
|
67
|
+
}): ClaudeSettings;
|
|
68
|
+
export declare function removeTelemetrySettings(settings: ClaudeSettings): {
|
|
69
|
+
settings: ClaudeSettings;
|
|
70
|
+
removedEnvKeys: string[];
|
|
71
|
+
removedHooks: boolean;
|
|
72
|
+
};
|
|
73
|
+
/** What settings.json currently declares, for `status` and `doctor`. */
|
|
74
|
+
export declare function readInstalledTelemetry(path?: string): {
|
|
75
|
+
path: string;
|
|
76
|
+
exists: boolean;
|
|
77
|
+
modifiedAt: Date | null;
|
|
78
|
+
env: Record<string, string>;
|
|
79
|
+
hookCommands: Partial<Record<(typeof HOOK_EVENTS)[number], string>>;
|
|
80
|
+
};
|
|
81
|
+
export {};
|
|
82
|
+
//# sourceMappingURL=claude-settings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claude-settings.d.ts","sourceRoot":"","sources":["../../../src/lib/claude-settings.ts"],"names":[],"mappings":"AAYA;;;;;;;;;;;;;GAaG;AAEH,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AAEjD,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,yRAUrB,CAAC;AAEX;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAEhD;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AASrD,qBAAa,kBAAmB,SAAQ,KAAK;aACf,IAAI,EAAE,MAAM;gBAAZ,IAAI,EAAE,MAAM;CAMzC;AAED,wBAAgB,eAAe,IAAI,MAAM,CAIxC;AAED,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,wBAAgB,YAAY,CAAC,IAAI,SAAiB,GAAG;IACnD,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,cAAc,CAAC;CAC1B,CA6BA;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,cAAc,EACxB,IAAI,SAAiB,GACpB;IAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAiB/B;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAmBzB;AAED,QAAA,MAAM,WAAW,yCAA0C,CAAC;AAqC5D,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,cAAc,EACxB,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC9D,cAAc,CA6ChB;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,cAAc,GAAG;IACjE,QAAQ,EAAE,cAAc,CAAC;IACzB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,YAAY,EAAE,OAAO,CAAC;CACvB,CAmCA;AAED,wEAAwE;AACxE,wBAAgB,sBAAsB,CAAC,IAAI,SAAiB,GAAG;IAC7D,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;CACrE,CAmCA"}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Reads and edits `~/.claude/settings.json` on the user's behalf.
|
|
6
|
+
*
|
|
7
|
+
* Two rules govern everything here, both of them about not destroying a file
|
|
8
|
+
* we do not own:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Never write over JSON we could not parse.** A malformed settings file
|
|
11
|
+
* is far more likely to be a half-finished edit than something to
|
|
12
|
+
* overwrite, and overwriting it would lose the user's own hooks,
|
|
13
|
+
* permissions and MCP servers. Parse failures raise instead.
|
|
14
|
+
* 2. **Only ever touch keys we put there.** Merging into `env` and appending
|
|
15
|
+
* to `hooks` leaves everything else untouched, and removal matches our
|
|
16
|
+
* own hook command rather than clearing the arrays.
|
|
17
|
+
*/
|
|
18
|
+
export const SETTINGS_FILENAME = "settings.json";
|
|
19
|
+
/** Env keys `clocktopus agent setup` owns — and the only ones it removes. */
|
|
20
|
+
export const TELEMETRY_ENV_KEYS = [
|
|
21
|
+
"CLAUDE_CODE_ENABLE_TELEMETRY",
|
|
22
|
+
"OTEL_METRICS_EXPORTER",
|
|
23
|
+
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
|
24
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
25
|
+
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
26
|
+
"OTEL_METRICS_INCLUDE_SESSION_ID",
|
|
27
|
+
"OTEL_METRIC_EXPORT_INTERVAL",
|
|
28
|
+
"CLOCKTOPUS_INGEST_TOKEN",
|
|
29
|
+
"CLOCKTOPUS_OTEL_ENDPOINT",
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* How often Claude Code's exporter ships metrics.
|
|
33
|
+
*
|
|
34
|
+
* Also the resolution of every "last export received" answer the status
|
|
35
|
+
* command can give — a session that started 30s ago has genuinely not
|
|
36
|
+
* exported yet, which is why status treats silence under one interval as
|
|
37
|
+
* "waiting" rather than "broken".
|
|
38
|
+
*/
|
|
39
|
+
export const METRIC_EXPORT_INTERVAL_MS = 60_000;
|
|
40
|
+
/**
|
|
41
|
+
* Seconds Claude Code will wait for the hook before giving up on it.
|
|
42
|
+
*
|
|
43
|
+
* Comfortably above the hook's own 4s request timeout, so a slow network
|
|
44
|
+
* produces the hook's own recorded failure — which `agent doctor` can read
|
|
45
|
+
* back — rather than a kill from the host, which leaves no trace anywhere.
|
|
46
|
+
*/
|
|
47
|
+
export const HOOK_TIMEOUT_SECONDS = 10;
|
|
48
|
+
export class SettingsParseError extends Error {
|
|
49
|
+
path;
|
|
50
|
+
constructor(path) {
|
|
51
|
+
super(`${path} is not valid JSON. Fix or move it, then run 'clocktopus agent setup' again.`);
|
|
52
|
+
this.path = path;
|
|
53
|
+
this.name = "SettingsParseError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function claudeConfigDir() {
|
|
57
|
+
// Claude Code honours CLAUDE_CONFIG_DIR; following it means setup writes
|
|
58
|
+
// where that installation actually reads.
|
|
59
|
+
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
60
|
+
}
|
|
61
|
+
export function settingsPath() {
|
|
62
|
+
return join(claudeConfigDir(), SETTINGS_FILENAME);
|
|
63
|
+
}
|
|
64
|
+
export function readSettings(path = settingsPath()) {
|
|
65
|
+
if (!existsSync(path)) {
|
|
66
|
+
return { path, exists: false, modifiedAt: null, settings: {} };
|
|
67
|
+
}
|
|
68
|
+
const raw = readFileSync(path, "utf8");
|
|
69
|
+
const modifiedAt = statSync(path).mtime;
|
|
70
|
+
// An empty file is a normal state (some installers touch it) and is safe
|
|
71
|
+
// to treat as an empty object; anything else that fails to parse is not.
|
|
72
|
+
if (raw.trim() === "") {
|
|
73
|
+
return { path, exists: true, modifiedAt, settings: {} };
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(raw);
|
|
77
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
78
|
+
throw new SettingsParseError(path);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
path,
|
|
82
|
+
exists: true,
|
|
83
|
+
modifiedAt,
|
|
84
|
+
settings: parsed,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof SettingsParseError)
|
|
89
|
+
throw error;
|
|
90
|
+
throw new SettingsParseError(path);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Writes settings, keeping a one-deep backup of what was there before.
|
|
95
|
+
*
|
|
96
|
+
* The rename is what makes it atomic: a crash midway leaves either the old
|
|
97
|
+
* file or the new one, never a truncated file that Claude Code would refuse
|
|
98
|
+
* to start with.
|
|
99
|
+
*/
|
|
100
|
+
export function writeSettings(settings, path = settingsPath()) {
|
|
101
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
102
|
+
let backupPath = null;
|
|
103
|
+
if (existsSync(path)) {
|
|
104
|
+
backupPath = `${path}.clocktopus-backup`;
|
|
105
|
+
copyFileSync(path, backupPath);
|
|
106
|
+
}
|
|
107
|
+
const temporaryPath = `${path}.clocktopus-tmp`;
|
|
108
|
+
writeFileSync(temporaryPath, `${JSON.stringify(settings, null, 2)}\n`, {
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
mode: 0o600,
|
|
111
|
+
});
|
|
112
|
+
renameSync(temporaryPath, path);
|
|
113
|
+
return { backupPath };
|
|
114
|
+
}
|
|
115
|
+
export function buildTelemetryEnv(input) {
|
|
116
|
+
const endpoint = input.endpoint.replace(/\/$/, "");
|
|
117
|
+
return {
|
|
118
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: "1",
|
|
119
|
+
OTEL_METRICS_EXPORTER: "otlp",
|
|
120
|
+
// The receiver answers protobuf with an explicit 415 rather than a
|
|
121
|
+
// parse error, but only JSON actually works.
|
|
122
|
+
OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
|
|
123
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: endpoint,
|
|
124
|
+
OTEL_EXPORTER_OTLP_HEADERS: `Authorization=Bearer ${input.token}`,
|
|
125
|
+
// Without the session id the metric stream cannot be joined to the hook
|
|
126
|
+
// stream, and every session loses its repository and branch.
|
|
127
|
+
OTEL_METRICS_INCLUDE_SESSION_ID: "true",
|
|
128
|
+
OTEL_METRIC_EXPORT_INTERVAL: String(METRIC_EXPORT_INTERVAL_MS),
|
|
129
|
+
// The hook's own channel. Same token, different transport.
|
|
130
|
+
CLOCKTOPUS_INGEST_TOKEN: input.token,
|
|
131
|
+
CLOCKTOPUS_OTEL_ENDPOINT: endpoint,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const HOOK_EVENTS = ["SessionStart", "SessionEnd"];
|
|
135
|
+
/**
|
|
136
|
+
* Recognises a hook entry as ours, across every spelling we have shipped.
|
|
137
|
+
*
|
|
138
|
+
* The `claude-hook.mjs` clause matters for upgrades: before this command
|
|
139
|
+
* existed the hook was a script inside a checkout of the Clocktopus repo,
|
|
140
|
+
* and anyone who set that up by hand still has it. Failing to recognise it
|
|
141
|
+
* would leave it installed next to the new one and POST every SessionStart
|
|
142
|
+
* twice.
|
|
143
|
+
*/
|
|
144
|
+
function isClocktopusHook(entry) {
|
|
145
|
+
const command = typeof entry.command === "string" ? entry.command : "";
|
|
146
|
+
if (/claude-hook\.mjs/.test(command))
|
|
147
|
+
return true;
|
|
148
|
+
return /clocktopus/i.test(command) && /agent\s+hook/.test(command);
|
|
149
|
+
}
|
|
150
|
+
function asMatchers(value) {
|
|
151
|
+
return Array.isArray(value) ? value : [];
|
|
152
|
+
}
|
|
153
|
+
/** Strips our hook from one event's matcher list, leaving the user's alone. */
|
|
154
|
+
function withoutOurHooks(matchers) {
|
|
155
|
+
return (matchers
|
|
156
|
+
.map((matcher) => ({
|
|
157
|
+
...matcher,
|
|
158
|
+
hooks: (matcher.hooks ?? []).filter((entry) => !isClocktopusHook(entry)),
|
|
159
|
+
}))
|
|
160
|
+
// A matcher group that only ever held our hook is ours to remove; one
|
|
161
|
+
// that still has entries belongs to the user and stays.
|
|
162
|
+
.filter((matcher) => (matcher.hooks ?? []).length > 0));
|
|
163
|
+
}
|
|
164
|
+
export function applyTelemetrySettings(settings, input) {
|
|
165
|
+
const existingEnv = settings.env &&
|
|
166
|
+
typeof settings.env === "object" &&
|
|
167
|
+
!Array.isArray(settings.env)
|
|
168
|
+
? settings.env
|
|
169
|
+
: {};
|
|
170
|
+
const existingHooks = settings.hooks &&
|
|
171
|
+
typeof settings.hooks === "object" &&
|
|
172
|
+
!Array.isArray(settings.hooks)
|
|
173
|
+
? settings.hooks
|
|
174
|
+
: {};
|
|
175
|
+
const hooks = { ...existingHooks };
|
|
176
|
+
for (const event of HOOK_EVENTS) {
|
|
177
|
+
// Remove-then-append rather than append: re-running setup after a
|
|
178
|
+
// reinstall must not leave two hooks firing per session, which would
|
|
179
|
+
// double every SessionStart POST.
|
|
180
|
+
hooks[event] = [
|
|
181
|
+
...withoutOurHooks(asMatchers(existingHooks[event])),
|
|
182
|
+
{
|
|
183
|
+
hooks: [
|
|
184
|
+
{
|
|
185
|
+
type: "command",
|
|
186
|
+
command: input.hookCommand,
|
|
187
|
+
// Both fields are about not making the user wait on telemetry.
|
|
188
|
+
// The hook does network I/O — its own POST, plus a sweep of
|
|
189
|
+
// abandoned sessions at SessionStart — and running it inline
|
|
190
|
+
// would add that latency to the start of every session.
|
|
191
|
+
timeout: HOOK_TIMEOUT_SECONDS,
|
|
192
|
+
async: true,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
...settings,
|
|
200
|
+
env: { ...existingEnv, ...buildTelemetryEnv(input) },
|
|
201
|
+
hooks,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
export function removeTelemetrySettings(settings) {
|
|
205
|
+
const next = { ...settings };
|
|
206
|
+
const removedEnvKeys = [];
|
|
207
|
+
if (next.env && typeof next.env === "object" && !Array.isArray(next.env)) {
|
|
208
|
+
const env = { ...next.env };
|
|
209
|
+
for (const key of TELEMETRY_ENV_KEYS) {
|
|
210
|
+
if (key in env) {
|
|
211
|
+
delete env[key];
|
|
212
|
+
removedEnvKeys.push(key);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (Object.keys(env).length > 0)
|
|
216
|
+
next.env = env;
|
|
217
|
+
else
|
|
218
|
+
delete next.env;
|
|
219
|
+
}
|
|
220
|
+
let removedHooks = false;
|
|
221
|
+
if (next.hooks &&
|
|
222
|
+
typeof next.hooks === "object" &&
|
|
223
|
+
!Array.isArray(next.hooks)) {
|
|
224
|
+
const hooks = { ...next.hooks };
|
|
225
|
+
for (const event of HOOK_EVENTS) {
|
|
226
|
+
const before = asMatchers(hooks[event]);
|
|
227
|
+
const after = withoutOurHooks(before);
|
|
228
|
+
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
229
|
+
removedHooks = true;
|
|
230
|
+
if (after.length > 0)
|
|
231
|
+
hooks[event] = after;
|
|
232
|
+
else
|
|
233
|
+
delete hooks[event];
|
|
234
|
+
}
|
|
235
|
+
if (Object.keys(hooks).length > 0)
|
|
236
|
+
next.hooks = hooks;
|
|
237
|
+
else
|
|
238
|
+
delete next.hooks;
|
|
239
|
+
}
|
|
240
|
+
return { settings: next, removedEnvKeys, removedHooks };
|
|
241
|
+
}
|
|
242
|
+
/** What settings.json currently declares, for `status` and `doctor`. */
|
|
243
|
+
export function readInstalledTelemetry(path = settingsPath()) {
|
|
244
|
+
const { settings, exists, modifiedAt } = readSettings(path);
|
|
245
|
+
const rawEnv = settings.env &&
|
|
246
|
+
typeof settings.env === "object" &&
|
|
247
|
+
!Array.isArray(settings.env)
|
|
248
|
+
? settings.env
|
|
249
|
+
: {};
|
|
250
|
+
const env = {};
|
|
251
|
+
for (const key of TELEMETRY_ENV_KEYS) {
|
|
252
|
+
if (typeof rawEnv[key] === "string")
|
|
253
|
+
env[key] = rawEnv[key];
|
|
254
|
+
}
|
|
255
|
+
const rawHooks = settings.hooks &&
|
|
256
|
+
typeof settings.hooks === "object" &&
|
|
257
|
+
!Array.isArray(settings.hooks)
|
|
258
|
+
? settings.hooks
|
|
259
|
+
: {};
|
|
260
|
+
const hookCommands = {};
|
|
261
|
+
for (const event of HOOK_EVENTS) {
|
|
262
|
+
for (const matcher of asMatchers(rawHooks[event])) {
|
|
263
|
+
const ours = (matcher.hooks ?? []).find(isClocktopusHook);
|
|
264
|
+
if (ours?.command) {
|
|
265
|
+
hookCommands[event] = ours.command;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return { path, exists, modifiedAt, env, hookCommands };
|
|
271
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claude-settings.test.d.ts","sourceRoot":"","sources":["../../../src/lib/claude-settings.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { applyTelemetrySettings, buildTelemetryEnv, readInstalledTelemetry, readSettings, removeTelemetrySettings, SettingsParseError, writeSettings, } from "./claude-settings";
|
|
6
|
+
/**
|
|
7
|
+
* These tests exist because this module edits a file we do not own.
|
|
8
|
+
* `~/.claude/settings.json` holds the user's model choice, their own hooks,
|
|
9
|
+
* their permissions and their MCP servers, and a merge bug here destroys
|
|
10
|
+
* work that has nothing to do with us.
|
|
11
|
+
*/
|
|
12
|
+
const INSTALL = {
|
|
13
|
+
token: "ctop_agt_testtoken",
|
|
14
|
+
endpoint: "https://otel.example.com",
|
|
15
|
+
hookCommand: "clocktopus agent hook",
|
|
16
|
+
};
|
|
17
|
+
/** A settings file with the user's own configuration already in it. */
|
|
18
|
+
function userSettings() {
|
|
19
|
+
return {
|
|
20
|
+
model: "opus",
|
|
21
|
+
env: { MY_OWN_VAR: "keep-me" },
|
|
22
|
+
hooks: {
|
|
23
|
+
SessionStart: [
|
|
24
|
+
{ hooks: [{ type: "command", command: "echo user-session-start" }] },
|
|
25
|
+
],
|
|
26
|
+
PreToolUse: [
|
|
27
|
+
{
|
|
28
|
+
matcher: "Bash",
|
|
29
|
+
hooks: [{ type: "command", command: "echo bash-guard" }],
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
describe("applyTelemetrySettings", () => {
|
|
36
|
+
it("leaves everything it did not put there alone", () => {
|
|
37
|
+
const next = applyTelemetrySettings(userSettings(), INSTALL);
|
|
38
|
+
expect(next.model).toBe("opus");
|
|
39
|
+
expect(next.env.MY_OWN_VAR).toBe("keep-me");
|
|
40
|
+
expect(next.hooks.PreToolUse).toEqual(userSettings().hooks.PreToolUse);
|
|
41
|
+
});
|
|
42
|
+
it("keeps the user's own SessionStart hook alongside ours", () => {
|
|
43
|
+
const next = applyTelemetrySettings(userSettings(), INSTALL);
|
|
44
|
+
const commands = (next.hooks.SessionStart ?? []).flatMap((matcher) => matcher.hooks.map((entry) => entry.command));
|
|
45
|
+
expect(commands).toContain("echo user-session-start");
|
|
46
|
+
expect(commands).toContain("clocktopus agent hook");
|
|
47
|
+
});
|
|
48
|
+
it("installs both events — one without the other loses attribution", () => {
|
|
49
|
+
// SessionStart is the only source of `cwd` and the *before* SHA;
|
|
50
|
+
// SessionEnd is the only source of the exact commit list.
|
|
51
|
+
const next = applyTelemetrySettings({}, INSTALL);
|
|
52
|
+
const hooks = next.hooks;
|
|
53
|
+
expect(hooks.SessionStart).toBeDefined();
|
|
54
|
+
expect(hooks.SessionEnd).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
it("is idempotent — re-running setup does not fire the hook twice", () => {
|
|
57
|
+
const once = applyTelemetrySettings(userSettings(), INSTALL);
|
|
58
|
+
const twice = applyTelemetrySettings(once, INSTALL);
|
|
59
|
+
const ours = (twice.hooks.SessionStart ?? [])
|
|
60
|
+
.flatMap((matcher) => matcher.hooks)
|
|
61
|
+
.filter((entry) => entry.command === "clocktopus agent hook");
|
|
62
|
+
expect(ours).toHaveLength(1);
|
|
63
|
+
});
|
|
64
|
+
it("runs the hook out of band, so telemetry never delays session start", () => {
|
|
65
|
+
const next = applyTelemetrySettings({}, INSTALL);
|
|
66
|
+
const entry = next.hooks.SessionStart[0].hooks[0];
|
|
67
|
+
expect(entry.async).toBe(true);
|
|
68
|
+
// Above the hook's own 4s request timeout, so a slow network leaves the
|
|
69
|
+
// hook's recorded failure behind instead of being killed without trace.
|
|
70
|
+
expect(entry.timeout).toBe(10);
|
|
71
|
+
});
|
|
72
|
+
it("replaces the pre-CLI script hook instead of firing both", () => {
|
|
73
|
+
// Anyone who wired this up before the CLI existed points at a script
|
|
74
|
+
// inside a checkout of this repo. Leaving it installed alongside the new
|
|
75
|
+
// hook would POST every SessionStart twice.
|
|
76
|
+
const legacy = {
|
|
77
|
+
hooks: {
|
|
78
|
+
SessionStart: [
|
|
79
|
+
{
|
|
80
|
+
hooks: [
|
|
81
|
+
{
|
|
82
|
+
type: "command",
|
|
83
|
+
command: "node /home/me/Projects/clocktopus/scripts/agent-telemetry/claude-hook.mjs",
|
|
84
|
+
timeout: 10,
|
|
85
|
+
async: true,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
const next = applyTelemetrySettings(legacy, INSTALL);
|
|
93
|
+
const commands = next.hooks.SessionStart.flatMap((matcher) => matcher.hooks.map((entry) => entry.command));
|
|
94
|
+
expect(commands).toEqual(["clocktopus agent hook"]);
|
|
95
|
+
});
|
|
96
|
+
it("replaces our hook when the command changes, rather than adding one", () => {
|
|
97
|
+
const first = applyTelemetrySettings({}, INSTALL);
|
|
98
|
+
const moved = applyTelemetrySettings(first, {
|
|
99
|
+
...INSTALL,
|
|
100
|
+
hookCommand: "/usr/local/bin/node /opt/clocktopus/cli.js agent hook",
|
|
101
|
+
});
|
|
102
|
+
const commands = (moved.hooks.SessionEnd ?? []).flatMap((matcher) => matcher.hooks.map((entry) => entry.command));
|
|
103
|
+
expect(commands).toEqual([
|
|
104
|
+
"/usr/local/bin/node /opt/clocktopus/cli.js agent hook",
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
describe("buildTelemetryEnv", () => {
|
|
109
|
+
it("configures the exporter the only way the receiver accepts", () => {
|
|
110
|
+
const env = buildTelemetryEnv(INSTALL);
|
|
111
|
+
// Protobuf gets an explicit 415 from the receiver.
|
|
112
|
+
expect(env.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/json");
|
|
113
|
+
expect(env.OTEL_EXPORTER_OTLP_HEADERS).toBe("Authorization=Bearer ctop_agt_testtoken");
|
|
114
|
+
// Without the session id the metric stream cannot be joined to the hook
|
|
115
|
+
// stream, and every session loses its repository and branch.
|
|
116
|
+
expect(env.OTEL_METRICS_INCLUDE_SESSION_ID).toBe("true");
|
|
117
|
+
});
|
|
118
|
+
it("normalises a trailing slash so paths do not double up", () => {
|
|
119
|
+
const env = buildTelemetryEnv({
|
|
120
|
+
...INSTALL,
|
|
121
|
+
endpoint: "https://otel.example.com/",
|
|
122
|
+
});
|
|
123
|
+
expect(env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://otel.example.com");
|
|
124
|
+
expect(env.CLOCKTOPUS_OTEL_ENDPOINT).toBe("https://otel.example.com");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe("removeTelemetrySettings", () => {
|
|
128
|
+
it("removes only what setup added", () => {
|
|
129
|
+
const installed = applyTelemetrySettings(userSettings(), INSTALL);
|
|
130
|
+
const { settings, removedEnvKeys, removedHooks } = removeTelemetrySettings(installed);
|
|
131
|
+
expect(removedHooks).toBe(true);
|
|
132
|
+
expect(removedEnvKeys).toContain("CLOCKTOPUS_INGEST_TOKEN");
|
|
133
|
+
expect(settings.model).toBe("opus");
|
|
134
|
+
expect(settings.env.MY_OWN_VAR).toBe("keep-me");
|
|
135
|
+
expect(settings.env.CLOCKTOPUS_INGEST_TOKEN).toBeUndefined();
|
|
136
|
+
const sessionStart = settings.hooks.SessionStart;
|
|
137
|
+
expect(sessionStart?.flatMap((m) => m.hooks.map((h) => h.command))).toEqual(["echo user-session-start"]);
|
|
138
|
+
expect(settings.hooks.PreToolUse).toBeDefined();
|
|
139
|
+
});
|
|
140
|
+
it("drops the env and hooks keys entirely when nothing else used them", () => {
|
|
141
|
+
const installed = applyTelemetrySettings({ model: "opus" }, INSTALL);
|
|
142
|
+
const { settings } = removeTelemetrySettings(installed);
|
|
143
|
+
expect(settings.env).toBeUndefined();
|
|
144
|
+
expect(settings.hooks).toBeUndefined();
|
|
145
|
+
expect(settings.model).toBe("opus");
|
|
146
|
+
});
|
|
147
|
+
it("reports nothing removed when telemetry was never installed", () => {
|
|
148
|
+
const { removedEnvKeys, removedHooks } = removeTelemetrySettings(userSettings());
|
|
149
|
+
expect(removedEnvKeys).toEqual([]);
|
|
150
|
+
expect(removedHooks).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe("reading and writing the file", () => {
|
|
154
|
+
let dir;
|
|
155
|
+
let path;
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
dir = mkdtempSync(join(tmpdir(), "clocktopus-settings-"));
|
|
158
|
+
path = join(dir, "settings.json");
|
|
159
|
+
});
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
rmSync(dir, { recursive: true, force: true });
|
|
162
|
+
});
|
|
163
|
+
it("refuses to read malformed JSON rather than overwrite it", () => {
|
|
164
|
+
// A broken settings file is far more likely to be a half-finished edit
|
|
165
|
+
// than something to clobber.
|
|
166
|
+
writeFileSync(path, '{ "model": "opus",,, }', "utf8");
|
|
167
|
+
expect(() => readSettings(path)).toThrow(SettingsParseError);
|
|
168
|
+
});
|
|
169
|
+
it("treats a missing file as empty settings", () => {
|
|
170
|
+
const result = readSettings(path);
|
|
171
|
+
expect(result.exists).toBe(false);
|
|
172
|
+
expect(result.settings).toEqual({});
|
|
173
|
+
});
|
|
174
|
+
it("backs up the previous file before replacing it", () => {
|
|
175
|
+
writeFileSync(path, JSON.stringify(userSettings()), "utf8");
|
|
176
|
+
const { backupPath } = writeSettings(applyTelemetrySettings(readSettings(path).settings, INSTALL), path);
|
|
177
|
+
expect(backupPath).toBe(`${path}.clocktopus-backup`);
|
|
178
|
+
expect(JSON.parse(readFileSync(backupPath, "utf8"))).toEqual(userSettings());
|
|
179
|
+
});
|
|
180
|
+
it("round-trips what setup installed", () => {
|
|
181
|
+
writeSettings(applyTelemetrySettings(userSettings(), INSTALL), path);
|
|
182
|
+
const installed = readInstalledTelemetry(path);
|
|
183
|
+
expect(installed.env.CLOCKTOPUS_INGEST_TOKEN).toBe("ctop_agt_testtoken");
|
|
184
|
+
expect(installed.hookCommands.SessionStart).toBe("clocktopus agent hook");
|
|
185
|
+
expect(installed.hookCommands.SessionEnd).toBe("clocktopus agent hook");
|
|
186
|
+
});
|
|
187
|
+
it("reports no telemetry for a settings file that only has the user's own hooks", () => {
|
|
188
|
+
writeFileSync(path, JSON.stringify(userSettings()), "utf8");
|
|
189
|
+
const installed = readInstalledTelemetry(path);
|
|
190
|
+
expect(installed.env).toEqual({});
|
|
191
|
+
expect(installed.hookCommands.SessionStart).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
});
|