@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.
Files changed (56) hide show
  1. package/README.md +124 -6
  2. package/dist/src/commands/agent/disable.d.ts +14 -0
  3. package/dist/src/commands/agent/disable.d.ts.map +1 -0
  4. package/dist/src/commands/agent/disable.js +72 -0
  5. package/dist/src/commands/agent/doctor.d.ts +2 -0
  6. package/dist/src/commands/agent/doctor.d.ts.map +1 -0
  7. package/dist/src/commands/agent/doctor.js +235 -0
  8. package/dist/src/commands/agent/hook.d.ts +2 -0
  9. package/dist/src/commands/agent/hook.d.ts.map +1 -0
  10. package/dist/src/commands/agent/hook.js +231 -0
  11. package/dist/src/commands/agent/setup.d.ts +21 -0
  12. package/dist/src/commands/agent/setup.d.ts.map +1 -0
  13. package/dist/src/commands/agent/setup.js +194 -0
  14. package/dist/src/commands/agent/status.d.ts +2 -0
  15. package/dist/src/commands/agent/status.d.ts.map +1 -0
  16. package/dist/src/commands/agent/status.js +160 -0
  17. package/dist/src/commands/clock.d.ts +26 -4
  18. package/dist/src/commands/clock.d.ts.map +1 -1
  19. package/dist/src/commands/clock.js +99 -6
  20. package/dist/src/commands/login.d.ts.map +1 -1
  21. package/dist/src/commands/login.js +5 -5
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/dist/src/index.js +45 -6
  24. package/dist/src/lib/agent-config.d.ts +41 -0
  25. package/dist/src/lib/agent-config.d.ts.map +1 -0
  26. package/dist/src/lib/agent-config.js +143 -0
  27. package/dist/src/lib/agent-hook-state.d.ts +36 -0
  28. package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
  29. package/dist/src/lib/agent-hook-state.js +136 -0
  30. package/dist/src/lib/agent-receiver.d.ts +26 -0
  31. package/dist/src/lib/agent-receiver.d.ts.map +1 -0
  32. package/dist/src/lib/agent-receiver.js +44 -0
  33. package/dist/src/lib/api.d.ts.map +1 -1
  34. package/dist/src/lib/api.js +28 -1
  35. package/dist/src/lib/claude-settings.d.ts +82 -0
  36. package/dist/src/lib/claude-settings.d.ts.map +1 -0
  37. package/dist/src/lib/claude-settings.js +271 -0
  38. package/dist/src/lib/claude-settings.test.d.ts +2 -0
  39. package/dist/src/lib/claude-settings.test.d.ts.map +1 -0
  40. package/dist/src/lib/claude-settings.test.js +193 -0
  41. package/dist/src/lib/config.d.ts +23 -0
  42. package/dist/src/lib/config.d.ts.map +1 -1
  43. package/dist/src/lib/config.js +14 -0
  44. package/dist/src/lib/format.d.ts +6 -0
  45. package/dist/src/lib/format.d.ts.map +1 -0
  46. package/dist/src/lib/format.js +19 -0
  47. package/dist/src/lib/git.d.ts +3 -0
  48. package/dist/src/lib/git.d.ts.map +1 -0
  49. package/dist/src/lib/git.js +30 -0
  50. package/dist/src/lib/repo-guidance.d.ts +40 -0
  51. package/dist/src/lib/repo-guidance.d.ts.map +1 -0
  52. package/dist/src/lib/repo-guidance.js +123 -0
  53. package/dist/src/lib/validators.d.ts +69 -0
  54. package/dist/src/lib/validators.d.ts.map +1 -1
  55. package/dist/src/lib/validators.js +69 -0
  56. package/package.json +7 -5
@@ -16,8 +16,30 @@ const ClockSignalResponseSchema = z.object({
16
16
  signalTimestamp: z.string(),
17
17
  alreadyExists: z.boolean(),
18
18
  blockedReason: z.enum(["already_clocked_in", "not_clocked_in"]).nullable(),
19
+ clamped: z.boolean().optional(),
20
+ requestedTime: z.string().nullable().optional(),
21
+ reopened: z.boolean().optional(),
19
22
  }),
20
23
  });
