@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 +29 -0
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/assets/README.md +16 -0
- package/assets/icon.svg +8 -0
- package/dist/src/agent.js +33 -0
- package/dist/src/claude.js +287 -0
- package/dist/src/cli.js +444 -0
- package/dist/src/commit_message.js +321 -0
- package/dist/src/config.js +348 -0
- package/dist/src/creds.js +158 -0
- package/dist/src/cursor.js +273 -0
- package/dist/src/detect.js +109 -0
- package/dist/src/login.js +152 -0
- package/dist/src/mcp.js +215 -0
- package/dist/src/outbox.js +150 -0
- package/dist/src/push.js +278 -0
- package/dist/src/repo_key.js +98 -0
- package/dist/src/rule-content.js +71 -0
- package/dist/src/send.js +141 -0
- package/dist/src/settings.js +213 -0
- package/dist/src/setup.js +148 -0
- package/dist/src/status.js +150 -0
- package/dist/src/uninstall.js +39 -0
- package/dist/src/version.js +2 -0
- package/package.json +61 -0
- package/snippets/ralph-homer.sh +21 -0
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 |
|
package/assets/README.md
ADDED
|
@@ -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).
|
package/assets/icon.svg
ADDED
|
@@ -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">>_</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
|
+
}
|