@blastin-dev/clocktopus-cli 0.1.4 → 0.2.1

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 (64) hide show
  1. package/README.md +163 -5
  2. package/dist/src/commands/agent/disable.d.ts +21 -0
  3. package/dist/src/commands/agent/disable.d.ts.map +1 -0
  4. package/dist/src/commands/agent/disable.js +106 -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 +306 -0
  8. package/dist/src/commands/agent/hook.d.ts +5 -0
  9. package/dist/src/commands/agent/hook.d.ts.map +1 -0
  10. package/dist/src/commands/agent/hook.js +368 -0
  11. package/dist/src/commands/agent/setup.d.ts +28 -0
  12. package/dist/src/commands/agent/setup.d.ts.map +1 -0
  13. package/dist/src/commands/agent/setup.js +333 -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 +182 -0
  17. package/dist/src/index.d.ts.map +1 -1
  18. package/dist/src/index.js +51 -2
  19. package/dist/src/lib/agent-config.d.ts +56 -0
  20. package/dist/src/lib/agent-config.d.ts.map +1 -0
  21. package/dist/src/lib/agent-config.js +168 -0
  22. package/dist/src/lib/agent-hook-state.d.ts +44 -0
  23. package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
  24. package/dist/src/lib/agent-hook-state.js +155 -0
  25. package/dist/src/lib/agent-receiver.d.ts +26 -0
  26. package/dist/src/lib/agent-receiver.d.ts.map +1 -0
  27. package/dist/src/lib/agent-receiver.js +44 -0
  28. package/dist/src/lib/agents.d.ts +115 -0
  29. package/dist/src/lib/agents.d.ts.map +1 -0
  30. package/dist/src/lib/agents.js +245 -0
  31. package/dist/src/lib/claude-settings.d.ts +82 -0
  32. package/dist/src/lib/claude-settings.d.ts.map +1 -0
  33. package/dist/src/lib/claude-settings.js +271 -0
  34. package/dist/src/lib/claude-settings.test.d.ts +2 -0
  35. package/dist/src/lib/claude-settings.test.d.ts.map +1 -0
  36. package/dist/src/lib/claude-settings.test.js +193 -0
  37. package/dist/src/lib/codex-config.d.ts +166 -0
  38. package/dist/src/lib/codex-config.d.ts.map +1 -0
  39. package/dist/src/lib/codex-config.js +441 -0
  40. package/dist/src/lib/codex-config.test.d.ts +2 -0
  41. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  42. package/dist/src/lib/codex-config.test.js +359 -0
  43. package/dist/src/lib/config.d.ts +23 -0
  44. package/dist/src/lib/config.d.ts.map +1 -1
  45. package/dist/src/lib/config.js +14 -0
  46. package/dist/src/lib/format.d.ts +6 -0
  47. package/dist/src/lib/format.d.ts.map +1 -0
  48. package/dist/src/lib/format.js +19 -0
  49. package/dist/src/lib/git.d.ts +3 -0
  50. package/dist/src/lib/git.d.ts.map +1 -0
  51. package/dist/src/lib/git.js +30 -0
  52. package/dist/src/lib/opencode-config.d.ts +108 -0
  53. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  54. package/dist/src/lib/opencode-config.js +330 -0
  55. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  56. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  57. package/dist/src/lib/opencode-config.test.js +140 -0
  58. package/dist/src/lib/repo-guidance.d.ts +40 -0
  59. package/dist/src/lib/repo-guidance.d.ts.map +1 -0
  60. package/dist/src/lib/repo-guidance.js +123 -0
  61. package/dist/src/lib/validators.d.ts +69 -0
  62. package/dist/src/lib/validators.d.ts.map +1 -1
  63. package/dist/src/lib/validators.js +69 -0
  64. package/package.json +7 -4
package/README.md CHANGED
@@ -47,10 +47,10 @@ clocktopus clock (in|out) [--ago <duration> | --at <time>]
47
47
  Record the start or end of a work session. Defaults to "now" when no
48
48
  flag is passed.
49
49
 
50
- | Option | Description |
51
- | --- | --- |
50
+ | Option | Description |
51
+ | ------------------ | --------------------------------------------------------------------------------------- |
52
52
  | `--ago <duration>` | Backdate by a duration from now (`15m`, `1h`, `1h30m`). Mutually exclusive with `--at`. |