24
+ /**
25
+ * Parses "15m", "1h", "1h30m" → whole minutes. Mirrors
26
+ * `packages/core/src/utils/duration.ts` (the CLI is a standalone package
27
+ * and doesn't depend on @repo/core).
28
+ */
29
+ function parseDurationToMinutes(input) {
30
+ const trimmed = input.trim().toLowerCase();
31
+ const match = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(trimmed);
32
+ if (!match || (!match[1] && !match[2])) {
33
+ throw new Error(`Invalid duration "${input}". Use formats like 15m, 1h, or 1h30m.`);
34
+ }
35
+ const hours = match[1] ? Number(match[1]) : 0;
36
+ const minutes = match[2] ? Number(match[2]) : 0;
37
+ const total = hours * 60 + minutes;
38
+ if (total <= 0) {
39
+ throw new Error(`Duration must be greater than zero: "${input}".`);
40
+ }
41
+ return total;
42
+ }
21
43
  /**
22
44
  * Schema for clock signals list response
23
45
  */
@@ -44,14 +66,43 @@ function formatSignalType(type) {
44
66
  return type === "clock_in" ? "Clock In" : "Clock Out";
45
67
  }
46
68
  /**
47
- * Record a clock-in signal for today
69
+ * Record a clock-in signal for today.
70
+ *
71
+ * Accepts the same backdate flags as `clock out`:
72
+ * --ago <duration> e.g. "45m", "1h", "1h30m"
73
+ * --at <HH:mm> absolute wall-clock time in the user's timezone
74
+ *
75
+ * The server silently clamps the requested time if it would fall outside
76
+ * the allowed window (before start-of-today, before the day's last
77
+ * clock-out, or after the first existing time entry on the day).
48
78
  */
