@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.
@@ -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
- const sha = (await git(["rev-parse", "--short", "HEAD"])).stdout.trim() || undefined;
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. Replaced by
30
- * the Claude Code / Cursor config-writer tasks (which register real writers via
31
- * {@link SetupOptions.writeConfig}). Writing nothing is fully idempotent.
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 is wired to {@link writeClaudeConfig} and
42
- * Cursor to {@link writeCursorConfig}; runtimes whose writer has not landed yet
43
- * (OpenCode) fall through to {@link pendingConfigWriter} (a no-op, fully
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
  /**
@@ -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
@@ -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.0";
2
+ export const VERSION = "0.1.3";