53
- | `--at <time>` | Backdate to an absolute wall-clock time today (`HH:mm` or `HH:mm:ss`). |
53
+ | `--at <time>` | Backdate to an absolute wall-clock time today (`HH:mm` or `HH:mm:ss`). |
54
54
 
55
55
  ### `clocktopus clock status`
56
56
 
@@ -60,10 +60,162 @@ clocktopus clock status [-d <YYYY-MM-DD>]
60
60
 
61
61
  Show clock signals for a given date.
62
62
 
63
- | Option | Description |
64
- | --- | --- |
63
+ | Option | Description |
64
+ | ------------------- | --------------------------------------------------------------------------- |
65
65
  | `-d, --date <date>` | Show signals for a specific date in `YYYY-MM-DD` format. Defaults to today. |
66
66
 
67
+ ### `clocktopus agent setup`
68
+
69
+ ```
70
+ clocktopus agent setup [--name <label>] [--force] [--agent <ids>]
71
+ ```
72
+
73
+ Configures this machine to report AI agent spend — token cost, active time,
74
+ and the repository each session worked in — so the true cost of a piece of
75
+ work can be measured as human time × rate **plus** agent spend.
76
+
77
+ One command, however many agents you have. It looks for each supported CLI
78
+ on PATH and configures what it finds:
79
+
80
+ ```
81
+ Agents on this machine
82
+
83
+ 1 Claude Code 2.1.234 already configured
84
+ 2 Codex CLI 0.147.0 not configured
85
+
86
+ Which should report to Clocktopus? [all]:
87
+ ```
88
+
89
+ With one agent installed there is no question to ask, so it just runs. With
90
+ several it prompts; `--agent` skips the prompt, and a non-interactive shell
91
+ configures everything found and says so.
92
+
93
+ | Agent | `--agent` id | Writes |
94
+ | ----------- | ------------ | --------------------------------------------- |
95
+ | Claude Code | `claude` | `~/.claude/settings.json` |
96
+ | Codex CLI | `codex` | `~/.codex/config.toml`, `~/.codex/hooks.json` |
97
+ | OpenCode | `opencode` | `~/.config/opencode/plugin/clocktopus.js` |
98
+
99
+ It mints **one** ingest token for the machine, writes each agent's exporter
100
+ configuration and `SessionStart` / `SessionEnd` hooks, then confirms the
101
+ receiver accepts the token before reporting success.
102
+
103
+ | Option | Description |
104
+ | ---------------- | --------------------------------------------------------------------------------------------------------------- |
105
+ | `--name <label>` | Label for this machine's token. Defaults to the hostname — keep it recognisable, since revocation is per token. |
106
+ | `--force` | Mint a replacement token instead of reusing the one already installed. |
107
+ | `-a, --agent` | Configure only these agents. Repeatable, or comma-separated: `--agent claude,codex`. |
108
+
109
+ Re-running without `--force` is safe and idempotent: it repairs the hooks and
110
+ refreshes the endpoint while keeping the existing token. Installing a second
111
+ agent later and re-running costs nothing — the new one inherits the token the
112
+ first is already using rather than minting a second against the same machine.
113
+
114
+ **Restart the agent afterwards.** Every exporter and hook reads its
115
+ configuration once, at process start, so a session that is already open will
116
+ not pick any of it up. This is the most common reason a correct setup appears
117
+ to do nothing.
118
+
119
+ **OpenCode is a plugin, not a hook.** It has no hook system, so setup
120
+ writes one generated file into `~/.config/opencode/plugin/`, which OpenCode
121
+ auto-loads. Your `opencode.json` is not touched — that file is your model,
122
+ provider and permission configuration, and leaving it alone removes a whole
123
+ class of ways to break your setup. Uninstalling is deleting the file.
124
+
125
+ OpenCode also has `experimental.openTelemetry`, which looks like an easier
126
+ route and is not one: its spans carry your prompts and the model's replies
127
+ verbatim, with no way to switch that off, and no cost or repository. The
128
+ plugin reads OpenCode's own per-message totals instead and sends counts and
129
+ identifiers only.
130
+
131
+ **Codex needs one extra step, once.** It will not run a `hooks.json` it has
132
+ not been shown, so the next Codex session asks — answer _Trust all and
133
+ continue_. Until you do, Codex reports what your sessions cost but not which
134
+ repository they were spent on. `clocktopus agent doctor` reports the state
135
+ until it is approved.
136
+
137
+ Everything for an agent lands in that agent's own config rather than a shell
138
+ profile. That single source of truth per agent is what lets `agent doctor`
139
+ say which value is actually in force. Note the ingest token is stored there
140
+ in plaintext: if you keep `~/.claude` or `~/.codex` in a dotfiles repository,
141
+ ignore the file.
142
+
143
+ Setup then checks the repository you ran it in, because configuring the
144
+ machine is only half the job — sessions will arrive either way, but two
145
+ things decide whether they mean anything, and neither is visible locally:
146
+
147
+ | Check | If it fails |
148
+ | -------------------------------- | -------------------------------------------------------------------------------- |
149
+ | Repository attached to a project | Spend is recorded, but lands in the unattributed row instead of against a client |
150
+ | Push webhook delivering | No human time entries, so there is nothing to compare the spend against |
151
+
152
+ The second does **not** stop agent→commit links — the SessionEnd hook
153
+ resolves `git rev-list before..after` locally and posts the list, so those
154
+ are created with no webhook involved. What is missing is the other side of
155
+ the comparison: true cost is human time × rate _plus_ agent spend, and one
156
+ term alone is not a smaller answer.
157
+
158
+ Both are reported separately, with the exact next step, because they send
159
+ you to two different places. Setup only tells you — it never changes your
160
+ GitHub settings. Note the webhook URL is **per person, not per repository**,
161
+ so on a shared repo each teammate adds their own.
162
+
163
+ ### `clocktopus agent status`
164
+
165
+ Shows what the receiver has actually received. Local configuration is a
166
+ claim; this is the evidence.
167
+
168
+ The two ingest lanes are reported separately because they fail separately:
169
+
170
+ | Lane | Carries | If it alone goes quiet |
171
+ | ----- | -------------------------------- | -------------------------------------------------------- |
172
+ | Hook | repository, branch, commit range | spend is recorded but cannot be attributed to a project |
173
+ | Spend | tokens, cost, active time | sessions show `$0.00`, indistinguishable from cheap work |
174
+
175
+ It also lists every supported agent found on this machine, including any
176
+ that are installed but _not_ reporting — a state the server-side lanes
177
+ cannot show, because those sessions simply never arrive.
178
+
179
+ That second failure is why there is no single "connected" line — it is the
180
+ one that silently understates what work cost.
181
+
182
+ ### `clocktopus agent doctor`
183
+
184
+ Checks every link in the chain, per agent, and names the first one that is
185
+ broken: the config file parses, the exporter is configured, both hooks are
186
+ installed, the hook command actually executes, Codex has approved its hooks,
187
+ the receiver accepts the token, no other file is shadowing the
188
+ configuration, and whether that agent needs restarting. The repository
189
+ checks — attached to a project, delivering commits — run once at the end,
190
+ since they are facts about the repo rather than about any one agent.
191
+
192
+ The receiver check is the one that cannot be done locally — it crosses the
193
+ network to a different host than the dashboard, so a wrong endpoint and a
194
+ revoked token are otherwise indistinguishable from an idle afternoon.
195
+
196
+ ### `clocktopus agent disable`
197
+
198
+ ```
199
+ clocktopus agent disable [--revoke] [--agent <ids>]
200
+ ```
201
+
202
+ Removes this machine's telemetry configuration from every agent, leaving any
203
+ hooks and settings you added yourself untouched.
204
+
205
+ | Option | Description |
206
+ | ------------- | --------------------------------------------------------------------------- |
207
+ | `--revoke` | Also revoke the ingest token, making it useless everywhere. |
208
+ | `-a, --agent` | Remove only these agents (`claude`, `codex`), leaving the others reporting. |
209
+
210
+ `--revoke` is ignored when combined with `--agent`: the token is shared by
211
+ every agent on the machine, so killing it while another one still has it
212
+ written would leave that agent posting into a 401 forever — which looks
213
+ like an idle week rather than a broken install.
214
+
215
+ Stopping and revoking are separate on purpose: removing the local
216
+ configuration stops _this_ machine reporting, but does nothing about a token
217
+ that has already been copied elsewhere or committed.
218
+
67
219
  ## Token Storage