49
- export async function clockInCommand() {
79
+ export async function clockInCommand(options = {}) {
50
80
  if (!checkAuth()) {
51
81
  process.exit(1);
52
82
  }
83
+ if (options.ago && options.at) {
84
+ console.error("Cannot use --ago and --at together. Pick one.");
85
+ process.exit(1);
86
+ }
87
+ const body = { signalType: "clock_in" };
88
+ if (options.ago) {
89
+ try {
90
+ body.minutesAgo = parseDurationToMinutes(options.ago);
91
+ }
92
+ catch (err) {
93
+ console.error(err instanceof Error ? err.message : String(err));
94
+ process.exit(1);
95
+ }
96
+ }
97
+ if (options.at) {
98
+ if (!/^\d{2}:\d{2}(:\d{2})?$/.test(options.at)) {
99
+ console.error(`Invalid time "${options.at}". Use HH:mm or HH:mm:ss format.`);
100
+ process.exit(1);
101
+ }
102
+ body.effectiveTime = options.at;
103
+ }
53
104
  try {
54
- const data = await post("/api/clock-signal", { signalType: "clock_in" });
105
+ const data = await post("/api/clock-signal", body);
55
106
  const response = ClockSignalResponseSchema.parse(data);
56
107
  if (response.signal.alreadyExists) {
57
108
  console.log(`\nAlready clocked in - must clock out first.`);
@@ -60,11 +111,21 @@ export async function clockInCommand() {
60
111
  console.log(` Time: ${response.signal.effectiveTime}`);
61
112
  console.log(` Timezone: ${response.signal.timezone}`);
62
113
  }
114
+ else if (response.signal.reopened) {
115
+ console.log(`\nPrevious session reopened — clock-out removed.`);
116
+ console.log(` Clocked in since:`);
117
+ console.log(` Date: ${response.signal.workDate}`);
118
+ console.log(` Time: ${response.signal.effectiveTime}`);
119
+ console.log(` Timezone: ${response.signal.timezone}`);
120
+ }
63
121
  else {
64
122
  console.log(`\nClock In recorded!`);
65
123
  console.log(` Date: ${response.signal.workDate}`);
66
124
  console.log(` Time: ${response.signal.effectiveTime}`);
67
125
  console.log(` Timezone: ${response.signal.timezone}`);
126
+ if (response.signal.clamped && response.signal.requestedTime) {
127
+ console.log(` Adjusted: you asked for ${response.signal.requestedTime}, but that's outside the allowed window for this day.`);
128
+ }
68
129
  }
69
130
  }
70
131
  catch (error) {
@@ -83,14 +144,43 @@ export async function clockInCommand() {
83
144
  }
84
145
  }
85
146
  /**
86
- * Record a clock-out signal for today
147
+ * Record a clock-out signal for today.
148
+ *
149
+ * Accepts optional backdate flags:
150
+ * --ago <duration> e.g. "45m", "1h", "1h30m"
151
+ * --at <HH:mm> absolute wall-clock time in the user's timezone
152
+ *
153
+ * The two flags are mutually exclusive. The server silently clamps the
154
+ * requested time if it would predate the session's clock-in or the
155
+ * session's last time entry.
87
156
  */
88
- export async function clockOutCommand() {
157
+ export async function clockOutCommand(options = {}) {
89
158
  if (!checkAuth()) {
90
159
  process.exit(1);
91
160
  }
161
+ if (options.ago && options.at) {
162
+ console.error("Cannot use --ago and --at together. Pick one.");
163
+ process.exit(1);
164
+ }
165
+ const body = { signalType: "clock_out" };
166
+ if (options.ago) {
167
+ try {
168
+ body.minutesAgo = parseDurationToMinutes(options.ago);
169
+ }
170
+ catch (err) {
171
+ console.error(err instanceof Error ? err.message : String(err));
172
+ process.exit(1);
173
+ }
174
+ }
175
+ if (options.at) {
176
+ if (!/^\d{2}:\d{2}(:\d{2})?$/.test(options.at)) {
177
+ console.error(`Invalid time "${options.at}". Use HH:mm or HH:mm:ss format.`);
178
+ process.exit(1);
179
+ }
180
+ body.effectiveTime = options.at;
181
+ }
92
182
  try {
93
- const data = await post("/api/clock-signal", { signalType: "clock_out" });
183
+ const data = await post("/api/clock-signal", body);
94
184
  const response = ClockSignalResponseSchema.parse(data);
95
185
  if (response.signal.alreadyExists) {
96
186
  console.log(`\nNot clocked in - must clock in first.`);
@@ -106,6 +196,9 @@ export async function clockOutCommand() {
106
196
  console.log(` Date: ${response.signal.workDate}`);
107
197
  console.log(` Time: ${response.signal.effectiveTime}`);
108
198
  console.log(` Timezone: ${response.signal.timezone}`);
199
+ if (response.signal.clamped && response.signal.requestedTime) {
200
+ console.log(` Adjusted: you asked for ${response.signal.requestedTime}, but that's before this session's earliest allowed time.`);
201
+ }
109
202
  }
110
203
  }
111
204
  catch (error) {
@@ -1 +1 @@
1
- {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../../src/commands/login.ts"],"names":[],"mappings":"AAKA,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAsFlD"}
1
+ {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../../src/commands/login.ts"],"names":[],"mappings":"AAKA,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAmFlD"}
@@ -1,12 +1,12 @@
1
1
  import { get } from "../lib/api.js";
2
2
  import { pollForToken, requestDeviceCode, sleep } from "../lib/auth.js";
3
- import { isLoggedIn, setToken } from "../lib/config.js";
3
+ import { setToken } from "../lib/config.js";
4
4
  import { UserSchema } from "../lib/validators.js";
5
5
  export async function loginCommand() {
6
- if (isLoggedIn()) {
7
- console.log("You are already logged in. Use 'clocktopus logout' to log out first.");
8
- return;
9
- }
6
+ // If the user is already logged in we still start a fresh device flow
7
+ // `setToken` on success overwrites the existing token. We don't
8
+ // clear the old token up-front, so a failed/cancelled/timed-out login
9
+ // leaves the previously-valid session intact.
10
10
  console.log("Starting device authorization...\n");
11
11
  try {
12
12
  const deviceCode = await requestDeviceCode();
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AA4EA,wBAAgB,GAAG,IAAI,IAAI,CAE1B"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AA4IA,wBAAgB,GAAG,IAAI,IAAI,CAE1B"}
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);
@@ -45,17 +50,51 @@ const clock = program
45
50
  .description("Record clock-in and clock-out signals");
46
51
  clock
47
52
  .command("in")
48
- .description("Record clock-in for today")
49
- .action(clockInCommand);
53
+ .description("Record clock-in for today (optionally backdated)")
54
+ .option("--ago <duration>", "Backdate by a duration relative to now (e.g. 45m, 1h, 1h30m)")
55
+ .option("--at <time>", "Backdate to an absolute time in your timezone (HH:mm or HH:mm:ss)")
56
+ .action((options) => clockInCommand(options));
50
57
  clock
51
58
  .command("out")
52
- .description("Record clock-out for today")
53
- .action(clockOutCommand);
59
+ .description("Record clock-out for today (optionally backdated)")
60
+ .option("--ago <duration>", "Backdate by a duration relative to now (e.g. 45m, 1h, 1h30m)")
61
+ .option("--at <time>", "Backdate to an absolute time in your timezone (HH:mm or HH:mm:ss)")
62
+ .action((options) => clockOutCommand(options));
54
63
  clock
55
64
  .command("status")
56
65
  .description("Show clock signals for a specific date")
57
66
  .option("-d, --date <date>", "Date in YYYY-MM-DD format (default: today)")
58
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);
59
98
  export function run() {
60
99
  program.parse();
61
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"}