@trygocode/notify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@trygocode/notify` are documented here. This project
4
+ follows [Semantic Versioning](https://semver.org).
5
+
6
+ ## [0.1.0] — 2026-06-03
7
+
8
+ Initial public release.
9
+
10
+ ### Added
11
+ - **One-command install** — `npx @trygocode/notify@latest setup` pairs the machine,
12
+ auto-detects your agent runtimes, and merges hooks + the MCP server + an
13
+ anti-double-ping rule into each (Cursor, Claude Code, OpenCode). Idempotent;
14
+ safe to re-run; `--force` re-pairs.
15
+ - **Three notification triggers** — (A) runtime hooks (Cursor `stop`; Claude Code
16
+ `Stop` / `Notification` / `SubagentStop`), (B) the `gocode_notify` MCP tool for
17
+ explicit "ping me when X is done" requests, (C) an opt-in Ralph/Homer loop
18
+ completion/halt snippet.
19
+ - **Secure pairing** — a short-lived 6-digit code is exchanged for a scoped,
20
+ push-only API key stored locally (`~/.gocode/credentials`, chmod 600). The key
21
+ can only send pushes to your own phone; revoke any time from the app.
22
+ - **Offline outbox** — sends made while the server is unreachable are queued and
23
+ flushed best-effort later, so a blocked agent is never caused by a slow push.
24
+ - **`status` self-diagnosis**, `test` round-trip push, and a clean `uninstall`
25
+ that removes exactly what this tool added.
26
+ - **MCP server metadata** — `icons` + `websiteUrl` (SEP-973) so compatible clients
27
+ can show the GoCode mark next to the server.
28
+
29
+ [0.1.0]: https://github.com/joseph-lewis/gocode-notify/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GoCode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,237 @@
1
+ <p align="center">
2
+ <img src="assets/icon.svg" alt="GoCode Notify" width="96" height="96" />
3
+ </p>
4
+
5
+ <h1 align="center">@trygocode/notify</h1>
6
+
7
+ <p align="center">
8
+ <strong>Get a push notification on your phone the moment your AI coding agent finishes.</strong><br/>
9
+ Cursor · Claude Code · OpenCode · Ralph/Homer loops — installed with <em>one</em> command.
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@trygocode/notify"><img alt="npm" src="https://img.shields.io/npm/v/@trygocode/notify?color=5EE6A8&label=npm"></a>
14
+ <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
15
+ <img alt="node" src="https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white">
16
+ <img alt="works with" src="https://img.shields.io/badge/works%20with-Cursor%20%C2%B7%20Claude%20Code%20%C2%B7%20OpenCode-111">
17
+ </p>
18
+
19
+ ```bash
20
+ npx @trygocode/notify@latest setup
21
+ ```
22
+
23
+ <p align="center">
24
+ <!-- Joseph: drop a 2-3s screen recording of the phone notification arriving here. -->
25
+ <img src="assets/demo.gif" alt="A push notification arrives on the phone the instant the agent finishes" width="320" />
26
+ </p>
27
+
28
+ ---
29
+
30
+ ## Why?
31
+
32
+ You kick off a long agent run, then walk away to make coffee, take a call, or
33
+ context-switch to something else. Now you're stuck in the loop of *checking back
34
+ every 30 seconds* to see if it's done — or worse, it finished 20 minutes ago and
35
+ you didn't notice.
36
+
37
+ **`@trygocode/notify` pings your phone the instant your agent finishes a turn, goes
38
+ idle waiting for you, errors out, or an overnight loop completes/halts.** Walk
39
+ away. Your phone tells you when it needs you.
40
+
41
+ - ⚡ **One command.** `npx @trygocode/notify@latest setup` — no server to host, no config files to hand-edit.
42
+ - 🔒 **Push-only & private.** The paired key can *only* send notifications to your phone. It can't read your chats, code, or settings. Revoke it any time.
43
+ - 🧩 **Auto-detects your tools.** Wires up Cursor, Claude Code, and OpenCode in one go — never clobbering your existing hooks/MCP config.
44
+ - 🪶 **Never blocks your agent.** Every send is fire-and-forget with a hard timeout + an offline queue. A slow push can't slow your work.
45
+
46
+ ## Works with
47
+
48
+ | Tool | How it hooks in |
49
+ |---|---|
50
+ | **Cursor** | `stop` hook |
51
+ | **Claude Code** | `Stop` + `Notification` + `SubagentStop` hooks |
52
+ | **OpenCode** | runtime hook |
53
+ | **Ralph / Homer loops** | opt-in completion/halt snippet |
54
+
55
+ > Notifications are delivered through the free **[GoCode](https://oh.jeltechsolutions.com)**
56
+ > phone app (the one-time pairing target). Install GoCode, pair once, done.
57
+
58
+ ## Contents
59
+
60
+ - [Install — two equally-supported paths](#install--two-equally-supported-paths)
61
+ - [Pairing — step by step](#pairing--step-by-step)
62
+ - [The three triggers](#the-three-triggers)
63
+ - [Ralph/Homer opt-in snippet (trigger C)](#ralphhomer-opt-in-snippet-trigger-c)
64
+ - [Troubleshooting](#troubleshooting)
65
+ - [Develop](#develop)
66
+ - [Layout](#layout)
67
+
68
+ ## Install — two equally-supported paths
69
+
70
+ Both paths converge on the same installer (`gocode-notify setup`): it pairs this
71
+ machine, auto-detects your agent runtimes, and merges the hooks + MCP server +
72
+ anti-double-ping rule into each one's config (never clobbering your existing
73
+ settings; safe to re-run).
74
+
75
+ ### Path 1 — paste a one-liner into your terminal
76
+
77
+ ```bash
78
+ npx @trygocode/notify@latest setup
79
+ ```
80
+
81
+ This runs the interactive installer: it prompts for the 6-digit pairing code
82
+ (from the GoCode app → **"Connect a coding agent"**), then detects and configures
83
+ Claude Code / Cursor / OpenCode. Re-run any time — it's idempotent; pass
84
+ `--force` to re-pair.
85
+
86
+ > First time? You'll need the free **[GoCode](https://oh.jeltechsolutions.com)**
87
+ > app on your phone to receive the pushes and to generate the 6-digit pairing
88
+ > code (Settings → **"Connect a coding agent"**).
89
+
90
+ ### Path 2 — paste a prompt into your AI agent and let it install
91
+
92
+ Hand this to Cursor / Claude Code and the agent does the install for you. The
93
+ `--agent-driven` flag suppresses interactive prompts and emits one JSON line per
94
+ step so the agent can verify each one:
95
+
96
+ ```
97
+ Install GoCode phone notifications for this machine. Run:
98
+ npx @trygocode/notify@latest setup --agent-driven --pair-code <CODE>
99
+ Then confirm the hooks and MCP server were written, and run
100
+ npx @trygocode/notify@latest test
101
+ to send a test push to my phone. Report whether the test push arrived.
102
+ ```
103
+
104
+ Replace `<CODE>` with the 6-digit code from the GoCode app. `--agent-driven` is
105
+ fully idempotent and machine-readable; it never spawns a prompt.
106
+
107
+ ## Pairing — step by step
108
+
109
+ A dev machine running agent hooks has no GoCode login (no JWT), so it can't use
110
+ GitHub OAuth. Instead you pair it once with a short-lived **6-digit code**, which
111
+ the CLI exchanges for a scoped, push-only **API key** stored locally. The key can
112
+ only send pushes to *your* phone — it can't read chats, settings, or trigger any
113
+ agent action, and you can revoke it from the app at any time.
114
+
115
+ 1. **In the GoCode app**, open **Settings → "Connect a coding agent"**. The app
116
+ shows a large 6-digit code (valid 10 minutes), a copyable
117
+ `npx @trygocode/notify login --code 123456` line, and a 10:00 countdown. Tap
118
+ **"Generate new code"** if it expires.
119
+ 2. **On the machine**, either run the full installer (`npx @trygocode/notify@latest
120
+ setup`, which pairs *and* writes your agent configs) or just pair on its own:
121
+
122
+ ```bash
123
+ # Interactive — prompts for the code:
124
+ npx @trygocode/notify@latest login
125
+
126
+ # Or pass it directly (and optionally label this machine):
127
+ npx @trygocode/notify@latest login --code 123456 --label "MacBook Pro — Cursor"
128
+ ```
129
+ 3. The CLI calls the server's `pair/claim` endpoint, receives the API key **once**,
130
+ and writes it to `~/.gocode/credentials` (chmod `600`). The app flips to a
131
+ **"connected ✓"** success state showing the machine's label.
132
+ 4. **Verify the round-trip** with a real push to your phone:
133
+
134
+ ```bash
135
+ npx @trygocode/notify@latest test
136
+ ```
137
+
138
+ A "GoCode test" notification should arrive on your paired device. If it
139
+ doesn't, see [Troubleshooting](#troubleshooting).
140
+
141
+ **Re-pairing.** Both `login` and `setup` are idempotent — re-running them won't
142
+ clobber an existing pairing. To deliberately replace the stored key (new machine
143
+ owner, rotated key), pass `--force` to `setup` (or just run `login` again with a
144
+ fresh code). Revoke an old machine from the app's **"Connected agents"** screen.
145
+
146
+ **Server selection.** Pairing and every send resolve the server URL in this
147
+ precedence order: the `--server` flag → the `GOCODE_SERVER` env var → the value
148
+ saved in `~/.gocode/credentials` → the built-in default
149
+ (`https://oh.jeltechsolutions.com`). You only need `--server` for a self-hosted
150
+ or staging GoCode server.
151
+
152
+ ## The three triggers
153
+
154
+ | Trigger | Mechanism | Fires when |
155
+ |---|---|---|
156
+ | **(A) Runtime hook** | Cursor `stop` / Claude Code `Stop`+`Notification`+`SubagentStop` | Agent finishes a turn, goes idle, or errors — **automatic, the killer feature** |
157
+ | **(B) MCP tool** | `gocode_notify` tool the agent calls | You *explicitly* ask "ping me when X is done" mid-task |
158
+ | **(C) Loop shell hook** | one line in your loop's completion/halt path | A Ralph/Homer loop reaches `completed` / `halted` |
159
+
160
+ The installed rule/skill tells the agent **not** to call the MCP tool for
161
+ done/idle/error pings — those are owned by the deterministic hook (A), so you
162
+ never get double-pinged.
163
+
164
+ ## Ralph/Homer opt-in snippet (trigger C)
165
+
166
+ For power users running a loop **they control** (this repo's `ralph`/`homer`
167
+ skills, a `while :; do … done` one-liner, or any custom driver), drop these two
168
+ lines into the loop's completion/halt path:
169
+
170
+ ```bash
171
+ # At loop completion:
172
+ gocode-notify send --kind loop_completed --source ralph --project "$(basename "$PWD")" || true
173
+ # At loop halt (paused_max_failures / awaiting_human):
174
+ gocode-notify send --kind loop_halted --source ralph --project "$(basename "$PWD")" \
175
+ --title "Ralph halted — needs you" || true
176
+ ```
177
+
178
+ The ready-to-copy version with comments lives at
179
+ [`snippets/ralph-homer.sh`](snippets/ralph-homer.sh).
180
+
181
+ **This is opt-in and never auto-injected** — the installer does not edit your
182
+ loop scripts. Both lines are fire-and-forget (`|| true` + the CLI's 5s
183
+ self-timeout), so a failed or slow push can never block or fail your loop.
184
+
185
+ ## Troubleshooting
186
+
187
+ **Start here:** `gocode-notify status` prints a one-screen report — whether
188
+ credentials are present (and the bound user/label), whether the server is
189
+ reachable, which agent runtimes were detected, and whether each one's config has
190
+ been written. Most issues below are diagnosable from that output.
191
+
192
+ | Symptom | Likely cause & fix |
193
+ |---|---|
194
+ | `test` / `send` prints **"not paired"** | No `~/.gocode/credentials`. Run `gocode-notify login` and pair from the app (see [Pairing](#pairing--step-by-step)). |
195
+ | **Pairing fails** ("invalid or expired code") | Codes expire after 10 min and are single-use. Tap **"Generate new code"** in the app and re-run `login` with the fresh code. |
196
+ | `status` shows **Server: not reachable** | Network/DNS/firewall, or a wrong server URL. Confirm you can reach `https://oh.jeltechsolutions.com`; check the `--server` flag / `GOCODE_SERVER` env / the `server` field in `~/.gocode/credentials`. |
197
+ | **No push arrives** even though `test` exits 0 | The send is fire-and-forget and exits 0 even on failure — check `~/.gocode/notify.log` for the real error. Also confirm push permissions are granted in the GoCode app and the device token is registered (re-open the app once after signing in). |
198
+ | **Double pings** (two notifications per event) | The agent is calling the `gocode_notify` MCP tool *and* the runtime hook is firing. Re-run `setup` so the anti-double-ping rule/skill is installed; it tells the agent not to notify for automatic done/idle/error events. |
199
+ | **Hook doesn't fire** in Cursor / Claude Code | Re-run `setup` and check `status` shows "config written" for that runtime. Restart the agent app so it reloads `~/.cursor/hooks.json` / `~/.claude/settings.json`. The hooks are merged, never clobbered — your existing hooks are preserved. |
200
+ | **Pushes queue up while offline** then arrive later | Expected. Sends made while the server is unreachable are enqueued to `~/.gocode/outbox/` (size-capped, drop-oldest) and flushed best-effort on the next `send`. A missed "done" ping is acceptable; a blocked agent is not. |
201
+ | **`npx @trygocode/notify` can't find the package** | Make sure you're online and using the scoped name exactly: `npx @trygocode/notify@latest setup`. Clear a stale npx cache with `npx clear-npx-cache` (or `rm -rf ~/.npm/_npx`) and retry. |
202
+ | **Want it gone** | `gocode-notify uninstall` removes exactly the hook/MCP/rule entries this tool added (nothing else). Delete `~/.gocode/` to also drop the stored credentials, and revoke the key from the app's **"Connected agents"** screen. |
203
+
204
+ **Logs & files.** Failures are appended to `~/.gocode/notify.log` (size-capped,
205
+ rotated to `notify.log.1`). Credentials live in `~/.gocode/credentials` (chmod
206
+ `600`); non-secret prefs in `~/.gocode/config.json`; the offline queue in
207
+ `~/.gocode/outbox/`.
208
+
209
+ Found a bug or have a feature idea? Please
210
+ [open an issue](https://github.com/joseph-lewis/gocode-notify/issues) — issues are
211
+ welcome and usually get a reply within a day or two.
212
+
213
+ ## Develop
214
+
215
+ ```bash
216
+ git clone https://github.com/joseph-lewis/gocode-notify.git
217
+ cd gocode-notify
218
+ npm install
219
+ npm run build # compile TypeScript -> dist/
220
+ npm test # builds, then runs node --test on dist/test/
221
+ npm run typecheck # type-check only, no emit
222
+ ```
223
+
224
+ Zero runtime dependencies beyond the MCP SDK (Node built-in `fetch`/`fs`/
225
+ `readline` for everything else). Tests use Node's built-in test runner
226
+ (`node:test`).
227
+
228
+ ## Layout
229
+
230
+ | Path | Purpose |
231
+ |---|---|
232
+ | `src/cli.ts` | `gocode-notify` bin entrypoint + command dispatcher |
233
+ | `src/setup.ts` | Installer orchestration (pair → detect → write configs) |
234
+ | `src/claude.ts` / `src/cursor.ts` | Per-client config writers (hooks + MCP + rule/skill) |
235
+ | `src/send.ts` / `src/login.ts` / `src/mcp.ts` | Core send, pairing, and MCP server |
236
+ | `snippets/ralph-homer.sh` | Opt-in loop completion/halt snippet (trigger C) |
237
+ | `test/` | `node:test` smoke + unit tests |
@@ -0,0 +1,16 @@
1
+ # Assets
2
+
3
+ Brand assets for **GoCode Notify**.
4
+
5
+ | File | Used by |
6
+ |---|---|
7
+ | `icon.svg` | MCP server icon metadata (`mimeType: image/svg+xml`, scalable), README, social. |
8
+ | `icon-128.png` | MCP server icon metadata (`128x128`). |
9
+ | `demo.gif` | README hero — the phone notification arriving when an agent finishes. |
10
+ | `logo.png` | GitHub social preview / npm. |
11
+
12
+ > **Joseph:** drop the real GoCode logo PNG/SVG and a 2-second demo GIF in here
13
+ > (replace the placeholders). Keep filenames identical so the MCP icon URLs and
14
+ > README image links keep resolving. Recommended: `icon.svg` (square, transparent),
15
+ > `icon-128.png` (128×128), `demo.gif` (≤ 3s, ≤ 5 MB), `logo.png` (1280×640 for the
16
+ > GitHub social card).
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" role="img" aria-label="GoCode Notify">
2
+ <!-- Placeholder GoCode Notify mark. Replace with the real GoCode logo (keep this filename). -->
3
+ <rect width="128" height="128" rx="28" fill="#0B1020"/>
4
+ <text x="20" y="74" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="40" font-weight="700" fill="#5EE6A8">&gt;_</text>
5
+ <!-- bell -->
6
+ <path d="M86 44a14 14 0 0 0-28 0c0 16-6 20-6 24h40c0-4-6-8-6-24z" fill="#FFC857"/>
7
+ <circle cx="72" cy="92" r="6" fill="#FFC857"/>
8
+ </svg>
@@ -0,0 +1,33 @@
1
+ // Structured, machine-readable progress output for `--agent-driven` mode (PRD §4.4).
2
+ //
3
+ // When any command is invoked with `--agent-driven`, it emits ONE JSON line per
4
+ // step to stdout in the canonical shape
5
+ // { "step": "...", "ok": true|false, "detail": "..." }
6
+ // so an agent installer (PRD §2 Path 2) can parse and verify each step. In this
7
+ // mode the usual human-readable console output is suppressed — only step lines
8
+ // reach stdout.
9
+ //
10
+ // The emitter takes an injectable {@link StepSink} so tests can collect lines
11
+ // without capturing the real process stdout. Zero runtime deps — Node built-ins
12
+ // only, matching the package's zero-dep rule.
13
+ /**
14
+ * Serialize a step to its canonical single-line JSON form (PRD §4.4). The field
15
+ * order is fixed (`step`, `ok`, `detail`) so the output is stable to assert
16
+ * against and contains no incidental newlines (detail is JSON-escaped).
17
+ */
18
+ export function formatStep(line) {
19
+ return JSON.stringify({ step: line.step, ok: line.ok, detail: line.detail });
20
+ }
21
+ /** Default sink: one newline-terminated JSON line per step to stdout. */
22
+ export const stdoutSink = (line) => {
23
+ process.stdout.write(`${formatStep(line)}\n`);
24
+ };
25
+ /**
26
+ * True when the parsed flags requested agent-driven (machine-readable) output.
27
+ * Accepts the bare boolean form (`--agent-driven`) and the explicit
28
+ * `--agent-driven=true`; any other value (including `=false`) is off.
29
+ */
30
+ export function isAgentDriven(flags) {
31
+ const v = flags.get("agent-driven");
32
+ return v === true || v === "true";
33
+ }
@@ -0,0 +1,287 @@
1
+ // Claude Code config writer (PRD §5.3) — the installer's per-runtime writer for
2
+ // Claude Code. It does three things, all idempotently and without clobbering the
3
+ // user's existing config:
4
+ //
5
+ // 1. MERGE three fire-and-forget hooks into `~/.claude/settings.json`:
6
+ // Stop → "finished" (a turn completed)
7
+ // Notification → "awaiting_input" (the agent needs the user)
8
+ // SubagentStop → "finished" (a subagent completed)
9
+ // Each hook shells out to `gocode-notify send … || true` so a failed push
10
+ // NEVER blocks the agent's turn (PRD §4.4, §5.3).
11
+ // 2. MERGE an `mcpServers` entry pointing at `npx -y @trygocode/notify mcp`.
12
+ // 3. WRITE the on-demand skill to `~/.claude/skills/gocode-notify/SKILL.md`
13
+ // (the anti-double-ping rule, PRD §5.5).
14
+ //
15
+ // MERGE, never clobber: the user's own hooks / MCP servers / top-level settings
16
+ // keys are preserved. Re-running converges (idempotent) — our hook groups and
17
+ // MCP entry are replaced in place, not duplicated. `uninstallClaudeConfig`
18
+ // removes EXACTLY our entries and nothing else (PRD §11, the uninstall test).
19
+ //
20
+ // Our entries are identified by a stable marker (the command contains both
21
+ // `gocode-notify` and `--source claude_code`) so idempotency and uninstall work
22
+ // even across version bumps to the exact command string.
23
+ //
24
+ // Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
25
+ import { promises as fs } from "node:fs";
26
+ import path from "node:path";
27
+ import { resolveHome } from "./creds.js";
28
+ import { buildRuleContent, CLAUDE_FRONTMATTER, CLAUDE_HOOK_DESCRIPTION, } from "./rule-content.js";
29
+ /** Display name of the runtime this writer handles (matches the detector). */
30
+ export const CLAUDE_RUNTIME_NAME = "Claude Code";
31
+ /** MCP server key written into `~/.claude/settings.json` `mcpServers`. */
32
+ export const MCP_SERVER_NAME = "gocode-notify";
33
+ /** The MCP server entry we register (PRD §4.3, §5.4 invocation form). */
34
+ export const MCP_SERVER_ENTRY = {
35
+ command: "npx",
36
+ args: ["-y", "@trygocode/notify", "mcp"],
37
+ };
38
+ /**
39
+ * The hook command for each Claude Code event (PRD §5.3, verbatim shape). Every
40
+ * command ends in `|| true` so a notification failure can never block the
41
+ * agent's turn, and carries a per-session `--dedupe-key` so overlapping triggers
42
+ * (e.g. Cursor `stop` + Claude `Stop`) coalesce server-side.
43
+ */
44
+ export const CLAUDE_HOOK_COMMANDS = {
45
+ Stop: 'gocode-notify send --kind finished --source claude_code --dedupe-key "$CLAUDE_SESSION_ID-stop" || true',
46
+ Notification: 'gocode-notify send --kind awaiting_input --source claude_code --title "Agent needs you" --dedupe-key "$CLAUDE_SESSION_ID-notify" || true',
47
+ SubagentStop: 'gocode-notify send --kind finished --source claude_code --title "Subagent done" --dedupe-key "$CLAUDE_SESSION_ID-subagent" || true',
48
+ };
49
+ /**
50
+ * Substrings that together identify a hook command as OURS. Used for idempotent
51
+ * merge (replace, don't duplicate) and for surgical uninstall (remove exactly
52
+ * ours). A command must contain BOTH to be considered ours.
53
+ */
54
+ const HOOK_MARKERS = ["gocode-notify", "--source claude_code"];
55
+ /**
56
+ * The on-demand skill written to `~/.claude/skills/gocode-notify/SKILL.md`
57
+ * (PRD §5.5). The crucial content is the anti-double-ping rule: the automatic
58
+ * pings are owned by the runtime hooks, so the agent must only call the MCP tool
59
+ * when the user EXPLICITLY asks. Built from the shared {@link buildRuleContent}
60
+ * so the body stays in lockstep with the Cursor rule.
61
+ */
62
+ export const SKILL_CONTENT = buildRuleContent({
63
+ frontmatter: CLAUDE_FRONTMATTER,
64
+ hookDescription: CLAUDE_HOOK_DESCRIPTION,
65
+ });
66
+ function isRecord(value) {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+ function errMessage(err) {
70
+ return err instanceof Error ? err.message : String(err);
71
+ }
72
+ /** `~/.claude/` directory for the given (optional) HOME override. */
73
+ function claudeDir(opts) {
74
+ return path.join(resolveHome(opts), ".claude");
75
+ }
76
+ /** Absolute path to Claude Code's `settings.json`. */
77
+ export function claudeSettingsPath(opts) {
78
+ return path.join(claudeDir(opts), "settings.json");
79
+ }
80
+ /** Absolute path to the directory holding our skill. */
81
+ export function claudeSkillDir(opts) {
82
+ return path.join(claudeDir(opts), "skills", "gocode-notify");
83
+ }
84
+ /** Absolute path to our `SKILL.md`. */
85
+ export function claudeSkillPath(opts) {
86
+ return path.join(claudeSkillDir(opts), "SKILL.md");
87
+ }
88
+ /**
89
+ * Read a JSON object from `file`. Returns null when the file does not exist.
90
+ * Throws when it exists but is not a JSON object — so we never silently clobber
91
+ * a file we failed to parse (the caller surfaces it as a write failure).
92
+ */
93
+ async function readJsonObject(file) {
94
+ let raw;
95
+ try {
96
+ raw = await fs.readFile(file, "utf8");
97
+ }
98
+ catch (err) {
99
+ if (err.code === "ENOENT")
100
+ return null;
101
+ throw err;
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(raw);
106
+ }
107
+ catch {
108
+ throw new Error(`gocode-notify: ${file} contains invalid JSON`);
109
+ }
110
+ if (!isRecord(parsed)) {
111
+ throw new Error(`gocode-notify: ${file} is not a JSON object`);
112
+ }
113
+ return parsed;
114
+ }
115
+ /** Write a JSON object with 2-space indent + trailing newline (matches creds). */
116
+ async function writeJsonFile(file, value) {
117
+ await fs.mkdir(path.dirname(file), { recursive: true });
118
+ await fs.writeFile(file, JSON.stringify(value, null, 2) + "\n");
119
+ }
120
+ /** True when a single hook ENTRY (`{ type, command }`) is one we wrote. */
121
+ function isOurHookCommand(h) {
122
+ return (isRecord(h) &&
123
+ typeof h.command === "string" &&
124
+ HOOK_MARKERS.every((m) => h.command.includes(m)));
125
+ }
126
+ /**
127
+ * Strip OUR hook entries out of an event's group array, operating at the
128
+ * individual-command level (NOT the whole group): a user group that happens to
129
+ * also contain one of our commands keeps its other commands. Any group left with
130
+ * no commands is dropped. Returns the cleaned array plus whether anything of ours
131
+ * was removed (so callers can detect a real change). Never mutates the input.
132
+ */
133
+ function stripOurHooks(groups) {
134
+ let removed = false;
135
+ const out = [];
136
+ for (const group of groups) {
137
+ if (!isRecord(group) || !Array.isArray(group.hooks)) {
138
+ out.push(group); // unexpected shape → leave the user's data untouched
139
+ continue;
140
+ }
141
+ const kept = group.hooks.filter((h) => !isOurHookCommand(h));
142
+ if (kept.length !== group.hooks.length)
143
+ removed = true;
144
+ if (kept.length === 0)
145
+ continue; // group held only our command(s) → drop it
146
+ out.push(kept.length === group.hooks.length ? group : { ...group, hooks: kept });
147
+ }
148
+ return { groups: out, removed };
149
+ }
150
+ /** Build the matcher group we add for a single event. */
151
+ function ourHookGroup(command) {
152
+ return { hooks: [{ type: "command", command }] };
153
+ }
154
+ /**
155
+ * Merge our three hook events into `settings.hooks`, preserving the user's own
156
+ * hooks. For each event we strip any prior copy of OUR command (idempotent /
157
+ * version-safe) — at the command level, so a user command sharing a group with
158
+ * ours survives — then append a single fresh group. Mutates `settings` in place.
159
+ */
160
+ function mergeHooks(settings) {
161
+ const hooks = isRecord(settings.hooks) ? settings.hooks : {};
162
+ for (const [event, command] of Object.entries(CLAUDE_HOOK_COMMANDS)) {
163
+ const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
164
+ const preserved = stripOurHooks(existing).groups;
165
+ preserved.push(ourHookGroup(command));
166
+ hooks[event] = preserved;
167
+ }
168
+ settings.hooks = hooks;
169
+ }
170
+ /** Merge our MCP server entry into `settings.mcpServers`. Mutates in place. */
171
+ function mergeMcp(settings) {
172
+ const servers = isRecord(settings.mcpServers) ? settings.mcpServers : {};
173
+ servers[MCP_SERVER_NAME] = { ...MCP_SERVER_ENTRY, args: [...MCP_SERVER_ENTRY.args] };
174
+ settings.mcpServers = servers;
175
+ }
176
+ /**
177
+ * Install Claude Code config (PRD §5.3): merge hooks + MCP entry into
178
+ * `settings.json` and write the skill. A {@link RuntimeConfigWriter} — never
179
+ * throws; returns a {@link ConfigWriteResult}. Idempotent: re-running converges
180
+ * without duplicating our entries.
181
+ */
182
+ export async function writeClaudeConfig(runtime, opts) {
183
+ const name = runtime?.name ?? CLAUDE_RUNTIME_NAME;
184
+ // Track paths as they land so a mid-way failure (e.g. settings written but the
185
+ // skill write fails) reports what WAS actually written rather than claiming
186
+ // nothing changed.
187
+ const written = [];
188
+ try {
189
+ await fs.mkdir(claudeDir(opts), { recursive: true });
190
+ const settingsPath = claudeSettingsPath(opts);
191
+ const settings = (await readJsonObject(settingsPath)) ?? {};
192
+ mergeHooks(settings);
193
+ mergeMcp(settings);
194
+ await writeJsonFile(settingsPath, settings);
195
+ written.push(settingsPath);
196
+ const skillPath = claudeSkillPath(opts);
197
+ await fs.mkdir(claudeSkillDir(opts), { recursive: true });
198
+ await fs.writeFile(skillPath, SKILL_CONTENT);
199
+ written.push(skillPath);
200
+ return {
201
+ runtime: name,
202
+ written,
203
+ skipped: false,
204
+ detail: "merged Stop/Notification/SubagentStop hooks + MCP entry; wrote skill",
205
+ };
206
+ }
207
+ catch (err) {
208
+ return {
209
+ runtime: name,
210
+ written,
211
+ skipped: false,
212
+ failed: true,
213
+ detail: `Claude Code config write failed: ${errMessage(err)}`,
214
+ };
215
+ }
216
+ }
217
+ /**
218
+ * Remove EXACTLY the entries this writer added (PRD §11): our three hook groups,
219
+ * our MCP server entry, and our skill directory. The user's own hooks, MCP
220
+ * servers, and other settings keys are preserved untouched. Idempotent — a
221
+ * second run (or a run when nothing was installed) is a clean no-op. Never
222
+ * throws.
223
+ */
224
+ export async function uninstallClaudeConfig(opts) {
225
+ const removed = [];
226
+ try {
227
+ const settingsPath = claudeSettingsPath(opts);
228
+ const settings = await readJsonObject(settingsPath);
229
+ if (settings) {
230
+ let changed = false;
231
+ if (isRecord(settings.hooks)) {
232
+ const hooks = settings.hooks;
233
+ for (const event of Object.keys(CLAUDE_HOOK_COMMANDS)) {
234
+ if (!Array.isArray(hooks[event]))
235
+ continue;
236
+ const { groups: kept, removed } = stripOurHooks(hooks[event]);
237
+ if (removed)
238
+ changed = true;
239
+ if (kept.length > 0)
240
+ hooks[event] = kept;
241
+ else
242
+ delete hooks[event];
243
+ }
244
+ if (Object.keys(hooks).length === 0)
245
+ delete settings.hooks;
246
+ }
247
+ if (isRecord(settings.mcpServers) && MCP_SERVER_NAME in settings.mcpServers) {
248
+ delete settings.mcpServers[MCP_SERVER_NAME];
249
+ changed = true;
250
+ if (Object.keys(settings.mcpServers).length === 0)
251
+ delete settings.mcpServers;
252
+ }
253
+ if (changed) {
254
+ await writeJsonFile(settingsPath, settings);
255
+ removed.push(settingsPath);
256
+ }
257
+ }
258
+ // Remove only OUR skill dir, never the whole `skills/` tree. Stat first so
259
+ // we report it in `removed` only when it actually existed.
260
+ const skillDir = claudeSkillDir(opts);
261
+ let skillExisted = false;
262
+ try {
263
+ await fs.stat(skillDir);
264
+ skillExisted = true;
265
+ }
266
+ catch (err) {
267
+ // Only ENOENT means "not installed". A permission/IO error must surface as
268
+ // a failure, not be silently reported as a clean uninstall.
269
+ if (err.code !== "ENOENT")
270
+ throw err;
271
+ skillExisted = false;
272
+ }
273
+ if (skillExisted) {
274
+ await fs.rm(skillDir, { recursive: true, force: true });
275
+ removed.push(skillDir);
276
+ }
277
+ return {
278
+ removed,
279
+ detail: removed.length > 0
280
+ ? `removed gocode-notify entries (${removed.length} path${removed.length === 1 ? "" : "s"})`
281
+ : "no gocode-notify entries found",
282
+ };
283
+ }
284
+ catch (err) {
285
+ return { removed, failed: true, detail: `Claude Code uninstall failed: ${errMessage(err)}` };
286
+ }
287
+ }