@trygocode/notify 0.1.2 → 0.1.4
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 +178 -104
- package/dist/src/claude.js +27 -14
- package/dist/src/cli.js +52 -0
- package/dist/src/cursor.js +25 -12
- package/dist/src/mcp.js +1 -38
- package/dist/src/on_stop.js +132 -0
- package/dist/src/opencode.js +394 -0
- package/dist/src/push.js +69 -1
- package/dist/src/setup.js +12 -7
- package/dist/src/uninstall.js +2 -0
- package/dist/src/version.js +1 -1
- package/package.json +3 -21
- package/snippets/ralph-homer.sh +3 -3
- package/CHANGELOG.md +0 -59
- package/LICENSE +0 -21
- package/assets/README.md +0 -13
- package/assets/banner.png +0 -0
- package/assets/icon-128.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/icon.svg +0 -8
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// `gocode-notify on-stop` — the single end-of-turn dispatcher (PRD §2.2).
|
|
2
|
+
//
|
|
3
|
+
// This is the ONE command the runtime stop hooks call (Cursor `stop`, Claude
|
|
4
|
+
// `Stop`). It exists to guarantee EXACTLY ONE notification per turn while
|
|
5
|
+
// keeping auto-push and the plain "finished" ping from ever firing together:
|
|
6
|
+
//
|
|
7
|
+
// 1. Derive this repo's identity (§2.5) — for settings resolution + the
|
|
8
|
+
// notification's `project` label.
|
|
9
|
+
// 2. Resolve the merged Notify settings for the repo (§2.1 step 1 / §3.6) via
|
|
10
|
+
// the 60s-TTL cache in `config.ts` (fail-safe: auto-push OFF when nothing
|
|
11
|
+
// is known).
|
|
12
|
+
// 3. Branch:
|
|
13
|
+
// • `auto_push.enabled === true` → run the `push-on-stop` git flow
|
|
14
|
+
// (`push.ts`), which sends its OWN finished/error notification carrying
|
|
15
|
+
// the commit summary. The plain `send` is SUPPRESSED for this turn.
|
|
16
|
+
// • otherwise → run the existing `send --kind finished` flow exactly as
|
|
17
|
+
// the hook did before this dispatcher existed.
|
|
18
|
+
//
|
|
19
|
+
// Backward-compatible: `send`, `test`, `push-on-stop` stay independently
|
|
20
|
+
// callable for power users + tests. Existing installs upgrade their hook to call
|
|
21
|
+
// `on-stop` on the next `setup`/`--force` (PRD §2.2).
|
|
22
|
+
//
|
|
23
|
+
// Best-effort + total: like every hook entry, it NEVER throws and ALWAYS maps to
|
|
24
|
+
// process exit 0 (PRD §0.5 "Never block the agent"). Every side-effecting dep is
|
|
25
|
+
// injectable so the dispatcher is unit-testable with zero network / git / fs.
|
|
26
|
+
//
|
|
27
|
+
// Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
|
|
28
|
+
import { resolveNotifySettings } from "./config.js";
|
|
29
|
+
import { deriveRepoIdentity } from "./repo_key.js";
|
|
30
|
+
import { pushOnStop, } from "./push.js";
|
|
31
|
+
import { appendLog, send } from "./send.js";
|
|
32
|
+
/** Slice the merged settings down to what the push flow consumes. */
|
|
33
|
+
function toPushSettings(settings) {
|
|
34
|
+
return {
|
|
35
|
+
auto_push: settings.auto_push,
|
|
36
|
+
commit_message: settings.commit_message,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The end-of-turn dispatcher (PRD §2.2). Resolves settings, then either delegates
|
|
41
|
+
* to the auto-push flow (which sends its own notification) OR fires the plain
|
|
42
|
+
* `finished` ping — NEVER both, so a turn produces exactly one notification.
|
|
43
|
+
*
|
|
44
|
+
* Best-effort + total: it NEVER throws and NEVER rejects — every branch returns
|
|
45
|
+
* an {@link OnStopResult}, and the caller maps any result to process exit 0 so a
|
|
46
|
+
* slow/failed push or send can never block the agent's turn (PRD §0.5).
|
|
47
|
+
*/
|
|
48
|
+
export async function onStop(opts = {}) {
|
|
49
|
+
const source = opts.source ?? "unknown";
|
|
50
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
51
|
+
const deriveRepo = opts.deriveRepo ?? deriveRepoIdentity;
|
|
52
|
+
const resolveSettings = opts.resolveSettings ?? resolveNotifySettings;
|
|
53
|
+
const logLine = async (line) => {
|
|
54
|
+
try {
|
|
55
|
+
if (opts.log)
|
|
56
|
+
await opts.log(line);
|
|
57
|
+
else
|
|
58
|
+
await appendLog(`ON-STOP: ${line}`, { home: opts.home, timestamp: opts.timestamp });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// logging must never be the thing that blocks the flow
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const sendImpl = opts.sendImpl ??
|
|
65
|
+
((payload) => send(payload, {
|
|
66
|
+
home: opts.home,
|
|
67
|
+
server: opts.server,
|
|
68
|
+
fetchImpl: opts.fetchImpl,
|
|
69
|
+
timeoutMs: opts.timeoutMs,
|
|
70
|
+
timestamp: opts.timestamp,
|
|
71
|
+
}));
|
|
72
|
+
try {
|
|
73
|
+
// ── Step 1: derive repo identity (never throws — local fallback on failure). ──
|
|
74
|
+
let repo;
|
|
75
|
+
try {
|
|
76
|
+
repo = await deriveRepo(cwd);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
repo = undefined; // resolution falls back to the global blob
|
|
80
|
+
}
|
|
81
|
+
// ── Step 2: resolve merged settings for this repo (fail-safe: auto-push OFF). ──
|
|
82
|
+
const resolved = await resolveSettings({
|
|
83
|
+
home: opts.home,
|
|
84
|
+
repo,
|
|
85
|
+
server: opts.server,
|
|
86
|
+
fetchImpl: opts.fetchImpl,
|
|
87
|
+
timeoutMs: opts.timeoutMs,
|
|
88
|
+
ttlMs: opts.ttlMs,
|
|
89
|
+
now: opts.now,
|
|
90
|
+
});
|
|
91
|
+
const settings = resolved.settings;
|
|
92
|
+
const autoPushOn = settings.auto_push?.enabled === true;
|
|
93
|
+
// ── Step 3a: auto-push owns the notification for this turn. ──
|
|
94
|
+
if (autoPushOn) {
|
|
95
|
+
const pushImpl = opts.pushImpl ?? pushOnStop;
|
|
96
|
+
const push = await pushImpl({
|
|
97
|
+
settings: toPushSettings(settings),
|
|
98
|
+
source,
|
|
99
|
+
cwd,
|
|
100
|
+
project: repo?.repo_label,
|
|
101
|
+
dedupeKey: opts.dedupeKey,
|
|
102
|
+
dryRun: opts.dryRun,
|
|
103
|
+
server: opts.server,
|
|
104
|
+
fetchImpl: opts.fetchImpl,
|
|
105
|
+
timeoutMs: opts.timeoutMs,
|
|
106
|
+
timestamp: opts.timestamp,
|
|
107
|
+
home: opts.home,
|
|
108
|
+
});
|
|
109
|
+
await logLine(`auto-push path → ${push.outcome} (source: ${source}, settings: ${resolved.source})`);
|
|
110
|
+
return { mode: "push", settingsSource: resolved.source, push, repo, detail: push.detail };
|
|
111
|
+
}
|
|
112
|
+
// ── Step 3b: auto-push off → the plain `finished` notification (legacy flow). ──
|
|
113
|
+
if (opts.dryRun) {
|
|
114
|
+
await logLine(`dry-run: would send finished (auto-push off, source: ${source}, settings: ${resolved.source})`);
|
|
115
|
+
return { mode: "dry-run-send", settingsSource: resolved.source, repo };
|
|
116
|
+
}
|
|
117
|
+
const payload = { kind: "finished", source };
|
|
118
|
+
if (repo?.repo_label)
|
|
119
|
+
payload.project = repo.repo_label;
|
|
120
|
+
if (opts.dedupeKey)
|
|
121
|
+
payload.dedupe_key = opts.dedupeKey;
|
|
122
|
+
const sent = await sendImpl(payload);
|
|
123
|
+
await logLine(`send path → finished ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
|
|
124
|
+
return { mode: "send", settingsSource: resolved.source, send: sent, repo };
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
// Defence in depth: the dispatcher must never throw. Degrade to a logged
|
|
128
|
+
// no-op so the hook's turn is never blocked (PRD §0.5).
|
|
129
|
+
await logLine(`unexpected error — treated as no-op: ${err instanceof Error ? err.message : String(err)}`);
|
|
130
|
+
return { mode: "send", settingsSource: "default", detail: "unexpected error" };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// OpenCode config writer (PRD §0 Phase 0, T-OC1) — the installer's per-runtime
|
|
2
|
+
// writer for OpenCode, at parity with `claude.ts` / `cursor.ts`. It does two
|
|
3
|
+
// things, all idempotently and without clobbering the user's existing config:
|
|
4
|
+
//
|
|
5
|
+
// 1. MERGE our MCP server entry into `opencode.json`'s `mcp` map, using
|
|
6
|
+
// OpenCode's shape (DIFFERENT from Claude/Cursor):
|
|
7
|
+
// "gocode-notify": { "type": "local",
|
|
8
|
+
// "command": ["npx","-y","@trygocode/notify","mcp"],
|
|
9
|
+
// "enabled": true }
|
|
10
|
+
// `command` is a single ARRAY (binary + args), plus `type` + `enabled`.
|
|
11
|
+
// 2. WRITE a `session.idle` plugin to `<config-dir>/plugin/gocode-notify.js`.
|
|
12
|
+
// OpenCode has NO stop-hook command array; instead a plugin subscribes to
|
|
13
|
+
// the `session.idle` event (the OpenCode equivalent of Cursor `stop` /
|
|
14
|
+
// Claude `Stop`) and fires the SHARED `on-stop` dispatcher:
|
|
15
|
+
// gocode-notify on-stop --source opencode --dedupe-key opencode-idle || true
|
|
16
|
+
// The child is detached + unref'd and errors are swallowed (`|| true`), so a
|
|
17
|
+
// notification/push failure can never block the session — exactly one ping
|
|
18
|
+
// per idle, in lockstep with Claude/Cursor (same `on-stop` dispatcher).
|
|
19
|
+
//
|
|
20
|
+
// The on-demand rule/skill (Claude SKILL.md / Cursor rule) is SKIPPED for
|
|
21
|
+
// OpenCode: OpenCode has no auto-loaded standalone per-file rule mechanism (the
|
|
22
|
+
// equivalent is the shared `AGENTS.md`, which we must NOT clobber), so per the
|
|
23
|
+
// T-OC1 spec ("write the on-demand rule/skill if OpenCode supports one, else skip
|
|
24
|
+
// gracefully") we skip it. The anti-double-ping invariant is still upheld because
|
|
25
|
+
// the `session.idle` plugin owns the single automatic ping.
|
|
26
|
+
//
|
|
27
|
+
// MERGE, never clobber: the user's own `mcp` servers + top-level `opencode.json`
|
|
28
|
+
// keys are preserved. Re-running converges (idempotent) — our `mcp` entry is
|
|
29
|
+
// replaced in place (not duplicated) and the plugin file is rewritten
|
|
30
|
+
// byte-identically. `uninstallOpenCodeConfig` removes EXACTLY our `mcp` entry +
|
|
31
|
+
// our plugin file and nothing else (PRD §A8, the uninstall test).
|
|
32
|
+
//
|
|
33
|
+
// Our entries are identified by stable markers (the plugin contains both
|
|
34
|
+
// `gocode-notify` and `--source opencode`) so idempotency and surgical uninstall
|
|
35
|
+
// work even across version bumps to the exact command string.
|
|
36
|
+
//
|
|
37
|
+
// Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
|
|
38
|
+
import { promises as fs } from "node:fs";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { resolveHome } from "./creds.js";
|
|
41
|
+
/** Display name of the runtime this writer handles (matches the detector). */
|
|
42
|
+
export const OPENCODE_RUNTIME_NAME = "OpenCode";
|
|
43
|
+
/** MCP server key written into `opencode.json` `mcp`. */
|
|
44
|
+
export const MCP_SERVER_NAME = "gocode-notify";
|
|
45
|
+
/**
|
|
46
|
+
* Home-relative OpenCode config dirs, in the detector's priority order. The
|
|
47
|
+
* writer resolves to whichever the detector found (or the primary one when
|
|
48
|
+
* creating fresh). Kept in lockstep with `detect.ts`'s OpenCode `detectDirs`.
|
|
49
|
+
*/
|
|
50
|
+
export const OPENCODE_DETECT_DIRS = [".config/opencode", ".opencode"];
|
|
51
|
+
/** Basename of the OpenCode config file we merge into (NOT `mcp.json`). */
|
|
52
|
+
export const OPENCODE_CONFIG_BASENAME = "opencode.json";
|
|
53
|
+
/**
|
|
54
|
+
* The MCP server entry we register (PRD §0). OpenCode's shape differs from
|
|
55
|
+
* Claude/Cursor: `command` is a single ARRAY (binary + args) and there are
|
|
56
|
+
* `type` + `enabled` fields.
|
|
57
|
+
*/
|
|
58
|
+
export const OPENCODE_MCP_ENTRY = {
|
|
59
|
+
type: "local",
|
|
60
|
+
command: ["npx", "-y", "@trygocode/notify", "mcp"],
|
|
61
|
+
enabled: true,
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* The shell command the `session.idle` plugin shells out to (PRD §0). Calls the
|
|
65
|
+
* shared `on-stop` DISPATCHER (NOT `send` directly): on-stop resolves settings
|
|
66
|
+
* and either runs the auto-push flow (which sends its own notification) or fires
|
|
67
|
+
* the plain `finished` ping — exactly one notification per idle, in lockstep with
|
|
68
|
+
* Claude/Cursor. Ends in `|| true` so a failure can never block the session, and
|
|
69
|
+
* carries a `--dedupe-key` so overlapping triggers coalesce server-side.
|
|
70
|
+
*/
|
|
71
|
+
export const OPENCODE_STOP_COMMAND = "npx -y @trygocode/notify on-stop --source opencode --dedupe-key opencode-idle || true";
|
|
72
|
+
/**
|
|
73
|
+
* Substrings that together identify our plugin file as OURS. Used to keep
|
|
74
|
+
* uninstall surgical: we only delete the plugin file when BOTH markers are
|
|
75
|
+
* present, so a user-authored file that happens to share the name is never
|
|
76
|
+
* removed. Present in {@link OPENCODE_PLUGIN_CONTENT}.
|
|
77
|
+
*/
|
|
78
|
+
const PLUGIN_MARKERS = ["gocode-notify", "--source opencode"];
|
|
79
|
+
/**
|
|
80
|
+
* Best-effort predicate: does an OpenCode `session.status` payload's `status`
|
|
81
|
+
* value mean "the agent finished this turn"?
|
|
82
|
+
*
|
|
83
|
+
* The `status` value has shifted shape across OpenCode versions — a bare string
|
|
84
|
+
* (`"idle"`) and an object (`{ type | state | status: "idle" }`). We normalise
|
|
85
|
+
* to a lowercase string and treat any idle/done/complete/finish word as
|
|
86
|
+
* end-of-turn, while explicitly REJECTING busy/working/running/active/stream/
|
|
87
|
+
* pending states so we never ping mid-turn.
|
|
88
|
+
*
|
|
89
|
+
* Exported so the generated plugin's behaviour is unit-testable in isolation
|
|
90
|
+
* (the plugin body inlines the identical logic — keep the two in lockstep; the
|
|
91
|
+
* `opencode.test.ts` "statusIsIdle parity" test guards against drift).
|
|
92
|
+
*/
|
|
93
|
+
export function opencodeStatusIsIdle(status) {
|
|
94
|
+
if (status == null)
|
|
95
|
+
return false;
|
|
96
|
+
const raw = typeof status === "string"
|
|
97
|
+
? status
|
|
98
|
+
: isRecord(status)
|
|
99
|
+
? (status.type ?? status.state ?? status.status ?? "")
|
|
100
|
+
: "";
|
|
101
|
+
const s = String(raw).toLowerCase();
|
|
102
|
+
if (!s)
|
|
103
|
+
return false;
|
|
104
|
+
if (s.includes("busy") ||
|
|
105
|
+
s.includes("work") ||
|
|
106
|
+
s.includes("run") ||
|
|
107
|
+
s.includes("active") ||
|
|
108
|
+
s.includes("stream") ||
|
|
109
|
+
s.includes("pending")) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return (s.includes("idle") ||
|
|
113
|
+
s.includes("done") ||
|
|
114
|
+
s.includes("complete") ||
|
|
115
|
+
s.includes("finish"));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The end-of-turn plugin written to `<config-dir>/plugin/gocode-notify.js`.
|
|
119
|
+
*
|
|
120
|
+
* An OpenCode plugin exports an async factory returning `{ event }`; we fire the
|
|
121
|
+
* shared `on-stop` dispatcher fire-and-forget when a session finishes a turn.
|
|
122
|
+
*
|
|
123
|
+
* IMPORTANT — why we listen to TWO events:
|
|
124
|
+
* - `session.idle` is the original "turn finished" signal, but as of recent
|
|
125
|
+
* OpenCode builds (≥ ~1.14) it is **deprecated** and no longer reliably
|
|
126
|
+
* emitted in the GUI (upstream moved to `session.status`). Riding it alone
|
|
127
|
+
* meant "Cursor pings me, OpenCode doesn't".
|
|
128
|
+
* - `session.status` (payload `{ sessionID, status }`) is the modern signal.
|
|
129
|
+
* It fires on EVERY status change, so we only treat it as end-of-turn when
|
|
130
|
+
* the status is the idle/finished state (best-effort shape detection — the
|
|
131
|
+
* `status` value has been a string and an object across versions).
|
|
132
|
+
*
|
|
133
|
+
* We subscribe to BOTH so the plugin works on old AND new OpenCode. A small
|
|
134
|
+
* per-session debounce (DEDUPE_MS) coalesces the idle+status pair for the same
|
|
135
|
+
* turn so we never spawn `on-stop` twice; the server's `--dedupe-key` is the
|
|
136
|
+
* second line of defence. The child is detached + unref'd with stdio ignored so
|
|
137
|
+
* it NEVER blocks the session, and any spawn error is swallowed (belt-and-braces
|
|
138
|
+
* with the command's own `|| true`).
|
|
139
|
+
*/
|
|
140
|
+
export const OPENCODE_PLUGIN_CONTENT = `// gocode-notify — OpenCode end-of-turn plugin (auto-generated by @trygocode/notify).
|
|
141
|
+
// Fires exactly one fire-and-forget phone notification when an OpenCode session
|
|
142
|
+
// finishes a turn (the OpenCode equivalent of Claude \`Stop\` / Cursor \`stop\`) by
|
|
143
|
+
// shelling out to the shared gocode-notify \`on-stop\` dispatcher. It NEVER blocks
|
|
144
|
+
// the session: the child is detached + unref'd, stdio ignored, errors swallowed.
|
|
145
|
+
//
|
|
146
|
+
// Listens to BOTH \`session.idle\` (legacy, deprecated in newer OpenCode) and
|
|
147
|
+
// \`session.status\` (modern end-of-turn signal). A per-session debounce stops the
|
|
148
|
+
// two from double-firing for the same turn. See tools/gocode-notify/src/opencode.ts.
|
|
149
|
+
//
|
|
150
|
+
// Stable markers (do not edit): gocode-notify --source opencode
|
|
151
|
+
// Managed by @trygocode/notify — \`npx @trygocode/notify uninstall\` removes this file.
|
|
152
|
+
import { spawn } from "node:child_process";
|
|
153
|
+
|
|
154
|
+
// Per-session last-fire timestamps so idle+status for the SAME turn coalesce.
|
|
155
|
+
const lastFiredAt = new Map();
|
|
156
|
+
const DEDUPE_MS = 4000;
|
|
157
|
+
|
|
158
|
+
function fire() {
|
|
159
|
+
try {
|
|
160
|
+
const child = spawn(
|
|
161
|
+
${JSON.stringify(OPENCODE_STOP_COMMAND)},
|
|
162
|
+
{ shell: true, detached: true, stdio: "ignore" },
|
|
163
|
+
);
|
|
164
|
+
child.unref();
|
|
165
|
+
} catch {
|
|
166
|
+
// never block the session on a notification failure
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Best-effort: does this \`session.status\` payload mean "the agent finished"?
|
|
171
|
+
// The status value has been a bare string ("idle") and an object ({type|state|
|
|
172
|
+
// status: "idle"}) across OpenCode versions; treat any of those idle-ish shapes
|
|
173
|
+
// as end-of-turn, and explicitly IGNORE busy/working/running states.
|
|
174
|
+
function statusIsIdle(status) {
|
|
175
|
+
if (status == null) return false;
|
|
176
|
+
const s = (typeof status === "string"
|
|
177
|
+
? status
|
|
178
|
+
: (status.type ?? status.state ?? status.status ?? "")
|
|
179
|
+
).toString().toLowerCase();
|
|
180
|
+
if (!s) return false;
|
|
181
|
+
if (s.includes("busy") || s.includes("work") || s.includes("run") ||
|
|
182
|
+
s.includes("active") || s.includes("stream") || s.includes("pending")) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return s.includes("idle") || s.includes("done") ||
|
|
186
|
+
s.includes("complete") || s.includes("finish");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function maybeFire(sessionID) {
|
|
190
|
+
const key = sessionID || "_";
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
const prev = lastFiredAt.get(key) ?? 0;
|
|
193
|
+
if (now - prev < DEDUPE_MS) return; // coalesce idle+status for one turn
|
|
194
|
+
lastFiredAt.set(key, now);
|
|
195
|
+
fire();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const GocodeNotify = async () => ({
|
|
199
|
+
event: async ({ event }) => {
|
|
200
|
+
if (!event) return;
|
|
201
|
+
const props = event.properties ?? {};
|
|
202
|
+
if (event.type === "session.idle") {
|
|
203
|
+
maybeFire(props.sessionID);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (event.type === "session.status") {
|
|
207
|
+
if (statusIsIdle(props.status)) maybeFire(props.sessionID);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
`;
|
|
213
|
+
function isRecord(value) {
|
|
214
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
215
|
+
}
|
|
216
|
+
function errMessage(err) {
|
|
217
|
+
return err instanceof Error ? err.message : String(err);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the OpenCode config directory. Prefers the dir the detector already
|
|
221
|
+
* settled on (`runtime.configPath`'s parent) so the writer and detector agree;
|
|
222
|
+
* otherwise probes {@link OPENCODE_DETECT_DIRS} under HOME, falling back to the
|
|
223
|
+
* primary dir when neither exists (fresh install). Never throws.
|
|
224
|
+
*/
|
|
225
|
+
export async function resolveOpenCodeDir(runtime, opts) {
|
|
226
|
+
if (runtime?.configPath)
|
|
227
|
+
return path.dirname(runtime.configPath);
|
|
228
|
+
const home = resolveHome(opts);
|
|
229
|
+
for (const rel of OPENCODE_DETECT_DIRS) {
|
|
230
|
+
const dir = path.join(home, rel);
|
|
231
|
+
try {
|
|
232
|
+
await fs.stat(dir);
|
|
233
|
+
return dir;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// keep probing
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return path.join(home, OPENCODE_DETECT_DIRS[0]);
|
|
240
|
+
}
|
|
241
|
+
/** Absolute path to OpenCode's `opencode.json` for the resolved config dir. */
|
|
242
|
+
export async function opencodeConfigPath(runtime, opts) {
|
|
243
|
+
return path.join(await resolveOpenCodeDir(runtime, opts), OPENCODE_CONFIG_BASENAME);
|
|
244
|
+
}
|
|
245
|
+
/** Absolute path to the OpenCode plugin directory (canonical singular `plugin/`). */
|
|
246
|
+
export async function opencodePluginDir(runtime, opts) {
|
|
247
|
+
return path.join(await resolveOpenCodeDir(runtime, opts), "plugin");
|
|
248
|
+
}
|
|
249
|
+
/** Absolute path to our `session.idle` plugin file. */
|
|
250
|
+
export async function opencodePluginPath(runtime, opts) {
|
|
251
|
+
return path.join(await opencodePluginDir(runtime, opts), "gocode-notify.js");
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Read a JSON object from `file`. Returns null when the file does not exist.
|
|
255
|
+
* Throws when it exists but is not a JSON object — so we never silently clobber
|
|
256
|
+
* a file we failed to parse (the caller surfaces it as a write failure).
|
|
257
|
+
*/
|
|
258
|
+
async function readJsonObject(file) {
|
|
259
|
+
let raw;
|
|
260
|
+
try {
|
|
261
|
+
raw = await fs.readFile(file, "utf8");
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
if (err.code === "ENOENT")
|
|
265
|
+
return null;
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
let parsed;
|
|
269
|
+
try {
|
|
270
|
+
parsed = JSON.parse(raw);
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
throw new Error(`gocode-notify: ${file} contains invalid JSON`);
|
|
274
|
+
}
|
|
275
|
+
if (!isRecord(parsed)) {
|
|
276
|
+
throw new Error(`gocode-notify: ${file} is not a JSON object`);
|
|
277
|
+
}
|
|
278
|
+
return parsed;
|
|
279
|
+
}
|
|
280
|
+
/** Write a JSON object with 2-space indent + trailing newline (matches creds). */
|
|
281
|
+
async function writeJsonFile(file, value) {
|
|
282
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
283
|
+
await fs.writeFile(file, JSON.stringify(value, null, 2) + "\n");
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Merge our MCP server entry into `config.mcp`, preserving the user's other
|
|
287
|
+
* `mcp` servers and top-level keys. Replaces OUR entry in place (idempotent).
|
|
288
|
+
* Mutates `config` in place. A fresh copy of the entry is written each time so
|
|
289
|
+
* the constant is never aliased into the user's config.
|
|
290
|
+
*/
|
|
291
|
+
function mergeMcp(config) {
|
|
292
|
+
const mcp = isRecord(config.mcp) ? config.mcp : {};
|
|
293
|
+
mcp[MCP_SERVER_NAME] = {
|
|
294
|
+
type: OPENCODE_MCP_ENTRY.type,
|
|
295
|
+
command: [...OPENCODE_MCP_ENTRY.command],
|
|
296
|
+
enabled: OPENCODE_MCP_ENTRY.enabled,
|
|
297
|
+
};
|
|
298
|
+
config.mcp = mcp;
|
|
299
|
+
}
|
|
300
|
+
/** True when a plugin file's content is one we wrote (BOTH markers present). */
|
|
301
|
+
function isOurPlugin(content) {
|
|
302
|
+
return PLUGIN_MARKERS.every((m) => content.includes(m));
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Install OpenCode config (PRD §0): merge the MCP entry into `opencode.json` and
|
|
306
|
+
* write the `session.idle` plugin. A {@link RuntimeConfigWriter}-shaped function —
|
|
307
|
+
* never throws; returns a {@link ConfigWriteResult}. Idempotent: re-running
|
|
308
|
+
* converges without duplicating our entries.
|
|
309
|
+
*/
|
|
310
|
+
export async function writeOpenCodeConfig(runtime, opts) {
|
|
311
|
+
const name = runtime?.name ?? OPENCODE_RUNTIME_NAME;
|
|
312
|
+
// Track paths as they land so a mid-way failure reports what WAS actually
|
|
313
|
+
// written rather than claiming nothing changed.
|
|
314
|
+
const written = [];
|
|
315
|
+
try {
|
|
316
|
+
const dir = await resolveOpenCodeDir(runtime, opts);
|
|
317
|
+
await fs.mkdir(dir, { recursive: true });
|
|
318
|
+
const configFile = path.join(dir, OPENCODE_CONFIG_BASENAME);
|
|
319
|
+
const config = (await readJsonObject(configFile)) ?? {};
|
|
320
|
+
mergeMcp(config);
|
|
321
|
+
await writeJsonFile(configFile, config);
|
|
322
|
+
written.push(configFile);
|
|
323
|
+
const pluginDir = path.join(dir, "plugin");
|
|
324
|
+
await fs.mkdir(pluginDir, { recursive: true });
|
|
325
|
+
const pluginFile = path.join(pluginDir, "gocode-notify.js");
|
|
326
|
+
await fs.writeFile(pluginFile, OPENCODE_PLUGIN_CONTENT);
|
|
327
|
+
written.push(pluginFile);
|
|
328
|
+
return {
|
|
329
|
+
runtime: name,
|
|
330
|
+
written,
|
|
331
|
+
skipped: false,
|
|
332
|
+
detail: "merged mcp.gocode-notify entry; wrote session.idle plugin (rule skipped — OpenCode has no standalone rule file)",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
return {
|
|
337
|
+
runtime: name,
|
|
338
|
+
written,
|
|
339
|
+
skipped: false,
|
|
340
|
+
failed: true,
|
|
341
|
+
detail: `OpenCode config write failed: ${errMessage(err)}`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Remove EXACTLY the entries this writer added (PRD §A8): our `mcp` entry and our
|
|
347
|
+
* plugin file. The user's own `mcp` servers, top-level `opencode.json` keys, and
|
|
348
|
+
* other plugins are preserved untouched. The plugin file is only deleted when it
|
|
349
|
+
* carries OUR markers, so a user file sharing the name is never removed.
|
|
350
|
+
* Idempotent — a second run (or a run when nothing was installed) is a clean
|
|
351
|
+
* no-op. Never throws.
|
|
352
|
+
*/
|
|
353
|
+
export async function uninstallOpenCodeConfig(opts) {
|
|
354
|
+
const removed = [];
|
|
355
|
+
try {
|
|
356
|
+
const dir = await resolveOpenCodeDir(undefined, opts);
|
|
357
|
+
// opencode.json — remove our mcp entry only.
|
|
358
|
+
const configFile = path.join(dir, OPENCODE_CONFIG_BASENAME);
|
|
359
|
+
const config = await readJsonObject(configFile);
|
|
360
|
+
if (config && isRecord(config.mcp) && MCP_SERVER_NAME in config.mcp) {
|
|
361
|
+
delete config.mcp[MCP_SERVER_NAME];
|
|
362
|
+
if (Object.keys(config.mcp).length === 0)
|
|
363
|
+
delete config.mcp;
|
|
364
|
+
await writeJsonFile(configFile, config);
|
|
365
|
+
removed.push(configFile);
|
|
366
|
+
}
|
|
367
|
+
// plugin/gocode-notify.js — remove only OUR plugin file (marker-checked).
|
|
368
|
+
const pluginFile = path.join(dir, "plugin", "gocode-notify.js");
|
|
369
|
+
let pluginContent = null;
|
|
370
|
+
try {
|
|
371
|
+
pluginContent = await fs.readFile(pluginFile, "utf8");
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
// Only ENOENT means "not installed". A permission/IO error must surface as
|
|
375
|
+
// a failure, not be silently reported as a clean uninstall.
|
|
376
|
+
if (err.code !== "ENOENT")
|
|
377
|
+
throw err;
|
|
378
|
+
pluginContent = null;
|
|
379
|
+
}
|
|
380
|
+
if (pluginContent !== null && isOurPlugin(pluginContent)) {
|
|
381
|
+
await fs.rm(pluginFile, { force: true });
|
|
382
|
+
removed.push(pluginFile);
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
removed,
|
|
386
|
+
detail: removed.length > 0
|
|
387
|
+
? `removed gocode-notify entries (${removed.length} path${removed.length === 1 ? "" : "s"})`
|
|
388
|
+
: "no gocode-notify entries found",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
return { removed, failed: true, detail: `OpenCode uninstall failed: ${errMessage(err)}` };
|
|
393
|
+
}
|
|
394
|
+
}
|
package/dist/src/push.js
CHANGED
|
@@ -213,7 +213,54 @@ export async function pushOnStop(opts = {}) {
|
|
|
213
213
|
finally {
|
|
214
214
|
await removeCommitFile(file).catch(() => { });
|
|
215
215
|
}
|
|
216
|
-
|
|
216
|
+
let sha = (await git(["rev-parse", "--short", "HEAD"])).stdout.trim() || undefined;
|
|
217
|
+
// ── Step 7.5 (PRD §3): pull-before-push. Default ON; opt out with
|
|
218
|
+
// `auto_push.pull_before_push: false` (FF-only). Runs AFTER the commit so
|
|
219
|
+
// any local work is already a commit being rebased atop the remote. ──
|
|
220
|
+
if (autoPush.pull_before_push !== false) {
|
|
221
|
+
// Learn the remote state. A fetch failure (offline / brand-new branch)
|
|
222
|
+
// falls through to the plain push, which still handles non-FF safely.
|
|
223
|
+
const fetched = await git(["fetch", remote, branch]);
|
|
224
|
+
if (fetched.code === 0 && !(await isFastForwardPossible(git, remote, branch))) {
|
|
225
|
+
// Remote moved — a plain push would be non-FF. Rebase our commit atop it.
|
|
226
|
+
const pulled = await git(["pull", "--rebase", "--autostash", remote, branch]);
|
|
227
|
+
if (pulled.code !== 0) {
|
|
228
|
+
// A REAL conflict (or pull failure): RESTORE the tree byte-for-byte and
|
|
229
|
+
// do NOT push. `--autostash` restores any stray changes on abort. We
|
|
230
|
+
// NEVER auto-resolve (-X theirs/ours) — that loses work; reasoned
|
|
231
|
+
// resolution is AI-Solve's job (PRD §5), user-initiated only.
|
|
232
|
+
const conflicting = parseConflictFiles((await git(["diff", "--name-only", "--diff-filter=U"])).stdout);
|
|
233
|
+
await git(["rebase", "--abort"]);
|
|
234
|
+
const fileList = conflicting.length ? conflicting.join(", ") : "remote changes";
|
|
235
|
+
await logLine(`pull --rebase hit a conflict on ${remote} ${branch} — aborted (tree restored), not pushing; conflicts: ${fileList}`);
|
|
236
|
+
// PRD §4: git_conflict is an error-class signal. The dedicated
|
|
237
|
+
// `git_conflict` push kind + deep-link payload arrive in a later phase;
|
|
238
|
+
// here we alert via an error notification (never block, exit 0).
|
|
239
|
+
const conflictRes = await sendImpl({
|
|
240
|
+
kind: "error",
|
|
241
|
+
title: "Push needs a merge",
|
|
242
|
+
body: `${branch} — remote changed; rebase conflicts in ${conflicting.length || "remote"} file(s). Pull + resolve (or use AI-Solve).`,
|
|
243
|
+
source,
|
|
244
|
+
project: opts.project,
|
|
245
|
+
dedupe_key: opts.dedupeKey,
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
outcome: "conflict-aborted",
|
|
249
|
+
branch,
|
|
250
|
+
sha,
|
|
251
|
+
message: composed.message,
|
|
252
|
+
generator: composed.generator,
|
|
253
|
+
notified: conflictRes.ok,
|
|
254
|
+
conflictingFiles: conflicting,
|
|
255
|
+
detail: `rebase conflict aborted; not pushed (${fileList})`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// Clean rebase: our commit now sits atop the remote → an FF push works.
|
|
259
|
+
// The rebase rewrote HEAD, so refresh the short SHA for the notification.
|
|
260
|
+
sha = (await git(["rev-parse", "--short", "HEAD"])).stdout.trim() || sha;
|
|
261
|
+
await logLine(`pulled remote changes (rebase clean) on ${remote} ${branch} — pushing`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
217
264
|
// ── Step 8: fast-forward push. NEVER --force. ──
|
|
218
265
|
const pushed = await git(["push", remote, branch]);
|
|
219
266
|
if (pushed.code !== 0) {
|
|
@@ -269,6 +316,27 @@ export async function pushOnStop(opts = {}) {
|
|
|
269
316
|
return { outcome: "disabled", detail: "unexpected error" };
|
|
270
317
|
}
|
|
271
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* After a `git fetch`, decide whether a plain (fast-forward) push is possible:
|
|
321
|
+
* true when the remote-tracking ref `<remote>/<branch>` is an ancestor of (or
|
|
322
|
+
* equal to) HEAD — i.e. we already have everything the remote has. False ONLY
|
|
323
|
+
* when the remote has commits we don't (a non-FF push). A missing remote ref
|
|
324
|
+
* (brand-new branch that fetch didn't create) counts as FF-possible.
|
|
325
|
+
*/
|
|
326
|
+
export async function isFastForwardPossible(git, remote, branch) {
|
|
327
|
+
const ancestor = await git(["merge-base", "--is-ancestor", `${remote}/${branch}`, "HEAD"]);
|
|
328
|
+
// 0 → remote is an ancestor of HEAD (or equal) → FF push OK.
|
|
329
|
+
// 1 → remote has diverged → non-FF → must `pull --rebase` first.
|
|
330
|
+
// 128 → unknown ref (new branch) → nothing to rebase onto → treat as FF.
|
|
331
|
+
return ancestor.code !== 1;
|
|
332
|
+
}
|
|
333
|
+
/** Parse the unmerged-file list from `git diff --name-only --diff-filter=U`. */
|
|
334
|
+
export function parseConflictFiles(stdout) {
|
|
335
|
+
return stdout
|
|
336
|
+
.split("\n")
|
|
337
|
+
.map((l) => l.trim())
|
|
338
|
+
.filter((l) => l !== "");
|
|
339
|
+
}
|
|
272
340
|
/** True when git's push stderr signals a non-fast-forward / rejected push. */
|
|
273
341
|
export function isNonFastForward(stderr) {
|
|
274
342
|
return /\b(non-fast-forward|rejected|fetch first|tip of your current branch is behind)\b/i.test(stderr);
|
package/dist/src/setup.js
CHANGED
|
@@ -25,10 +25,13 @@ import { readCredentials } from "./creds.js";
|
|
|
25
25
|
import { detectRuntimes } from "./status.js";
|
|
26
26
|
import { writeClaudeConfig, CLAUDE_RUNTIME_NAME } from "./claude.js";
|
|
27
27
|
import { writeCursorConfig, CURSOR_RUNTIME_NAME } from "./cursor.js";
|
|
28
|
+
import { writeOpenCodeConfig, OPENCODE_RUNTIME_NAME } from "./opencode.js";
|
|
28
29
|
/**
|
|
29
|
-
* Default config writer: reports the detected runtime as pending.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* Default config writer: reports the detected runtime as pending. Used as the
|
|
31
|
+
* fall-through for any future runtime whose dedicated writer has not landed yet.
|
|
32
|
+
* All currently-supported runtimes (Claude Code / Cursor / OpenCode) have real
|
|
33
|
+
* writers wired in {@link defaultConfigWriter}. Writing nothing is fully
|
|
34
|
+
* idempotent.
|
|
32
35
|
*/
|
|
33
36
|
export const pendingConfigWriter = async (runtime) => ({
|
|
34
37
|
runtime: runtime.name,
|
|
@@ -38,16 +41,18 @@ export const pendingConfigWriter = async (runtime) => ({
|
|
|
38
41
|
});
|
|
39
42
|
/**
|
|
40
43
|
* The default config writer used in production: routes each detected runtime to
|
|
41
|
-
* its dedicated writer. Claude Code
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* idempotent).
|
|
44
|
+
* its dedicated writer. Claude Code → {@link writeClaudeConfig}, Cursor →
|
|
45
|
+
* {@link writeCursorConfig}, OpenCode → {@link writeOpenCodeConfig}. Any future
|
|
46
|
+
* runtime without a writer falls through to {@link pendingConfigWriter} (a
|
|
47
|
+
* no-op, fully idempotent).
|
|
45
48
|
*/
|
|
46
49
|
export const defaultConfigWriter = async (runtime, opts) => {
|
|
47
50
|
if (runtime.name === CLAUDE_RUNTIME_NAME)
|
|
48
51
|
return writeClaudeConfig(runtime, opts);
|
|
49
52
|
if (runtime.name === CURSOR_RUNTIME_NAME)
|
|
50
53
|
return writeCursorConfig(runtime, opts);
|
|
54
|
+
if (runtime.name === OPENCODE_RUNTIME_NAME)
|
|
55
|
+
return writeOpenCodeConfig(runtime, opts);
|
|
51
56
|
return pendingConfigWriter(runtime, opts);
|
|
52
57
|
};
|
|
53
58
|
/**
|
package/dist/src/uninstall.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { uninstallClaudeConfig, CLAUDE_RUNTIME_NAME, } from "./claude.js";
|
|
2
2
|
import { uninstallCursorConfig, CURSOR_RUNTIME_NAME } from "./cursor.js";
|
|
3
|
+
import { uninstallOpenCodeConfig, OPENCODE_RUNTIME_NAME } from "./opencode.js";
|
|
3
4
|
/** The production runtime uninstallers, in run order. */
|
|
4
5
|
export const defaultUninstallers = [
|
|
5
6
|
{ runtime: CLAUDE_RUNTIME_NAME, run: uninstallClaudeConfig },
|
|
6
7
|
{ runtime: CURSOR_RUNTIME_NAME, run: uninstallCursorConfig },
|
|
8
|
+
{ runtime: OPENCODE_RUNTIME_NAME, run: uninstallOpenCodeConfig },
|
|
7
9
|
];
|
|
8
10
|
/**
|
|
9
11
|
* Run the uninstall orchestration (PRD §4.1, §11). Resolves (never rejects) to a
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Single source of truth for the CLI version. Keep in sync with package.json.
|
|
2
|
-
export const VERSION = "0.1.
|
|
2
|
+
export const VERSION = "0.1.3";
|