68
220
 
69
221
  Credentials are stored in a platform-specific config directory:
@@ -74,3 +226,9 @@ ig.json`
74
226
  - **Windows**: `%APPDATA%/clocktopus-cli-nodejs/config.json`
75
227
 
76
228
  Run `clocktopus logout` to clear stored credentials.
229
+
230
+ The agent telemetry setup deliberately keeps **no** copy of the ingest token
231
+ here. It has to live in each agent's own config for that agent's exporter to
232
+ read it, and a second copy would be another thing to leak and another thing
233
+ to fall out of date. Only the token id (needed to revoke) and its display
234
+ prefix are kept.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Removes this machine's telemetry configuration, from every agent.
3
+ *
4
+ * Stopping and revoking are separate on purpose. Removing the local
5
+ * configuration stops *this* machine reporting; it does nothing about a
6
+ * token that has already been copied elsewhere or committed to a dotfiles
7
+ * repository. `--revoke` is the one that makes the token useless everywhere,
8
+ * and it is opt-in because the common case — moving machines, reinstalling —
9
+ * should not invalidate a token that CI or another workstation still uses.
10
+ *
11
+ * `--agent` narrows it to one, which is the only way to stop reporting from
12
+ * Codex while leaving Claude Code alone. Without it every agent is cleaned,
13
+ * including ones no longer installed — an uninstalled agent's config file
14
+ * outlives it, and a token left in a file nobody looks at is the worst
15
+ * place for one to sit.
16
+ */
17
+ export declare function disableCommand(options: {
18
+ revoke?: boolean;
19
+ agent?: string[];
20
+ }): Promise<void>;
21
+ //# sourceMappingURL=disable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"disable.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/disable.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyFhB"}
@@ -0,0 +1,106 @@
1
+ import { clearHookState } from "../../lib/agent-hook-state.js";
2
+ import { isConfigParseError, surveyAgents } from "../../lib/agents.js";
3
+ import { ApiError, request } from "../../lib/api.js";
4
+ import { clearAgentConfig, getAgentConfig, isLoggedIn, } from "../../lib/config.js";
5
+ /**
6
+ * Removes this machine's telemetry configuration, from every agent.
7
+ *
8
+ * Stopping and revoking are separate on purpose. Removing the local
9
+ * configuration stops *this* machine reporting; it does nothing about a
10
+ * token that has already been copied elsewhere or committed to a dotfiles
11
+ * repository. `--revoke` is the one that makes the token useless everywhere,
12
+ * and it is opt-in because the common case — moving machines, reinstalling —
13
+ * should not invalidate a token that CI or another workstation still uses.
14
+ *
15
+ * `--agent` narrows it to one, which is the only way to stop reporting from
16
+ * Codex while leaving Claude Code alone. Without it every agent is cleaned,
17
+ * including ones no longer installed — an uninstalled agent's config file
18
+ * outlives it, and a token left in a file nobody looks at is the worst
19
+ * place for one to sit.
20
+ */
21
+ export async function disableCommand(options) {
22
+ const survey = surveyAgents();
23
+ const requested = (options.agent ?? []).flatMap((value) => value.split(",").map((part) => part.trim().toLowerCase()));
24
+ if (requested.length > 0) {
25
+ const unknown = requested.filter((id) => !survey.some((entry) => entry.agent.id === id));
26
+ if (unknown.length > 0) {
27
+ console.error(`Unknown agent: ${unknown.join(", ")}. Valid ids: ${survey
28
+ .map((entry) => entry.agent.id)
29
+ .join(", ")}.`);
30
+ process.exit(1);
31
+ }
32
+ }
33
+ const targets = requested.length > 0
34
+ ? survey.filter((entry) => requested.includes(entry.agent.id))
35
+ : survey;
36
+ const cleaned = [];
37
+ for (const entry of targets) {
38
+ try {
39
+ const { removed } = entry.agent.remove();
40
+ if (removed.length > 0)
41
+ cleaned.push({ label: entry.agent.label, removed });
42
+ }
43
+ catch (error) {
44
+ if (isConfigParseError(error)) {
45
+ console.error(`✗ ${error.message}`);
46
+ process.exit(1);
47
+ }
48
+ // A failure on one agent must not leave the others reporting.
49
+ console.error(`✗ ${entry.agent.label}: ${error instanceof Error ? error.message : "unknown error"}`);
50
+ }
51
+ }
52
+ const agent = getAgentConfig();
53
+ // Revoking while another agent on this machine still has the token
54
+ // written would leave that one posting into a 401 forever, which looks
55
+ // like an idle week rather than a broken install.
56
+ const partial = requested.length > 0 && requested.length < survey.length;
57
+ if (options.revoke && partial) {
58
+ console.log("⚠ --revoke was ignored: it kills the token everywhere, and this run only\n" +
59
+ " cleaned some of the agents on this machine. Re-run without --agent to\n" +
60
+ " remove all of them, then revoke.");
61
+ }
62
+ else if (options.revoke) {
63
+ await revokeToken(agent);
64
+ }
65
+ if (!partial) {
66
+ clearAgentConfig();
67
+ clearHookState();
68
+ }
69
+ if (cleaned.length === 0) {
70
+ console.log("Nothing to remove — agent telemetry was not configured here.");
71
+ return;
72
+ }
73
+ for (const { label, removed } of cleaned) {
74
+ console.log(`✓ ${label}: removed ${removed.join(", ")}.`);
75
+ }
76
+ if (!options.revoke || partial) {
77
+ console.log("\nThe ingest token is still valid. Revoke it with\n" +
78
+ " clocktopus agent disable --revoke");
79
+ }
80
+ console.log(`\nRestart ${cleaned
81
+ .map((c) => c.label)
82
+ .join(" and ")} to stop the current session reporting.`);
83
+ }
84
+ async function revokeToken(agent) {
85
+ if (!agent.tokenId) {
86
+ console.log("⚠ No token id on record, so nothing was revoked. This machine's token\n" +
87
+ " was minted by an older version or on another machine — revoke it\n" +
88
+ " from the dashboard instead.");
89
+ return;
90
+ }
91
+ if (!isLoggedIn()) {
92
+ console.log("⚠ Not logged in, so the token could not be revoked. Run 'clocktopus\n" +
93
+ " login' and try again — the local configuration was still removed.");
94
+ return;
95
+ }
96
+ try {
97
+ await request(`/api/agent/ingest-token?tokenId=${encodeURIComponent(agent.tokenId)}`, { method: "DELETE" });
98
+ console.log(`✓ Revoked ${agent.tokenPrefix ?? "the ingest token"}.`);
99
+ }
100
+ catch (error) {
101
+ console.error(`✗ Could not revoke the token: ${error instanceof ApiError || error instanceof Error
102
+ ? error.message
103
+ : "unknown error"}`);
104
+ console.error(" The local configuration was still removed.");
105
+ }
106
+ }
@@ -0,0 +1,2 @@
1
+ export declare function doctorCommand(): Promise<void>;
2
+ //# sourceMappingURL=doctor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/doctor.ts"],"names":[],"mappings":"AAuCA,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CA8CnD"}
@@ -0,0 +1,306 @@
1
+ import { execSync } from "node:child_process";
2
+ import { isAfter, parseISO } from "date-fns";
3
+ import { findShadowedExports, maskToken, resolveAgentCredentials, } from "../../lib/agent-config.js";
4
+ import { readLastRun } from "../../lib/agent-hook-state.js";
5
+ import { verifyReceiver } from "../../lib/agent-receiver.js";
6
+ import { isConfigParseError, surveyAgents } from "../../lib/agents.js";
7
+ import { isLoggedIn } from "../../lib/config.js";
8
+ import { formatAgo } from "../../lib/format.js";
9
+ import { fetchRepoStatus, repoChecks } from "../../lib/repo-guidance.js";
10
+ export async function doctorCommand() {
11
+ const sections = [];
12
+ sections.push({
13
+ title: "This machine",
14
+ checks: [
15
+ {
16
+ label: "Logged in",
17
+ ok: isLoggedIn(),
18
+ detail: isLoggedIn() ? "yes" : "no",
19
+ fix: "clocktopus login",
20
+ },
21
+ ],
22
+ });
23
+ const survey = surveyAgents();
24
+ const relevant = survey.filter((entry) => entry.installed || entry.configured || entry.unreadable);
25
+ if (relevant.length === 0) {
26
+ sections[0].checks.push({
27
+ label: "Agents",
28
+ ok: false,
29
+ detail: `none of ${survey.map((e) => e.agent.binary).join(", ")} found on PATH`,
30
+ fix: "Install a supported agent, then run 'clocktopus agent setup'",
31
+ });
32
+ }
33
+ for (const entry of relevant) {
34
+ sections.push({
35
+ title: `${entry.agent.label}${entry.version ? ` ${entry.version}` : ""}`,
36
+ checks: await agentChecks(entry),
37
+ });
38
+ }
39
+ // Past this point the pipeline works; these decide whether what it carries
40
+ // can be turned into a number. Both are server-side facts about the
41
+ // repository, not about any one agent.
42
+ const tail = [];
43
+ const repoStatus = await fetchRepoStatus();
44
+ if (repoStatus)
45
+ tail.push(...repoChecks(repoStatus));
46
+ tail.push(checkLastHookRun());
47
+ sections.push({ title: "Repository and delivery", checks: tail });
48
+ report(sections);
49
+ }
50
+ async function agentChecks(entry) {
51
+ const checks = [];
52
+ if (entry.unreadable) {
53
+ return [
54
+ {
55
+ label: "Config",
56
+ ok: false,
57
+ detail: "could not be parsed",
58
+ fix: entry.unreadable,
59
+ },
60
+ ];
61
+ }
62
+ checks.push({
63
+ label: "Installed",
64
+ ok: entry.installed ? true : "warn",
65
+ detail: entry.installed
66
+ ? `${entry.agent.binary} ${entry.version ?? ""}`.trim()
67
+ : `${entry.agent.binary} is not on PATH`,
68
+ fix: "Config here is harmless, but nothing will report until it is installed",
69
+ });
70
+ let state;
71
+ try {
72
+ state = entry.agent.read();
73
+ }
74
+ catch (error) {
75
+ if (!isConfigParseError(error))
76
+ throw error;
77
+ return [
78
+ {
79
+ label: "Config",
80
+ ok: false,
81
+ detail: "could not be parsed",
82
+ fix: error.message,
83
+ },
84
+ ];
85
+ }
86
+ checks.push({
87
+ label: "Config",
88
+ ok: state.token !== null,
89
+ detail: state.token !== null
90
+ ? `${state.paths.join(", ")} (modified ${formatAgo(state.modifiedAt?.toISOString() ?? null)})`
91
+ : `not configured in ${state.paths.join(", ")}`,
92
+ fix: `clocktopus agent setup --agent ${entry.agent.id}`,
93
+ });
94
+ // Both events are required, and for different reasons: SessionStart is the
95
+ // only source of the *before* SHA and of `cwd`, SessionEnd is the only
96
+ // source of the exact commit list. One without the other silently
97
+ // degrades what can be attributed.
98
+ const hookStart = state.hookCommands.SessionStart;
99
+ const hookEnd = state.hookCommands.SessionEnd;
100
+ checks.push({
101
+ label: "Hooks installed",
102
+ ok: Boolean(hookStart && hookEnd),
103
+ detail: hookStart && hookEnd
104
+ ? `SessionStart, SessionEnd → ${hookStart}`
105
+ : `missing ${[!hookStart && "SessionStart", !hookEnd && "SessionEnd"]
106
+ .filter(Boolean)
107
+ .join(", ")}`,
108
+ fix: `clocktopus agent setup --agent ${entry.agent.id}`,
109
+ });
110
+ if (hookStart)
111
+ checks.push(checkHookRuns(hookStart));
112
+ // A warning, not a failure: a plugin from an older generation still
113
+ // reports. What it does not have is whatever the newer one fixed, and
114
+ // nothing else in this report would ever say so — the token is valid, the
115
+ // hook resolves, the receiver answers.
116
+ const stale = entry.agent.staleReason?.() ?? null;
117
+ if (stale) {
118
+ checks.push({
119
+ label: "Generated config",
120
+ ok: "warn",
121
+ detail: stale,
122
+ fix: `clocktopus agent setup --agent ${entry.agent.id}`,
123
+ });
124
+ }
125
+ for (const action of entry.agent.pendingActions()) {
126
+ checks.push({
127
+ label: "Hooks enabled",
128
+ ok: "warn",
129
+ detail: "installed but not yet approved by the agent",
130
+ fix: action,
131
+ });
132
+ }
133
+ const credentials = resolveAgentCredentials(entry.agent.id);
134
+ checks.push({
135
+ label: "Token in force",
136
+ ok: credentials.token !== null,
137
+ detail: credentials.token
138
+ ? `${maskToken(credentials.token)} (from ${credentials.tokenSource === "settings"
139
+ ? credentials.sourcePath
140
+ : credentials.tokenSource})`
141
+ : "none resolved",
142
+ fix: `clocktopus agent setup --agent ${entry.agent.id}`,
143
+ });
144
+ if (credentials.token && credentials.endpoint) {
145
+ const check = await verifyReceiver(credentials.endpoint, credentials.token);
146
+ checks.push({
147
+ label: "Receiver",
148
+ ok: check.ok,
149
+ detail: check.ok
150
+ ? `${credentials.endpoint} accepted the token`
151
+ : check.reason === "invalid_token"
152
+ ? `${credentials.endpoint} rejected the token (401)`
153
+ : check.reason === "unexpected_status"
154
+ ? `${credentials.endpoint} answered HTTP ${check.status}`
155
+ : `${credentials.endpoint} unreachable — ${check.message}`,
156
+ fix: check.ok || check.reason !== "invalid_token"
157
+ ? "Check the endpoint is reachable from this network"
158
+ : "clocktopus agent setup --force (mints a replacement token)",
159
+ });
160
+ }
161
+ // Only Claude Code applies config `env` over the inherited environment,
162
+ // so only it can be shadowed by a shell export.
163
+ if (entry.agent.id === "claude")
164
+ checks.push(checkShadowedExports());
165
+ checks.push(checkRestartNeeded(entry, state.modifiedAt));
166
+ return checks;
167
+ }
168
+ /**
169
+ * Runs the installed hook command with empty stdin.
170
+ *
171
+ * This is a genuine no-op — the hook returns immediately when stdin carries
172
+ * no payload — so it sends nothing and records nothing, while still proving
173
+ * the exact command line in the agent's config resolves and executes. Worth
174
+ * checking directly: hooks run under a shell whose PATH may differ from the
175
+ * interactive one, and a command that fails to resolve there fails
176
+ * silently, costing every session its repository context.
177
+ */
178
+ function checkHookRuns(command) {
179
+ try {
180
+ execSync(command, {
181
+ input: "",
182
+ timeout: 15_000,
183
+ stdio: ["pipe", "ignore", "ignore"],
184
+ });
185
+ return {
186
+ label: "Hook executes",
187
+ ok: true,
188
+ detail: "command resolved and ran",
189
+ };
190
+ }
191
+ catch (error) {
192
+ return {
193
+ label: "Hook executes",
194
+ ok: false,
195
+ detail: `'${command}' failed — ${error instanceof Error ? error.message.split("\n")[0] : "unknown error"}`,
196
+ fix: "clocktopus agent setup (rewrites the hook with a resolvable path)",
197
+ };
198
+ }
199
+ }
200
+ function checkShadowedExports() {
201
+ const shadowed = findShadowedExports();
202
+ if (shadowed.length === 0) {
203
+ return {
204
+ label: "Config conflicts",
205
+ ok: true,
206
+ detail: "settings.json is the only source",
207
+ };
208
+ }
209
+ return {
210
+ label: "Config conflicts",
211
+ ok: "warn",
212
+ detail: shadowed
213
+ .map(({ path, keys }) => `${path} exports ${keys.join(", ")}`)
214
+ .join("; "),
215
+ fix: "settings.json is applied over the inherited environment, so these are\n" +
216
+ " ignored inside Claude Code. Remove them — two sources that\n" +
217
+ " disagree is how spend lands on the wrong account.",
218
+ };
219
+ }
220
+ /**
221
+ * Did the configuration change after the last session used it?
222
+ *
223
+ * The exporter and the hook both read their configuration once, at process
224
+ * start, so a session already open when setup ran is still using the old
225
+ * values. This is the most common reason a correct configuration looks dead.
226
+ *
227
+ * Scoped to the agent whose hook last ran: one receipt file is shared by all
228
+ * of them, and comparing Claude Code's last run against Codex's config
229
+ * mtime would report a restart that has already happened, or miss one that
230
+ * has not.
231
+ */
232
+ function checkRestartNeeded(entry, configModifiedAt) {
233
+ const lastRun = readLastRun();
234
+ const ranThisAgent = lastRun?.provider === entry.agent.provider;
235
+ if (!configModifiedAt || !lastRun?.at || !ranThisAgent) {
236
+ return {
237
+ label: "Restart",
238
+ ok: "warn",
239
+ detail: `${entry.agent.label} has not run a hook since this configuration was written`,
240
+ fix: `Restart ${entry.agent.label}, then run 'clocktopus agent status'`,
241
+ };
242
+ }
243
+ const ranAfterConfig = isAfter(parseISO(lastRun.at), configModifiedAt);
244
+ return {
245
+ label: "Restart",
246
+ ok: ranAfterConfig ? true : "warn",
247
+ detail: ranAfterConfig
248
+ ? "a session has run since the last configuration change"
249
+ : "the config changed after the last session started",
250
+ fix: `Restart ${entry.agent.label} so the exporter and hook pick up the new values`,
251
+ };
252
+ }
253
+ function checkLastHookRun() {
254
+ const lastRun = readLastRun();
255
+ if (!lastRun) {
256
+ return {
257
+ label: "Last hook run",
258
+ ok: "warn",
259
+ detail: "no record — no hook has run on this machine",
260
+ fix: "Restart your agent and start a session",
261
+ };
262
+ }
263
+ const failed = lastRun.status === null ||
264
+ lastRun.status === undefined ||
265
+ lastRun.status < 200 ||
266
+ lastRun.status >= 300;
267
+ const who = lastRun.provider ? ` (${lastRun.provider})` : "";
268
+ return {
269
+ label: "Last hook run",
270
+ ok: !failed,
271
+ detail: failed
272
+ ? `${formatAgo(lastRun.at)}${who} — ${lastRun.status ? `HTTP ${lastRun.status}` : (lastRun.error ?? "no response")}`
273
+ : `${formatAgo(lastRun.at)}${who} — HTTP ${lastRun.status}`,
274
+ fix: lastRun.status === 401
275
+ ? "clocktopus agent setup --force (the token was rejected)"
276
+ : "Check network access to the receiver",
277
+ };
278
+ }
279
+ function report(sections) {
280
+ console.log("\nAgent telemetry diagnosis");
281
+ for (const section of sections) {
282
+ if (section.checks.length === 0)
283
+ continue;
284
+ console.log(`\n${section.title}\n`);
285
+ for (const check of section.checks) {
286
+ const symbol = check.ok === true ? "✓" : check.ok === "warn" ? "⚠" : "✗";
287
+ console.log(` ${symbol} ${check.label.padEnd(18)}${check.detail}`);
288
+ if (check.ok !== true && check.fix) {
289
+ console.log(` → ${check.fix}`);
290
+ }
291
+ }
292
+ }
293
+ const all = sections.flatMap((section) => section.checks);
294
+ const failures = all.filter((check) => check.ok === false).length;
295
+ const warnings = all.filter((check) => check.ok === "warn").length;
296
+ console.log("");
297
+ if (failures === 0 && warnings === 0) {
298
+ console.log("Everything checks out. 'clocktopus agent status' shows what has arrived.");
299
+ }
300
+ else {
301
+ console.log(`${failures} failing, ${warnings} to review. Fix the first failure above —\n` +
302
+ "the later ones are usually consequences of it.");
303
+ }
304
+ if (failures > 0)
305
+ process.exitCode = 1;
306
+ }
@@ -0,0 +1,5 @@
1
+ export declare function hookCommand(options: {
2
+ provider?: string;
3
+ detached?: boolean;
4
+ }): Promise<void>;
5
+ //# sourceMappingURL=hook.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hook.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/hook.ts"],"names":[],"mappings":"AAwaA,wBAAsB,WAAW,CAAC,OAAO,EAAE;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAahB"}