@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.
- package/README.md +163 -5
- package/dist/src/commands/agent/disable.d.ts +21 -0
- package/dist/src/commands/agent/disable.d.ts.map +1 -0
- package/dist/src/commands/agent/disable.js +106 -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 +306 -0
- package/dist/src/commands/agent/hook.d.ts +5 -0
- package/dist/src/commands/agent/hook.d.ts.map +1 -0
- package/dist/src/commands/agent/hook.js +368 -0
- package/dist/src/commands/agent/setup.d.ts +28 -0
- package/dist/src/commands/agent/setup.d.ts.map +1 -0
- package/dist/src/commands/agent/setup.js +333 -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 +182 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +51 -2
- package/dist/src/lib/agent-config.d.ts +56 -0
- package/dist/src/lib/agent-config.d.ts.map +1 -0
- package/dist/src/lib/agent-config.js +168 -0
- package/dist/src/lib/agent-hook-state.d.ts +44 -0
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
- package/dist/src/lib/agent-hook-state.js +155 -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/agents.d.ts +115 -0
- package/dist/src/lib/agents.d.ts.map +1 -0
- package/dist/src/lib/agents.js +245 -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/codex-config.d.ts +166 -0
- package/dist/src/lib/codex-config.d.ts.map +1 -0
- package/dist/src/lib/codex-config.js +441 -0
- package/dist/src/lib/codex-config.test.d.ts +2 -0
- package/dist/src/lib/codex-config.test.d.ts.map +1 -0
- package/dist/src/lib/codex-config.test.js +359 -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/opencode-config.d.ts +108 -0
- package/dist/src/lib/opencode-config.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.js +330 -0
- package/dist/src/lib/opencode-config.test.d.ts +2 -0
- package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.test.js +140 -0
- package/dist/src/lib/repo-guidance.d.ts +40 -0
- package/dist/src/lib/repo-guidance.d.ts.map +1 -0
- package/dist/src/lib/repo-guidance.js +123 -0
- package/dist/src/lib/validators.d.ts +69 -0
- package/dist/src/lib/validators.d.ts.map +1 -1
- package/dist/src/lib/validators.js +69 -0
- package/package.json +7 -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
|
|
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>`
|
|
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
|
|
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 @@
|
|
|
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 @@
|
|
|
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"}
|