@trygocode/notify 0.2.0 → 0.3.2
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 +75 -1
- package/dist/src/cli.js +77 -1
- package/dist/src/doctor.js +262 -0
- package/dist/src/on_stop.js +417 -11
- package/dist/src/send.js +8 -1
- package/dist/src/status.js +18 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/snippets/ralph-homer.sh +329 -9
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ waiting for you**, **errors out**, or when an overnight **loop completes / halts
|
|
|
17
17
|
- [Auto-push to git (opt-in)](#auto-push-to-git-opt-in)
|
|
18
18
|
- [Settings sync — the `config` command](#settings-sync--the-config-command)
|
|
19
19
|
- [Ralph/Homer opt-in snippet (trigger C)](#ralphhomer-opt-in-snippet-trigger-c)
|
|
20
|
+
- [Notify from any custom script (the minimal one-liner)](#notify-from-any-custom-script-the-minimal-one-liner)
|
|
20
21
|
- [Troubleshooting](#troubleshooting)
|
|
21
22
|
- [Changelog](#changelog)
|
|
22
23
|
- [Develop](#develop)
|
|
@@ -125,6 +126,21 @@ The installed rule/skill tells the agent **not** to call the MCP tool for
|
|
|
125
126
|
done/idle/error pings — those are owned by the deterministic hook (A), so you
|
|
126
127
|
never get double-pinged.
|
|
127
128
|
|
|
129
|
+
> **Cursor `stop.status` → kind mapping (since 0.3.0).** Cursor's `stop` hook
|
|
130
|
+
> carries a `status` field in its stdin JSON. The `on-stop` dispatcher maps it
|
|
131
|
+
> to the right notification kind automatically:
|
|
132
|
+
>
|
|
133
|
+
> | Cursor `stop.status` | Notification kind | Meaning |
|
|
134
|
+
> |---|---|---|
|
|
135
|
+
> | `completed` | `finished` | Agent turned cleanly (default — same as before 0.3.0) |
|
|
136
|
+
> | `aborted` | `awaiting_input` | Agent yielded back to you — may need your input |
|
|
137
|
+
> | `error` | `error` | Agent hit an error |
|
|
138
|
+
> | absent / unrecognised | `finished` | Back-compatible fallback |
|
|
139
|
+
>
|
|
140
|
+
> Before 0.3.0, Cursor only ever sent `finished` regardless of what the agent
|
|
141
|
+
> did. The status mapping requires no hook re-installation — run
|
|
142
|
+
> `gocode-notify setup --force` once to apply the updated hook command.
|
|
143
|
+
|
|
128
144
|
> **OpenCode's hook (A)** is a small `session.idle` plugin written to
|
|
129
145
|
> `~/.config/opencode/plugin/gocode-notify.js`; on each `session.idle` event it
|
|
130
146
|
> fire-and-forgets `gocode-notify on-stop --source opencode` (the same
|
|
@@ -316,7 +332,7 @@ Valid keys (mirror of the canonical schema):
|
|
|
316
332
|
|
|
317
333
|
| Key | Type | Meaning |
|
|
318
334
|
|---|---|---|
|
|
319
|
-
| `kinds.finished` / `kinds.error` / `kinds.awaiting_input` / `kinds.loop_completed` / `kinds.loop_halted` | bool | Per-kind notification toggles |
|
|
335
|
+
| `kinds.finished` / `kinds.error` / `kinds.awaiting_input` / `kinds.loop_completed` / `kinds.loop_halted` / `kinds.ralph_waiting` | bool | Per-kind notification toggles |
|
|
320
336
|
| `min_duration_seconds` | int ≥ 0 | Only notify if the turn ran ≥ N seconds (0 = always) |
|
|
321
337
|
| `quiet_hours.enabled` / `quiet_hours.start` / `quiet_hours.end` / `quiet_hours.tz` | bool / `HH:MM` / `HH:MM` / IANA tz | Do-not-disturb window |
|
|
322
338
|
| `auto_push.enabled` | bool | Master auto-push switch (OFF by default) |
|
|
@@ -358,6 +374,41 @@ The ready-to-copy version with comments lives at
|
|
|
358
374
|
loop scripts. Both lines are fire-and-forget (`|| true` + the CLI's 5s
|
|
359
375
|
self-timeout), so a failed or slow push can never block or fail your loop.
|
|
360
376
|
|
|
377
|
+
## Notify from any custom script (the minimal one-liner)
|
|
378
|
+
|
|
379
|
+
Any shell script — a cron job, a `Makefile` target, a post-build hook, or
|
|
380
|
+
someone else's automation — can send a push with a single line, no configuration
|
|
381
|
+
beyond a one-time `gocode-notify login` pairing:
|
|
382
|
+
|
|
383
|
+
```bash
|
|
384
|
+
gocode-notify send --kind finished --source <name> || true
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Replace `<name>` with a short identifier for the script (e.g. `ci`, `build`,
|
|
388
|
+
`deploy`). The `|| true` guard ensures a failed or slow push **never** blocks the
|
|
389
|
+
calling script (the CLI also self-times-out in 5 seconds).
|
|
390
|
+
|
|
391
|
+
> **Discovery hint:** `gocode-notify status` always prints this line at the
|
|
392
|
+
> bottom of its report so you can copy-paste it even when you don't have the
|
|
393
|
+
> README handy.
|
|
394
|
+
|
|
395
|
+
**Other useful kinds for custom scripts:**
|
|
396
|
+
|
|
397
|
+
| Kind | When to use |
|
|
398
|
+
|---|---|
|
|
399
|
+
| `finished` | Script completed cleanly |
|
|
400
|
+
| `error` | Script hit an error |
|
|
401
|
+
| `awaiting_input` | Script paused; a human is needed |
|
|
402
|
+
| `loop_completed` | Long-running loop finished all work |
|
|
403
|
+
| `loop_halted` | Long-running loop stopped; human needed |
|
|
404
|
+
|
|
405
|
+
Pass `--title "My script"` and `--body "extra detail"` to customise the
|
|
406
|
+
notification text. Use `--project "$(basename "$PWD")"` to badge it with the
|
|
407
|
+
project name on your phone. All flags are optional — only `--kind` is required.
|
|
408
|
+
|
|
409
|
+
See `snippets/ralph-homer.sh` for the full Ralph/Homer lifecycle pattern
|
|
410
|
+
(completed / halted / stall-edge / resumed).
|
|
411
|
+
|
|
361
412
|
## Troubleshooting
|
|
362
413
|
|
|
363
414
|
**Start here:** `gocode-notify status` prints a one-screen report — whether
|
|
@@ -389,6 +440,29 @@ npm publish, real-device E2E), see
|
|
|
389
440
|
|
|
390
441
|
## Changelog
|
|
391
442
|
|
|
443
|
+
### 0.3.0
|
|
444
|
+
|
|
445
|
+
- **New: Cursor `stop.status` → kind mapping (T-CUR1/T-CUR4).** The `on-stop`
|
|
446
|
+
dispatcher now reads the Cursor `stop` hook's stdin JSON and maps `status` to
|
|
447
|
+
the right notification kind: `completed→finished`, `aborted→awaiting_input`,
|
|
448
|
+
`error→error`. Back-compatible: absent/unrecognised status → `finished`.
|
|
449
|
+
Run `gocode-notify setup --force` to pick up the updated hook command.
|
|
450
|
+
- **New: `ralph_waiting` + `ralph_resumed` kinds (T-C1).** `NOTIFY_KINDS` now
|
|
451
|
+
includes the Ralph/Homer offline-stall lifecycle kinds. `ralph_waiting` fires
|
|
452
|
+
once on the stall edge (server drops repeats until a recovery event re-arms
|
|
453
|
+
it); `ralph_resumed` is a silent control event that resets the stall state
|
|
454
|
+
machine. See `snippets/ralph-homer.sh` for the edge-trigger pattern.
|
|
455
|
+
- **Kind taxonomy (T-S1).** The server now classifies every kind into push-worthy
|
|
456
|
+
vs. silent-info buckets: `ralph_question` / `ralph_advanced` are never pushed
|
|
457
|
+
(Oracle-answerable questions are silent); `ralph_halted` / `ralph_completed` /
|
|
458
|
+
`ralph_waiting` (edge) are the only loop kinds that reach FCM.
|
|
459
|
+
- **Foreground suppression (T-S2/T-S3).** The server suppresses a push when the
|
|
460
|
+
app is open on that exact chat (`POST /api/v1/notify/presence` heartbeat, 45s
|
|
461
|
+
TTL). Different-chat or closed app → push goes out as normal.
|
|
462
|
+
- **Offline/stall debounce (T-S4).** Server-side edge-triggered state machine:
|
|
463
|
+
a loop retrying every 60s on a quota outage pushes once on stall and stays
|
|
464
|
+
silent until it recovers and stalls again.
|
|
465
|
+
|
|
392
466
|
### 0.2.0
|
|
393
467
|
|
|
394
468
|
- **New: `launch` / `autopilot` command** — hand a large, multi-step task off to
|
package/dist/src/cli.js
CHANGED
|
@@ -21,6 +21,7 @@ import { setup } from "./setup.js";
|
|
|
21
21
|
import { uninstall } from "./uninstall.js";
|
|
22
22
|
import { cmdConfig } from "./config.js";
|
|
23
23
|
import { onStop } from "./on_stop.js";
|
|
24
|
+
import { gatherDoctor, formatDoctor } from "./doctor.js";
|
|
24
25
|
/** Subcommands the finished CLI will expose (see PRD §4.1). */
|
|
25
26
|
export const COMMANDS = [
|
|
26
27
|
"login",
|
|
@@ -28,6 +29,7 @@ export const COMMANDS = [
|
|
|
28
29
|
"test",
|
|
29
30
|
"setup",
|
|
30
31
|
"status",
|
|
32
|
+
"doctor",
|
|
31
33
|
"mcp",
|
|
32
34
|
"uninstall",
|
|
33
35
|
"config",
|
|
@@ -38,6 +40,43 @@ export const COMMANDS = [
|
|
|
38
40
|
export const COMMAND_ALIASES = {
|
|
39
41
|
autopilot: "launch",
|
|
40
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* Read all available data from `process.stdin` when it is a pipe (not a TTY),
|
|
45
|
+
* returning the full content as a string. Best-effort: returns an empty string on
|
|
46
|
+
* any error or if stdin is a TTY (no data to consume). Used by {@link cmdOnStop}
|
|
47
|
+
* to consume the Cursor `stop` hook's JSON payload (T-CUR1 / PRD §8.5).
|
|
48
|
+
*
|
|
49
|
+
* The read is bounded to {@link MAX_STDIN_BYTES} to prevent a misbehaving hook
|
|
50
|
+
* from blocking with an enormous payload. The function resolves (never rejects)
|
|
51
|
+
* so it can never block the hook's turn.
|
|
52
|
+
*/
|
|
53
|
+
export const MAX_STDIN_BYTES = 65536; // 64 KiB — far more than any hook JSON
|
|
54
|
+
export async function readStdinIfPipe(stream) {
|
|
55
|
+
const src = stream ?? process.stdin;
|
|
56
|
+
// In a TTY / non-pipe context there is nothing to read — return early so we
|
|
57
|
+
// never block waiting for input on an interactive terminal.
|
|
58
|
+
if (src.isTTY)
|
|
59
|
+
return "";
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let totalBytes = 0;
|
|
63
|
+
src.on("data", (chunk) => {
|
|
64
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
65
|
+
totalBytes += buf.byteLength;
|
|
66
|
+
if (totalBytes <= MAX_STDIN_BYTES)
|
|
67
|
+
chunks.push(buf);
|
|
68
|
+
});
|
|
69
|
+
src.on("end", () => {
|
|
70
|
+
try {
|
|
71
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
resolve("");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
src.on("error", () => resolve(""));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
41
80
|
export function printHelp() {
|
|
42
81
|
console.log([
|
|
43
82
|
`gocode-notify v${VERSION}`,
|
|
@@ -52,6 +91,7 @@ export function printHelp() {
|
|
|
52
91
|
" test Send a canned test push",
|
|
53
92
|
" setup Pair + detect runtimes + write configs",
|
|
54
93
|
" status Report credentials / server / detected runtimes",
|
|
94
|
+
" doctor Self-diagnostic checklist with exact fix commands",
|
|
55
95
|
" mcp Run as an MCP server over stdio",
|
|
56
96
|
" uninstall Remove entries this tool added",
|
|
57
97
|
" config Get/set Notify settings (get | set <key> <value> | pull)",
|
|
@@ -324,6 +364,26 @@ export async function cmdStatus(args, deps = {}) {
|
|
|
324
364
|
}
|
|
325
365
|
return 0;
|
|
326
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Handle `gocode-notify doctor [--server URL]` (PRD §8.6 R10 / T-COV4).
|
|
369
|
+
* Prints a checklist: (1) paired? (2) server reachable? (3) which runner hooks
|
|
370
|
+
* installed (Cursor stop / Claude Stop + Notification)? (4) gocode-notify on
|
|
371
|
+
* PATH for non-login shells? Each ✗ item prints the exact fix command.
|
|
372
|
+
* Always exits 0 — it is an informational report, not a gate.
|
|
373
|
+
*/
|
|
374
|
+
export async function cmdDoctor(args, deps = {}) {
|
|
375
|
+
const flags = parseFlags(args);
|
|
376
|
+
const report = await gatherDoctor({
|
|
377
|
+
home: deps.home,
|
|
378
|
+
serverFlag: flagString(flags, "server") ?? deps.serverFlag,
|
|
379
|
+
fetchImpl: deps.fetchImpl,
|
|
380
|
+
timeoutMs: deps.timeoutMs,
|
|
381
|
+
pathEnv: deps.pathEnv,
|
|
382
|
+
});
|
|
383
|
+
for (const line of formatDoctor(report))
|
|
384
|
+
console.log(line);
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
327
387
|
/** True when a flag is present as a bare boolean or explicit `=true`. */
|
|
328
388
|
function flagBool(flags, name) {
|
|
329
389
|
const v = flags.get(name);
|
|
@@ -429,6 +489,17 @@ export async function cmdOnStop(args, deps = {}) {
|
|
|
429
489
|
const flags = parseFlags(args);
|
|
430
490
|
const agent = isAgentDriven(flags);
|
|
431
491
|
const sink = deps.sink ?? stdoutSink;
|
|
492
|
+
// Read the hook's stdin JSON (T-CUR1 / PRD §8.5). The Cursor `stop` hook pipes
|
|
493
|
+
// its event data to the process stdin; we consume it here so on-stop can map
|
|
494
|
+
// the `status` field to the correct notification kind. Best-effort: errors and
|
|
495
|
+
// TTY contexts return an empty string (falls back to `finished` in on_stop.ts).
|
|
496
|
+
//
|
|
497
|
+
// Injection priority: deps.hookStdin (test stub) > deps.readStdin (test reader)
|
|
498
|
+
// > real stdin read. An explicit empty string from deps.hookStdin means "no
|
|
499
|
+
// stdin provided" (do not read from the real process.stdin).
|
|
500
|
+
const hookStdin = deps.hookStdin !== undefined
|
|
501
|
+
? deps.hookStdin
|
|
502
|
+
: await (deps.readStdin ?? readStdinIfPipe)();
|
|
432
503
|
const server = await resolveServerUrl(flagString(flags, "server"), deps);
|
|
433
504
|
const run = deps.onStopImpl ?? onStop;
|
|
434
505
|
const result = await run({
|
|
@@ -441,6 +512,7 @@ export async function cmdOnStop(args, deps = {}) {
|
|
|
441
512
|
fetchImpl: deps.fetchImpl,
|
|
442
513
|
timeoutMs: deps.timeoutMs,
|
|
443
514
|
timestamp: deps.timestamp,
|
|
515
|
+
hookStdin,
|
|
444
516
|
});
|
|
445
517
|
const delivered = result.mode === "push"
|
|
446
518
|
? result.push?.notified === true
|
|
@@ -453,7 +525,9 @@ export async function cmdOnStop(args, deps = {}) {
|
|
|
453
525
|
? "dry-run: would send finished (auto-push off)"
|
|
454
526
|
: result.mode === "deduped"
|
|
455
527
|
? "deduped: another source already notified for this run"
|
|
456
|
-
:
|
|
528
|
+
: result.mode === "autopilot-suppressed"
|
|
529
|
+
? "autopilot-suppressed: an Autopilot loop owns this turn's ping"
|
|
530
|
+
: `finished ${delivered ? "delivered" : "not delivered"}`;
|
|
457
531
|
if (agent) {
|
|
458
532
|
sink({ step: "on-stop", ok: true, detail });
|
|
459
533
|
}
|
|
@@ -586,6 +660,8 @@ export async function runAsync(argv) {
|
|
|
586
660
|
return cmdTest(argv.slice(1));
|
|
587
661
|
if (cmd === "status")
|
|
588
662
|
return cmdStatus(argv.slice(1));
|
|
663
|
+
if (cmd === "doctor")
|
|
664
|
+
return cmdDoctor(argv.slice(1));
|
|
589
665
|
if (cmd === "setup")
|
|
590
666
|
return cmdSetup(argv.slice(1));
|
|
591
667
|
if (cmd === "mcp")
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// `gocode-notify doctor` — self-diagnostic checklist (PRD §8.6 R10 / T-COV4).
|
|
2
|
+
//
|
|
3
|
+
// Prints a human-readable checklist of the installation health so users know
|
|
4
|
+
// immediately what is missing and how to fix it. Checks (in order):
|
|
5
|
+
//
|
|
6
|
+
// 1. Paired? credentials exist and are valid.
|
|
7
|
+
// 2. Server reachable? the bound (or default) server answers.
|
|
8
|
+
// 3. Runner hooks installed? Claude Code Stop/Notification + Cursor stop.
|
|
9
|
+
// 4. `gocode-notify` on PATH? resolvable in non-login shells.
|
|
10
|
+
//
|
|
11
|
+
// Every ✗ check prints the EXACT fix command so users never have to guess.
|
|
12
|
+
// Always exits 0 — it is a report, not a gate.
|
|
13
|
+
//
|
|
14
|
+
// The gather / format split mirrors `status.ts`:
|
|
15
|
+
// `gatherDoctor` → pure async data collection (injectable deps, fully testable)
|
|
16
|
+
// `formatDoctor` → pure sync rendering to string lines
|
|
17
|
+
// `cmdDoctor` → thin CLI wire-up in `cli.ts`
|
|
18
|
+
//
|
|
19
|
+
// Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
|
|
20
|
+
import { promises as fs } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { readCredentials, resolveServerUrl } from "./creds.js";
|
|
23
|
+
import { probeServer, DEFAULT_PROBE_TIMEOUT_MS, UNPAIRED_WARNING } from "./status.js";
|
|
24
|
+
// Re-export UNPAIRED_WARNING so existing `from "./doctor.js"` imports keep
|
|
25
|
+
// working after the constant moved to status.ts (T-COV1).
|
|
26
|
+
export { UNPAIRED_WARNING } from "./status.js";
|
|
27
|
+
// ─── Hook markers ──────────────────────────────────────────────────────────
|
|
28
|
+
// We detect our hook entries by looking for these stable tokens in the command
|
|
29
|
+
// strings. The same tokens are used by claude.ts / cursor.ts for idempotent
|
|
30
|
+
// install / uninstall (kept in sync via the HOOK_SOURCE comments there).
|
|
31
|
+
/** Tokens that identify a gocode-notify Claude Code hook in settings.json. */
|
|
32
|
+
const CLAUDE_HOOK_TOKEN = "--source claude_code";
|
|
33
|
+
/** Token that identifies our Cursor stop hook in hooks.json. */
|
|
34
|
+
const CURSOR_HOOK_TOKEN = "--source cursor";
|
|
35
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
36
|
+
function resolveHomeDir(opts) {
|
|
37
|
+
return opts.home ?? process.env.HOME ?? process.env.USERPROFILE ?? "~";
|
|
38
|
+
}
|
|
39
|
+
/** Read a JSON file; returns null on any error (absent, bad JSON, bad perms). */
|
|
40
|
+
async function readJsonFile(p) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await fs.readFile(p, "utf8");
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** True when `cmd` appears on any entry in `pathEnv` (colon-separated). */
|
|
50
|
+
async function commandOnPath(cmd, pathEnv) {
|
|
51
|
+
if (!pathEnv)
|
|
52
|
+
return false;
|
|
53
|
+
const names = process.platform === "win32" ? [cmd, `${cmd}.exe`, `${cmd}.cmd`, `${cmd}.bat`] : [cmd];
|
|
54
|
+
for (const dir of pathEnv.split(path.delimiter)) {
|
|
55
|
+
if (!dir)
|
|
56
|
+
continue;
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
try {
|
|
59
|
+
await fs.stat(path.join(dir, name));
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// not in this dir — keep looking
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Recursively extract all `command` strings from a Claude/Cursor hooks value.
|
|
71
|
+
*
|
|
72
|
+
* Claude settings.json hook events have this shape:
|
|
73
|
+
* hooks.Stop = [ { matcher: string, hooks: [ { type: "command", command: string } ] } ]
|
|
74
|
+
*
|
|
75
|
+
* Cursor hooks.json stop entries have this shape:
|
|
76
|
+
* stop = [ { command: string } ]
|
|
77
|
+
*
|
|
78
|
+
* This walks the structure recursively so both formats are handled without
|
|
79
|
+
* needing a per-runtime parser.
|
|
80
|
+
*/
|
|
81
|
+
function extractCommands(value) {
|
|
82
|
+
if (!value)
|
|
83
|
+
return [];
|
|
84
|
+
if (typeof value === "string")
|
|
85
|
+
return [value];
|
|
86
|
+
if (Array.isArray(value)) {
|
|
87
|
+
const cmds = [];
|
|
88
|
+
for (const item of value)
|
|
89
|
+
cmds.push(...extractCommands(item));
|
|
90
|
+
return cmds;
|
|
91
|
+
}
|
|
92
|
+
if (typeof value === "object") {
|
|
93
|
+
const obj = value;
|
|
94
|
+
const cmds = [];
|
|
95
|
+
// If this object has a `command` string field, it's a hook entry.
|
|
96
|
+
if (typeof obj.command === "string")
|
|
97
|
+
cmds.push(obj.command);
|
|
98
|
+
// Recurse into `hooks` sub-array (Claude Code's two-level nesting).
|
|
99
|
+
if (obj.hooks)
|
|
100
|
+
cmds.push(...extractCommands(obj.hooks));
|
|
101
|
+
return cmds;
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
/** Extract all hook command strings from a hooks event value (array or object). */
|
|
106
|
+
function flattenHookCommands(hooksValue) {
|
|
107
|
+
return extractCommands(hooksValue);
|
|
108
|
+
}
|
|
109
|
+
// ─── Core gather ────────────────────────────────────────────────────────────
|
|
110
|
+
/**
|
|
111
|
+
* Collect the full {@link DoctorReport}. Never throws — errors are surfaced as
|
|
112
|
+
* failing checks with actionable fix commands.
|
|
113
|
+
*/
|
|
114
|
+
export async function gatherDoctor(opts = {}) {
|
|
115
|
+
const checks = [];
|
|
116
|
+
const home = resolveHomeDir(opts);
|
|
117
|
+
// ── 1. Paired? ─────────────────────────────────────────────────────────
|
|
118
|
+
let pairedOk = false;
|
|
119
|
+
let pairedDetail = "not paired";
|
|
120
|
+
try {
|
|
121
|
+
const creds = await readCredentials({ home: opts.home });
|
|
122
|
+
if (creds) {
|
|
123
|
+
pairedOk = true;
|
|
124
|
+
pairedDetail = `paired as ${creds.user_id} (${creds.label})`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
pairedDetail = "credentials file unreadable";
|
|
129
|
+
}
|
|
130
|
+
checks.push({
|
|
131
|
+
key: "paired",
|
|
132
|
+
label: "Paired?",
|
|
133
|
+
ok: pairedOk,
|
|
134
|
+
detail: pairedDetail,
|
|
135
|
+
fixCmd: pairedOk ? undefined : "gocode-notify login",
|
|
136
|
+
});
|
|
137
|
+
// ── 2. Server reachable? ───────────────────────────────────────────────
|
|
138
|
+
const serverUrl = await resolveServerUrl(opts.serverFlag, { home: opts.home });
|
|
139
|
+
const probe = await probeServer(serverUrl, {
|
|
140
|
+
fetchImpl: opts.fetchImpl,
|
|
141
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
|
|
142
|
+
});
|
|
143
|
+
checks.push({
|
|
144
|
+
key: "server",
|
|
145
|
+
label: "Server reachable?",
|
|
146
|
+
ok: probe.reachable,
|
|
147
|
+
detail: `${serverUrl} (${probe.detail})`,
|
|
148
|
+
fixCmd: probe.reachable
|
|
149
|
+
? undefined
|
|
150
|
+
: "# Check your network or run: gocode-notify login --server <url>",
|
|
151
|
+
});
|
|
152
|
+
// ── 3a. Claude Code Stop hook ──────────────────────────────────────────
|
|
153
|
+
const claudeSettingsPath = path.join(home, ".claude", "settings.json");
|
|
154
|
+
const claudeSettings = await readJsonFile(claudeSettingsPath);
|
|
155
|
+
const claudeHooks = claudeSettings &&
|
|
156
|
+
typeof claudeSettings === "object" &&
|
|
157
|
+
"hooks" in claudeSettings
|
|
158
|
+
? claudeSettings.hooks
|
|
159
|
+
: null;
|
|
160
|
+
const claudeStopCommands = flattenHookCommands(claudeHooks &&
|
|
161
|
+
typeof claudeHooks === "object" &&
|
|
162
|
+
"Stop" in claudeHooks
|
|
163
|
+
? claudeHooks.Stop
|
|
164
|
+
: null);
|
|
165
|
+
const claudeStopInstalled = claudeStopCommands.some((c) => c.includes(CLAUDE_HOOK_TOKEN));
|
|
166
|
+
checks.push({
|
|
167
|
+
key: "hook:claude-stop",
|
|
168
|
+
label: "Claude Code Stop hook installed?",
|
|
169
|
+
ok: claudeStopInstalled,
|
|
170
|
+
detail: claudeStopInstalled
|
|
171
|
+
? `found in ${claudeSettingsPath}`
|
|
172
|
+
: claudeHooks === null
|
|
173
|
+
? "~/.claude/settings.json not found or no hooks"
|
|
174
|
+
: "hook not found",
|
|
175
|
+
fixCmd: claudeStopInstalled ? undefined : "gocode-notify setup",
|
|
176
|
+
});
|
|
177
|
+
// ── 3b. Claude Code Notification hook ──────────────────────────────────
|
|
178
|
+
const claudeNotifyCommands = flattenHookCommands(claudeHooks &&
|
|
179
|
+
typeof claudeHooks === "object" &&
|
|
180
|
+
"Notification" in claudeHooks
|
|
181
|
+
? claudeHooks.Notification
|
|
182
|
+
: null);
|
|
183
|
+
const claudeNotifyInstalled = claudeNotifyCommands.some((c) => c.includes(CLAUDE_HOOK_TOKEN));
|
|
184
|
+
checks.push({
|
|
185
|
+
key: "hook:claude-notification",
|
|
186
|
+
label: "Claude Code Notification hook installed?",
|
|
187
|
+
ok: claudeNotifyInstalled,
|
|
188
|
+
detail: claudeNotifyInstalled
|
|
189
|
+
? `found in ${claudeSettingsPath}`
|
|
190
|
+
: "hook not found",
|
|
191
|
+
fixCmd: claudeNotifyInstalled ? undefined : "gocode-notify setup",
|
|
192
|
+
});
|
|
193
|
+
// ── 3c. Cursor stop hook ───────────────────────────────────────────────
|
|
194
|
+
const cursorHooksPath = path.join(home, ".cursor", "hooks.json");
|
|
195
|
+
const cursorHooks = await readJsonFile(cursorHooksPath);
|
|
196
|
+
// Pass the stop array directly (not wrapped in an object) so extractCommands
|
|
197
|
+
// can find the {command: ...} entries inside it.
|
|
198
|
+
const cursorStopValue = cursorHooks &&
|
|
199
|
+
typeof cursorHooks === "object" &&
|
|
200
|
+
"stop" in cursorHooks
|
|
201
|
+
? cursorHooks.stop
|
|
202
|
+
: null;
|
|
203
|
+
const cursorStopEntries = flattenHookCommands(cursorStopValue);
|
|
204
|
+
const cursorStopInstalled = cursorStopEntries.some((c) => c.includes(CURSOR_HOOK_TOKEN));
|
|
205
|
+
checks.push({
|
|
206
|
+
key: "hook:cursor-stop",
|
|
207
|
+
label: "Cursor stop hook installed?",
|
|
208
|
+
ok: cursorStopInstalled,
|
|
209
|
+
detail: cursorStopInstalled
|
|
210
|
+
? `found in ${cursorHooksPath}`
|
|
211
|
+
: cursorHooks === null
|
|
212
|
+
? "~/.cursor/hooks.json not found"
|
|
213
|
+
: "hook not found",
|
|
214
|
+
fixCmd: cursorStopInstalled ? undefined : "gocode-notify setup",
|
|
215
|
+
});
|
|
216
|
+
// ── 4. gocode-notify on PATH? ──────────────────────────────────────────
|
|
217
|
+
const pathEnv = opts.pathEnv ?? process.env.PATH;
|
|
218
|
+
const onPath = await commandOnPath("gocode-notify", pathEnv);
|
|
219
|
+
checks.push({
|
|
220
|
+
key: "path",
|
|
221
|
+
label: "gocode-notify on PATH (non-login shells)?",
|
|
222
|
+
ok: onPath,
|
|
223
|
+
detail: onPath
|
|
224
|
+
? "gocode-notify is resolvable"
|
|
225
|
+
: "not found on PATH — hooks may fail in non-login shells",
|
|
226
|
+
fixCmd: onPath
|
|
227
|
+
? undefined
|
|
228
|
+
: "# Add npm global bin to PATH, e.g.:\n echo 'export PATH=\"$(npm root -g)/../.bin:$PATH\"' >> ~/.bashrc",
|
|
229
|
+
});
|
|
230
|
+
return { checks };
|
|
231
|
+
}
|
|
232
|
+
// ─── Formatting ─────────────────────────────────────────────────────────────
|
|
233
|
+
function mark(ok) {
|
|
234
|
+
return ok ? "✓" : "✗";
|
|
235
|
+
}
|
|
236
|
+
// UNPAIRED_WARNING is defined in status.ts and re-exported above (T-COV1).
|
|
237
|
+
/**
|
|
238
|
+
* Render a {@link DoctorReport} as human-readable lines. Each failing check
|
|
239
|
+
* includes the exact fix command. Always friendly — never emits an error tone
|
|
240
|
+
* for passing checks.
|
|
241
|
+
*/
|
|
242
|
+
export function formatDoctor(report) {
|
|
243
|
+
const lines = ["gocode-notify doctor", ""];
|
|
244
|
+
for (const check of report.checks) {
|
|
245
|
+
lines.push(`${mark(check.ok)} ${check.label}`);
|
|
246
|
+
lines.push(` ${check.detail}`);
|
|
247
|
+
if (!check.ok && check.fixCmd) {
|
|
248
|
+
lines.push(` Fix: ${check.fixCmd}`);
|
|
249
|
+
}
|
|
250
|
+
lines.push("");
|
|
251
|
+
}
|
|
252
|
+
// Surface the unpaired warning loudly (T-COV1 requirement: "surface ⚠️ loop
|
|
253
|
+
// completion pushes will NOT reach your phone" when not paired).
|
|
254
|
+
const pairedCheck = report.checks.find((c) => c.key === "paired");
|
|
255
|
+
if (pairedCheck && !pairedCheck.ok) {
|
|
256
|
+
lines.push(UNPAIRED_WARNING);
|
|
257
|
+
lines.push("");
|
|
258
|
+
}
|
|
259
|
+
const allOk = report.checks.every((c) => c.ok);
|
|
260
|
+
lines.push(allOk ? "All checks passed." : "Some checks failed — see fix commands above.");
|
|
261
|
+
return lines;
|
|
262
|
+
}
|