@blastin-dev/clocktopus-cli 0.1.4 → 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 +108 -5
- 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/index.d.ts.map +1 -1
- package/dist/src/index.js +37 -2
- 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/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 +6 -4
package/dist/src/index.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { disableCommand } from "./commands/agent/disable.js";
|
|
5
|
+
import { doctorCommand } from "./commands/agent/doctor.js";
|
|
6
|
+
import { hookCommand } from "./commands/agent/hook.js";
|
|
7
|
+
import { setupCommand } from "./commands/agent/setup.js";
|
|
8
|
+
import { statusCommand } from "./commands/agent/status.js";
|
|
4
9
|
import { clockInCommand, clockOutCommand, clockStatusCommand, } from "./commands/clock.js";
|
|
5
10
|
import { loginCommand } from "./commands/login.js";
|
|
6
11
|
import { logoutCommand } from "./commands/logout.js";
|
|
@@ -16,12 +21,12 @@ program
|
|
|
16
21
|
.name("clocktopus")
|
|
17
22
|
.description("CLI for Clocktopus time tracking")
|
|
18
23
|
.version(version)
|
|
19
|
-
.option("-e, --env <environment>", "Use environment (dev or prod)")
|
|
24
|
+
.option("-e, --env <environment>", "Use environment (dev, staging or prod)")
|
|
20
25
|
.hook("preAction", (thisCommand) => {
|
|
21
26
|
const opts = thisCommand.opts();
|
|
22
27
|
if (opts.env) {
|
|
23
28
|
if (!(opts.env in ENVIRONMENTS)) {
|
|
24
|
-
console.error(`Invalid environment: ${opts.env}. Use 'dev' or 'prod'.`);
|
|
29
|
+
console.error(`Invalid environment: ${opts.env}. Use 'dev', 'staging' or 'prod'.`);
|
|
25
30
|
process.exit(1);
|
|
26
31
|
}
|
|
27
32
|
setRuntimeEnvironment(opts.env);
|
|
@@ -60,6 +65,36 @@ clock
|
|
|
60
65
|
.description("Show clock signals for a specific date")
|
|
61
66
|
.option("-d, --date <date>", "Date in YYYY-MM-DD format (default: today)")
|
|
62
67
|
.action(clockStatusCommand);
|
|
68
|
+
// Agent telemetry — tracks what AI agents cost, alongside human time.
|
|
69
|
+
const agent = program
|
|
70
|
+
.command("agent")
|
|
71
|
+
.description("Track AI agent spend from this machine");
|
|
72
|
+
agent
|
|
73
|
+
.command("setup")
|
|
74
|
+
.description("Point Claude Code at Clocktopus and install the session hooks")
|
|
75
|
+
.option("--name <name>", "Label for this machine's ingest token")
|
|
76
|
+
.option("--force", "Mint a replacement token instead of reusing the existing one")
|
|
77
|
+
.action((options) => setupCommand(options));
|
|
78
|
+
agent
|
|
79
|
+
.command("status")
|
|
80
|
+
.description("Show what the receiver has actually received")
|
|
81
|
+
.action(statusCommand);
|
|
82
|
+
agent
|
|
83
|
+
.command("doctor")
|
|
84
|
+
.description("Check every link in the telemetry chain and report failures")
|
|
85
|
+
.action(doctorCommand);
|
|
86
|
+
agent
|
|
87
|
+
.command("disable")
|
|
88
|
+
.description("Remove this machine's telemetry configuration")
|
|
89
|
+
.option("--revoke", "Also revoke the ingest token, everywhere")
|
|
90
|
+
.action((options) => disableCommand(options));
|
|
91
|
+
// Invoked by Claude Code, never by a person: it reads the hook payload from
|
|
92
|
+
// stdin and must not write to stdout. Hidden so it does not read as
|
|
93
|
+
// something to run by hand.
|
|
94
|
+
agent
|
|
95
|
+
.command("hook", { hidden: true })
|
|
96
|
+
.description("Internal: Claude Code SessionStart/SessionEnd hook")
|
|
97
|
+
.action(hookCommand);
|
|
63
98
|
export function run() {
|
|
64
99
|
program.parse();
|
|
65
100
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the ingest credentials come from, and what shadows what.
|
|
3
|
+
*
|
|
4
|
+
* The order matters and is not arbitrary. Inside a Claude Code session the
|
|
5
|
+
* process environment is what the exporter and the hook actually read, and
|
|
6
|
+
* `settings.json` `env` is applied *over* the inherited shell environment —
|
|
7
|
+
* so a value in settings.json wins over one exported from `.envrc` or a
|
|
8
|
+
* shell profile, silently. That is the failure this resolver is built to
|
|
9
|
+
* make visible rather than to paper over: it reports the source alongside
|
|
10
|
+
* the value so `doctor` can name the file that is winning.
|
|
11
|
+
*/
|
|
12
|
+
export type CredentialSource = "environment" | "settings" | "none";
|
|
13
|
+
export type ResolvedAgentCredentials = {
|
|
14
|
+
token: string | null;
|
|
15
|
+
endpoint: string | null;
|
|
16
|
+
tokenSource: CredentialSource;
|
|
17
|
+
endpointSource: CredentialSource;
|
|
18
|
+
};
|
|
19
|
+
export declare function resolveAgentCredentials(): ResolvedAgentCredentials;
|
|
20
|
+
/** Masks a token for display: never print more than the stored prefix. */
|
|
21
|
+
export declare function maskToken(token: string): string;
|
|
22
|
+
export declare function findShadowedExports(cwd?: string): Array<{
|
|
23
|
+
path: string;
|
|
24
|
+
keys: string[];
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* The command to write into settings.json for the hook.
|
|
28
|
+
*
|
|
29
|
+
* Prefers the bare name, but only after confirming that `clocktopus` on
|
|
30
|
+
* PATH resolves to *this* executable. Hooks run through a shell whose PATH
|
|
31
|
+
* may differ from the interactive one, and a bare name that does not
|
|
32
|
+
* resolve there fails silently — the session runs fine and every bit of
|
|
33
|
+
* repository context is lost. An absolute path is uglier and survives that;
|
|
34
|
+
* `doctor` checks it still exists.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveHookCommand(): {
|
|
37
|
+
command: string;
|
|
38
|
+
usesAbsolutePath: boolean;
|
|
39
|
+
binaryPath: string | null;
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=agent-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-config.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-config.ts"],"names":[],"mappings":"AAYA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,UAAU,GAAG,MAAM,CAAC;AAEnE,MAAM,MAAM,wBAAwB,GAAG;IACrC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,gBAAgB,CAAC;IAC9B,cAAc,EAAE,gBAAgB,CAAC;CAClC,CAAC;AAEF,wBAAgB,uBAAuB,IAAI,wBAAwB,CA2BlE;AAED,0EAA0E;AAC1E,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/C;AAmBD,wBAAgB,mBAAmB,CAAC,GAAG,SAAgB,GAAG,KAAK,CAAC;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB,CAAC,CA+BD;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,IAAI;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CA8BA"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, readFileSync, realpathSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { delimiter, join, resolve } from "node:path";
|
|
4
|
+
import { readInstalledTelemetry } from "./claude-settings.js";
|
|
5
|
+
export function resolveAgentCredentials() {
|
|
6
|
+
const envToken = process.env.CLOCKTOPUS_INGEST_TOKEN?.trim();
|
|
7
|
+
const envEndpoint = process.env.CLOCKTOPUS_OTEL_ENDPOINT?.trim();
|
|
8
|
+
let settingsToken;
|
|
9
|
+
let settingsEndpoint;
|
|
10
|
+
try {
|
|
11
|
+
const installed = readInstalledTelemetry();
|
|
12
|
+
settingsToken = installed.env.CLOCKTOPUS_INGEST_TOKEN;
|
|
13
|
+
settingsEndpoint = installed.env.CLOCKTOPUS_OTEL_ENDPOINT;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// A malformed settings.json is reported properly by `setup` and
|
|
17
|
+
// `doctor`. Credential resolution must not throw — the hook calls it on
|
|
18
|
+
// every session start.
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
token: envToken || settingsToken || null,
|
|
22
|
+
endpoint: (envEndpoint || settingsEndpoint || null)?.replace(/\/$/, "") ?? null,
|
|
23
|
+
tokenSource: envToken ? "environment" : settingsToken ? "settings" : "none",
|
|
24
|
+
endpointSource: envEndpoint
|
|
25
|
+
? "environment"
|
|
26
|
+
: settingsEndpoint
|
|
27
|
+
? "settings"
|
|
28
|
+
: "none",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Masks a token for display: never print more than the stored prefix. */
|
|
32
|
+
export function maskToken(token) {
|
|
33
|
+
return token.length <= 15 ? "…" : `${token.slice(0, 15)}…`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Files that commonly export the same variables, and would be shadowed.
|
|
37
|
+
*
|
|
38
|
+
* Scanned rather than guessed at, because "your `.envrc` is being ignored"
|
|
39
|
+
* is only useful advice when it names the file. The repo-local `.envrc` is
|
|
40
|
+
* included because that is exactly how this project was dogfooded before
|
|
41
|
+
* the CLI existed.
|
|
42
|
+
*/
|
|
43
|
+
const SHELL_FILES = [
|
|
44
|
+
".bashrc",
|
|
45
|
+
".bash_profile",
|
|
46
|
+
".zshrc",
|
|
47
|
+
".zshenv",
|
|
48
|
+
".profile",
|
|
49
|
+
".config/fish/config.fish",
|
|
50
|
+
];
|
|
51
|
+
export function findShadowedExports(cwd = process.cwd()) {
|
|
52
|
+
const candidates = [
|
|
53
|
+
...SHELL_FILES.map((file) => join(homedir(), file)),
|
|
54
|
+
join(cwd, ".envrc"),
|
|
55
|
+
];
|
|
56
|
+
const found = [];
|
|
57
|
+
for (const path of candidates) {
|
|
58
|
+
let contents;
|
|
59
|
+
try {
|
|
60
|
+
if (!existsSync(path))
|
|
61
|
+
continue;
|
|
62
|
+
contents = readFileSync(path, "utf8");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const keys = [
|
|
68
|
+
"CLOCKTOPUS_INGEST_TOKEN",
|
|
69
|
+
"CLOCKTOPUS_OTEL_ENDPOINT",
|
|
70
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
71
|
+
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
72
|
+
"CLAUDE_CODE_ENABLE_TELEMETRY",
|
|
73
|
+
].filter((key) => new RegExp(`^\\s*(export\\s+)?${key}=`, "m").test(contents));
|
|
74
|
+
if (keys.length > 0)
|
|
75
|
+
found.push({ path, keys });
|
|
76
|
+
}
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The command to write into settings.json for the hook.
|
|
81
|
+
*
|
|
82
|
+
* Prefers the bare name, but only after confirming that `clocktopus` on
|
|
83
|
+
* PATH resolves to *this* executable. Hooks run through a shell whose PATH
|
|
84
|
+
* may differ from the interactive one, and a bare name that does not
|
|
85
|
+
* resolve there fails silently — the session runs fine and every bit of
|
|
86
|
+
* repository context is lost. An absolute path is uglier and survives that;
|
|
87
|
+
* `doctor` checks it still exists.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveHookCommand() {
|
|
90
|
+
const script = process.argv[1] ? resolve(process.argv[1]) : null;
|
|
91
|
+
const quote = (value) => /[\s"']/.test(value) ? `"${value}"` : value;
|
|
92
|
+
if (script && resolvesOnPath("clocktopus", script)) {
|
|
93
|
+
return {
|
|
94
|
+
command: "clocktopus agent hook",
|
|
95
|
+
usesAbsolutePath: false,
|
|
96
|
+
binaryPath: script,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (script) {
|
|
100
|
+
// `node <script>` rather than executing the script directly: the file in
|
|
101
|
+
// a published package keeps its shebang, but a checkout may not have the
|
|
102
|
+
// executable bit, and the hook must not depend on that.
|
|
103
|
+
return {
|
|
104
|
+
command: `${quote(process.execPath)} ${quote(script)} agent hook`,
|
|
105
|
+
usesAbsolutePath: true,
|
|
106
|
+
binaryPath: script,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
command: "clocktopus agent hook",
|
|
111
|
+
usesAbsolutePath: false,
|
|
112
|
+
binaryPath: null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function resolvesOnPath(name, expected) {
|
|
116
|
+
const searchPath = process.env.PATH;
|
|
117
|
+
if (!searchPath)
|
|
118
|
+
return false;
|
|
119
|
+
let expectedReal;
|
|
120
|
+
try {
|
|
121
|
+
expectedReal = realpathSync(expected);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
for (const dir of searchPath.split(delimiter)) {
|
|
127
|
+
if (!dir)
|
|
128
|
+
continue;
|
|
129
|
+
const candidate = join(dir, name);
|
|
130
|
+
try {
|
|
131
|
+
accessSync(candidate, constants.X_OK);
|
|
132
|
+
if (realpathSync(candidate) === expectedReal)
|
|
133
|
+
return true;
|
|
134
|
+
// A different `clocktopus` earlier on PATH would shadow this one, so
|
|
135
|
+
// stop rather than keep looking for a match further down.
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const LastRunSchema: z.ZodObject<{
|
|
3
|
+
at: z.ZodString;
|
|
4
|
+
event: z.ZodOptional<z.ZodString>;
|
|
5
|
+
sessionId: z.ZodOptional<z.ZodString>;
|
|
6
|
+
endpoint: z.ZodOptional<z.ZodString>;
|
|
7
|
+
tokenPrefix: z.ZodOptional<z.ZodString>;
|
|
8
|
+
status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
9
|
+
error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
10
|
+
repository: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
export type HookLastRun = z.infer<typeof LastRunSchema>;
|
|
13
|
+
declare const StartStateSchema: z.ZodObject<{
|
|
14
|
+
sha: z.ZodString;
|
|
15
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
16
|
+
}, z.core.$strip>;
|
|
17
|
+
export type HookStartState = z.infer<typeof StartStateSchema>;
|
|
18
|
+
export declare function writeStartState(sessionId: string, sha: string, cwd: string): void;
|
|
19
|
+
export declare function readStartState(sessionId: string): HookStartState | undefined;
|
|
20
|
+
export declare function clearStartState(sessionId: string): void;
|
|
21
|
+
export declare function listStartStates(): Array<{
|
|
22
|
+
sessionId: string;
|
|
23
|
+
ageMs: number;
|
|
24
|
+
/**
|
|
25
|
+
* When the state file was last touched — the newest moment this machine
|
|
26
|
+
* has evidence the session existed. The sweep sends it as the abandoned
|
|
27
|
+
* session's `ended_at` so the receiver does not stamp its own clock,
|
|
28
|
+
* hours later, as the end of a window used to attribute commits.
|
|
29
|
+
*/
|
|
30
|
+
lastActivityAt: Date;
|
|
31
|
+
}>;
|
|
32
|
+
export declare function recordLastRun(run: HookLastRun): void;
|
|
33
|
+
export declare function readLastRun(): HookLastRun | null;
|
|
34
|
+
export declare function clearHookState(): void;
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=agent-hook-state.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-hook-state.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-hook-state.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA6BxB,QAAA,MAAM,aAAa;;;;;;;;;iBAUjB,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAExD,QAAA,MAAM,gBAAgB;;;iBAGpB,CAAC;AAEH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAK9D,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,GACV,IAAI,CAWN;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAgB5E;AAED,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAMvD;AAED,wBAAgB,eAAe,IAAI,KAAK,CAAC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,cAAc,EAAE,IAAI,CAAC;CACtB,CAAC,CAsBD;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAOpD;AAED,wBAAgB,WAAW,IAAI,WAAW,GAAG,IAAI,CAShD;AAED,wBAAgB,cAAc,IAAI,IAAI,CAOrC"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
/**
|
|
6
|
+
* On-disk state the SessionStart/SessionEnd hook keeps between its two
|
|
7
|
+
* invocations, plus the record of what the last invocation achieved.
|
|
8
|
+
*
|
|
9
|
+
* Under the cache directory rather than the repository so it never shows up
|
|
10
|
+
* in `git status`. The path is unchanged from the standalone script this
|
|
11
|
+
* command replaced (`scripts/agent-telemetry/claude-hook.mjs`, since
|
|
12
|
+
* deleted) on purpose: a session that started under the old script must
|
|
13
|
+
* still be able to close under the new one, and a sweep must still find
|
|
14
|
+
* what the old one abandoned.
|
|
15
|
+
*/
|
|
16
|
+
const CACHE_DIR = join(homedir(), ".cache", "clocktopus");
|
|
17
|
+
const STATE_DIR = join(CACHE_DIR, "agent-sessions");
|
|
18
|
+
/**
|
|
19
|
+
* The hook's last outcome, written every run.
|
|
20
|
+
*
|
|
21
|
+
* This is the only place the pipeline reports on itself from the machine's
|
|
22
|
+
* own side. The hook must stay silent — it cannot warn, prompt or print —
|
|
23
|
+
* so instead it leaves a receipt, and `agent status` / `agent doctor` read
|
|
24
|
+
* it. Without this, a token that started returning 401 mid-week is
|
|
25
|
+
* invisible locally: the session runs normally and nothing is written
|
|
26
|
+
* anywhere the user looks.
|
|
27
|
+
*/
|
|
28
|
+
const LAST_RUN_FILE = join(CACHE_DIR, "agent-hook-last.json");
|
|
29
|
+
const LastRunSchema = z.object({
|
|
30
|
+
at: z.string(),
|
|
31
|
+
event: z.string().optional(),
|
|
32
|
+
sessionId: z.string().optional(),
|
|
33
|
+
endpoint: z.string().optional(),
|
|
34
|
+
tokenPrefix: z.string().optional(),
|
|
35
|
+
/** HTTP status from the receiver, or null when the request never landed. */
|
|
36
|
+
status: z.number().nullable().optional(),
|
|
37
|
+
error: z.string().nullable().optional(),
|
|
38
|
+
repository: z.string().nullable().optional(),
|
|
39
|
+
});
|
|
40
|
+
const StartStateSchema = z.object({
|
|
41
|
+
sha: z.string(),
|
|
42
|
+
cwd: z.string().optional(),
|
|
43
|
+
});
|
|
44
|
+
const stateFile = (sessionId) => join(STATE_DIR, `${sessionId.replace(/[^\w-]/g, "")}.sha`);
|
|
45
|
+
export function writeStartState(sessionId, sha, cwd) {
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
48
|
+
// `cwd` is stored alongside the SHA because the sweep runs from
|
|
49
|
+
// whatever repository the *next* session happens to start in.
|
|
50
|
+
// Resolving an abandoned session's SHA against the wrong checkout would
|
|
51
|
+
// either fail or, worse, succeed against an unrelated history.
|
|
52
|
+
writeFileSync(stateFile(sessionId), JSON.stringify({ sha, cwd }), "utf8");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Losing the start SHA costs the exact commit list, not the session.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function readStartState(sessionId) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = readFileSync(stateFile(sessionId), "utf8").trim();
|
|
61
|
+
if (!raw)
|
|
62
|
+
return undefined;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = StartStateSchema.safeParse(JSON.parse(raw));
|
|
65
|
+
return parsed.success ? parsed.data : undefined;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Files written by earlier versions hold a bare SHA. A session that
|
|
69
|
+
// started under the old format must still close correctly.
|
|
70
|
+
return { sha: raw };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function clearStartState(sessionId) {
|
|
78
|
+
try {
|
|
79
|
+
rmSync(stateFile(sessionId), { force: true });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Best effort; a stale file is overwritten by the next session anyway.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function listStartStates() {
|
|
86
|
+
try {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
return readdirSync(STATE_DIR)
|
|
89
|
+
.filter((name) => name.endsWith(".sha"))
|
|
90
|
+
.flatMap((name) => {
|
|
91
|
+
try {
|
|
92
|
+
const { mtimeMs } = statSync(join(STATE_DIR, name));
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
sessionId: name.replace(/\.sha$/, ""),
|
|
96
|
+
ageMs: now - mtimeMs,
|
|
97
|
+
lastActivityAt: new Date(mtimeMs),
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function recordLastRun(run) {
|
|
111
|
+
try {
|
|
112
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
113
|
+
writeFileSync(LAST_RUN_FILE, JSON.stringify(run, null, 2), "utf8");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// A missing receipt degrades diagnostics; it must never fail a session.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function readLastRun() {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = LastRunSchema.safeParse(JSON.parse(readFileSync(LAST_RUN_FILE, "utf8")));
|
|
122
|
+
return parsed.success ? parsed.data : null;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export function clearHookState() {
|
|
129
|
+
try {
|
|
130
|
+
rmSync(STATE_DIR, { recursive: true, force: true });
|
|
131
|
+
rmSync(LAST_RUN_FILE, { force: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Nothing to clean up, or not ours to clean up.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
export type ReceiverCheck = {
|
|
12
|
+
ok: true;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
reason: "invalid_token";
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
reason: "unexpected_status";
|
|
19
|
+
status: number;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
reason: "unreachable";
|
|
23
|
+
message: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function verifyReceiver(endpoint: string, token: string): Promise<ReceiverCheck>;
|
|
26
|
+
//# sourceMappingURL=agent-receiver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-receiver.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-receiver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,MAAM,MAAM,aAAa,GACrB;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GACZ;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GACtC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1D,wBAAsB,cAAc,CAClC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,CAAC,CA+BxB"}
|
|
@@ -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
|
+
}
|
|
@@ -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"}
|