@goodnesshq/opencode-notification 0.1.5 → 0.1.7
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/.opencode/notify-init.mjs +397 -90
- package/.opencode/oc-notify.schema.json +1 -1
- package/README.md +31 -25
- package/package.json +1 -1
- package/plugins/opencode-notifications.mjs +13 -18
|
@@ -3,6 +3,7 @@ import { readFile, writeFile, mkdir, copyFile, stat } from "node:fs/promises";
|
|
|
3
3
|
import { join, basename, dirname } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import * as readline from "node:readline";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
|
|
8
9
|
const REPO_CONFIG_PATH = ".opencode/oc-notify.json";
|
|
@@ -19,7 +20,7 @@ const SCHEMA_SOURCE_PATH = join(SCRIPT_DIR, "oc-notify.schema.json");
|
|
|
19
20
|
const DEFAULTS = {
|
|
20
21
|
enabled: true,
|
|
21
22
|
title: "",
|
|
22
|
-
tier: "
|
|
23
|
+
tier: "focus",
|
|
23
24
|
detailLevel: "full",
|
|
24
25
|
responseComplete: {
|
|
25
26
|
enabled: true,
|
|
@@ -127,6 +128,246 @@ async function copySchema(cwd) {
|
|
|
127
128
|
} catch {}
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
function isInteractive() {
|
|
132
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
133
|
+
if (process.env.CI) return false;
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function promptInput(message) {
|
|
138
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
139
|
+
const answer = await rl.question(message);
|
|
140
|
+
await rl.close();
|
|
141
|
+
return answer;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderPrompt(lines, state) {
|
|
145
|
+
if (state.linesRendered > 0) {
|
|
146
|
+
readline.moveCursor(process.stdout, 0, -(state.linesRendered - 1));
|
|
147
|
+
readline.cursorTo(process.stdout, 0);
|
|
148
|
+
readline.clearScreenDown(process.stdout);
|
|
149
|
+
}
|
|
150
|
+
process.stdout.write(lines.join("\n"));
|
|
151
|
+
state.linesRendered = lines.length;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function withRawInput(handler) {
|
|
155
|
+
const stdin = process.stdin;
|
|
156
|
+
readline.emitKeypressEvents(stdin);
|
|
157
|
+
const wasRaw = !!stdin.isRaw;
|
|
158
|
+
const wasPaused = stdin.isPaused();
|
|
159
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
160
|
+
if (wasPaused) stdin.resume();
|
|
161
|
+
try {
|
|
162
|
+
return await handler();
|
|
163
|
+
} finally {
|
|
164
|
+
if (stdin.isTTY) stdin.setRawMode(wasRaw);
|
|
165
|
+
if (wasPaused) stdin.pause();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function promptSelect({ message, choices, defaultValue }) {
|
|
170
|
+
return withRawInput(
|
|
171
|
+
() =>
|
|
172
|
+
new Promise((resolve) => {
|
|
173
|
+
const state = { linesRendered: 0 };
|
|
174
|
+
let cursor = choices.findIndex((choice) => choice.value === defaultValue);
|
|
175
|
+
if (cursor < 0) cursor = 0;
|
|
176
|
+
|
|
177
|
+
const render = () => {
|
|
178
|
+
const lines = [];
|
|
179
|
+
lines.push(message);
|
|
180
|
+
for (let index = 0; index < choices.length; index += 1) {
|
|
181
|
+
const choice = choices[index];
|
|
182
|
+
const pointer = index === cursor ? ">" : " ";
|
|
183
|
+
lines.push(` ${pointer} ${choice.name}`);
|
|
184
|
+
}
|
|
185
|
+
renderPrompt(lines, state);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const finish = () => {
|
|
189
|
+
const choice = choices[cursor];
|
|
190
|
+
const label = choice?.name ?? choice?.value ?? "";
|
|
191
|
+
renderPrompt([`${message} ${label}`], state);
|
|
192
|
+
process.stdout.write("\n");
|
|
193
|
+
cleanup();
|
|
194
|
+
resolve(choice?.value);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const onKey = (_char, key) => {
|
|
198
|
+
if (!key) return;
|
|
199
|
+
if (key.ctrl && key.name === "c") {
|
|
200
|
+
cleanup();
|
|
201
|
+
process.stdout.write("\n");
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
if (key.name === "up") {
|
|
205
|
+
cursor = Math.max(0, cursor - 1);
|
|
206
|
+
render();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (key.name === "down") {
|
|
210
|
+
cursor = Math.min(choices.length - 1, cursor + 1);
|
|
211
|
+
render();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (key.name === "return" || key.name === "enter") {
|
|
215
|
+
finish();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const cleanup = () => {
|
|
220
|
+
process.stdin.removeListener("keypress", onKey);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
process.stdin.on("keypress", onKey);
|
|
224
|
+
render();
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function promptConfirm(message, defaultValue) {
|
|
230
|
+
return promptSelect({
|
|
231
|
+
message,
|
|
232
|
+
choices: [
|
|
233
|
+
{ name: "Yes", value: true },
|
|
234
|
+
{ name: "No", value: false },
|
|
235
|
+
],
|
|
236
|
+
defaultValue: defaultValue ? true : false,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function promptMultiSelect({
|
|
241
|
+
message,
|
|
242
|
+
choices,
|
|
243
|
+
defaultValues = [],
|
|
244
|
+
pageSize = 12,
|
|
245
|
+
}) {
|
|
246
|
+
return withRawInput(
|
|
247
|
+
() =>
|
|
248
|
+
new Promise((resolve) => {
|
|
249
|
+
const state = { linesRendered: 0 };
|
|
250
|
+
let filter = "";
|
|
251
|
+
let cursor = 0;
|
|
252
|
+
const selected = new Set(defaultValues);
|
|
253
|
+
|
|
254
|
+
const getFiltered = () => {
|
|
255
|
+
if (!filter.trim()) return choices;
|
|
256
|
+
const term = filter.toLowerCase();
|
|
257
|
+
return choices.filter(
|
|
258
|
+
(choice) =>
|
|
259
|
+
choice.name.toLowerCase().includes(term) ||
|
|
260
|
+
choice.value.toLowerCase().includes(term),
|
|
261
|
+
);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const render = () => {
|
|
265
|
+
const filtered = getFiltered();
|
|
266
|
+
if (cursor > Math.max(0, filtered.length - 1)) cursor = 0;
|
|
267
|
+
|
|
268
|
+
const lines = [];
|
|
269
|
+
lines.push(message);
|
|
270
|
+
lines.push(
|
|
271
|
+
`Filter: ${filter || "(type to filter)"} (${selected.size} selected)`,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
if (filtered.length === 0) {
|
|
275
|
+
lines.push(" No matches");
|
|
276
|
+
renderPrompt(lines, state);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const startIndex = Math.max(
|
|
281
|
+
0,
|
|
282
|
+
Math.min(
|
|
283
|
+
cursor - Math.floor(pageSize / 2),
|
|
284
|
+
Math.max(0, filtered.length - pageSize),
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
const endIndex = Math.min(startIndex + pageSize, filtered.length);
|
|
288
|
+
const visible = filtered.slice(startIndex, endIndex);
|
|
289
|
+
|
|
290
|
+
for (let index = 0; index < visible.length; index += 1) {
|
|
291
|
+
const actualIndex = startIndex + index;
|
|
292
|
+
const choice = visible[index];
|
|
293
|
+
const pointer = actualIndex === cursor ? ">" : " ";
|
|
294
|
+
const checked = selected.has(choice.value) ? "[x]" : "[ ]";
|
|
295
|
+
lines.push(` ${pointer} ${checked} ${choice.name}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
renderPrompt(lines, state);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const finish = () => {
|
|
302
|
+
const ordered = choices
|
|
303
|
+
.filter((choice) => selected.has(choice.value))
|
|
304
|
+
.map((choice) => choice.value);
|
|
305
|
+
const summary = ordered.length > 0 ? ordered.join(", ") : "(none)";
|
|
306
|
+
renderPrompt([`${message} ${summary}`], state);
|
|
307
|
+
process.stdout.write("\n");
|
|
308
|
+
cleanup();
|
|
309
|
+
resolve(ordered);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const onKey = (char, key) => {
|
|
313
|
+
if (!key) return;
|
|
314
|
+
if (key.ctrl && key.name === "c") {
|
|
315
|
+
cleanup();
|
|
316
|
+
process.stdout.write("\n");
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
if (key.name === "up") {
|
|
320
|
+
cursor = Math.max(0, cursor - 1);
|
|
321
|
+
render();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (key.name === "down") {
|
|
325
|
+
cursor = Math.min(getFiltered().length - 1, cursor + 1);
|
|
326
|
+
render();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (key.name === "space") {
|
|
330
|
+
const filtered = getFiltered();
|
|
331
|
+
const choice = filtered[cursor];
|
|
332
|
+
if (choice) {
|
|
333
|
+
if (selected.has(choice.value)) {
|
|
334
|
+
selected.delete(choice.value);
|
|
335
|
+
} else {
|
|
336
|
+
selected.add(choice.value);
|
|
337
|
+
}
|
|
338
|
+
render();
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (key.name === "backspace") {
|
|
343
|
+
if (filter.length > 0) {
|
|
344
|
+
filter = filter.slice(0, -1);
|
|
345
|
+
cursor = 0;
|
|
346
|
+
render();
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (key.name === "return" || key.name === "enter") {
|
|
351
|
+
finish();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (char && char.length === 1 && !key.ctrl && !key.meta) {
|
|
355
|
+
filter += char;
|
|
356
|
+
cursor = 0;
|
|
357
|
+
render();
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const cleanup = () => {
|
|
362
|
+
process.stdin.removeListener("keypress", onKey);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
process.stdin.on("keypress", onKey);
|
|
366
|
+
render();
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
130
371
|
|
|
131
372
|
async function sendTestNotification(config, repoName) {
|
|
132
373
|
const title = config.title || `${repoName}@test`;
|
|
@@ -202,121 +443,184 @@ async function main() {
|
|
|
202
443
|
console.log("Installed .opencode/notify-init.mjs and schema in this repo.");
|
|
203
444
|
return;
|
|
204
445
|
}
|
|
205
|
-
const
|
|
446
|
+
const interactive = isInteractive();
|
|
447
|
+
const rl = interactive ? null : createInterface({ input: process.stdin, output: process.stdout });
|
|
206
448
|
|
|
207
449
|
console.log("OpenCode Notify — per-repo setup\n");
|
|
208
450
|
|
|
209
|
-
const
|
|
210
|
-
"Enable notifications for this repo?
|
|
211
|
-
|
|
212
|
-
const enabled = yesNo(enableInput, true);
|
|
451
|
+
const enabled = interactive
|
|
452
|
+
? await promptConfirm("Enable notifications for this repo?", true)
|
|
453
|
+
: yesNo(await rl.question("Enable notifications for this repo? (Y/n) [Y] "), true);
|
|
213
454
|
if (!enabled) {
|
|
214
|
-
await rl.close();
|
|
455
|
+
if (rl) await rl.close();
|
|
215
456
|
return;
|
|
216
457
|
}
|
|
217
458
|
|
|
218
|
-
const titleInput =
|
|
219
|
-
`Notification title (blank = "${repoName}@<branch>"):
|
|
220
|
-
|
|
459
|
+
const titleInput = interactive
|
|
460
|
+
? await promptInput(`Notification title (blank = "${repoName}@<branch>"): `)
|
|
461
|
+
: await rl.question(`Notification title (blank = "${repoName}@<branch>"): `);
|
|
221
462
|
const title = titleInput.trim();
|
|
222
463
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
: "
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
464
|
+
const tier = interactive
|
|
465
|
+
? await promptSelect({
|
|
466
|
+
message: "Select a tier:",
|
|
467
|
+
choices: [
|
|
468
|
+
{ name: "Focus (requests + completion)", value: "focus" },
|
|
469
|
+
{ name: "Full (standard + failures + summary)", value: "full" },
|
|
470
|
+
{ name: "Custom", value: "custom" },
|
|
471
|
+
],
|
|
472
|
+
defaultValue: "focus",
|
|
473
|
+
})
|
|
474
|
+
: await (async () => {
|
|
475
|
+
console.log("\nSelect a tier:\n 1) Focus (requests + completion)\n 2) Full (standard + failures + summary)\n 3) Custom");
|
|
476
|
+
const tierInput = await rl.question("> [1] ");
|
|
477
|
+
return tierInput.trim() === "2"
|
|
478
|
+
? "full"
|
|
479
|
+
: tierInput.trim() === "3"
|
|
480
|
+
? "custom"
|
|
481
|
+
: "focus";
|
|
482
|
+
})();
|
|
483
|
+
|
|
484
|
+
const trigger = interactive
|
|
485
|
+
? await promptSelect({
|
|
486
|
+
message: "Response-complete trigger:",
|
|
487
|
+
choices: [
|
|
488
|
+
{ name: "session.idle (coarse, low noise)", value: "session.idle" },
|
|
489
|
+
{ name: "message.updated (precise)", value: "message.updated" },
|
|
490
|
+
{
|
|
491
|
+
name: "message.part.updated (very precise, more noise)",
|
|
492
|
+
value: "message.part.updated",
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
defaultValue: "message.updated",
|
|
496
|
+
})
|
|
497
|
+
: await (async () => {
|
|
498
|
+
console.log(
|
|
499
|
+
"\nResponse-complete trigger:\n 1) session.idle (coarse, low noise)\n 2) message.updated (precise)\n 3) message.part.updated (very precise, more noise)",
|
|
500
|
+
);
|
|
501
|
+
const triggerInput = await rl.question("> [2] ");
|
|
502
|
+
return triggerInput.trim() === "1"
|
|
503
|
+
? "session.idle"
|
|
504
|
+
: triggerInput.trim() === "3"
|
|
505
|
+
? "message.part.updated"
|
|
506
|
+
: "message.updated";
|
|
507
|
+
})();
|
|
508
|
+
|
|
509
|
+
const macEnabled = interactive
|
|
510
|
+
? await promptConfirm("Enable macOS Notification Center?", true)
|
|
511
|
+
: yesNo(
|
|
512
|
+
await rl.question(
|
|
513
|
+
"\nEnable macOS Notification Center? (Y/n) [Y] ",
|
|
514
|
+
),
|
|
515
|
+
true,
|
|
516
|
+
);
|
|
251
517
|
|
|
252
|
-
const
|
|
253
|
-
|
|
518
|
+
const ntfyEnabled = interactive
|
|
519
|
+
? await promptConfirm("Enable ntfy notifications?", true)
|
|
520
|
+
: yesNo(await rl.question("Enable ntfy notifications? (Y/n) [Y] "), true);
|
|
254
521
|
|
|
255
522
|
let globalConfig = await readJson(GLOBAL_CONFIG_PATH);
|
|
256
523
|
if (!globalConfig) globalConfig = {};
|
|
257
524
|
if (!globalConfig.ntfy) globalConfig.ntfy = {};
|
|
258
525
|
|
|
259
526
|
if (ntfyEnabled) {
|
|
260
|
-
const serverInput =
|
|
261
|
-
|
|
262
|
-
|
|
527
|
+
const serverInput = interactive
|
|
528
|
+
? await promptInput(
|
|
529
|
+
`ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
|
|
530
|
+
)
|
|
531
|
+
: await rl.question(
|
|
532
|
+
`ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
|
|
533
|
+
);
|
|
263
534
|
const topicDefault = globalConfig.ntfy.topic || DEFAULTS.channels.ntfy.topic;
|
|
264
535
|
const topicLabel = topicDefault || "<your-topic>";
|
|
265
|
-
const topicInput =
|
|
266
|
-
|
|
267
|
-
|
|
536
|
+
const topicInput = interactive
|
|
537
|
+
? await promptInput(
|
|
538
|
+
`ntfy topic (blank = ${topicLabel}; see docs/ntfy-topic.md): `,
|
|
539
|
+
)
|
|
540
|
+
: await rl.question(
|
|
541
|
+
`ntfy topic (blank = ${topicLabel}; see docs/ntfy-topic.md): `,
|
|
542
|
+
);
|
|
268
543
|
if (serverInput.trim()) globalConfig.ntfy.server = serverInput.trim();
|
|
269
544
|
if (topicInput.trim()) globalConfig.ntfy.topic = topicInput.trim();
|
|
270
545
|
}
|
|
271
546
|
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
547
|
+
const detailLevel = interactive
|
|
548
|
+
? await promptSelect({
|
|
549
|
+
message: "Detail level:",
|
|
550
|
+
choices: [
|
|
551
|
+
{ name: "full", value: "full" },
|
|
552
|
+
{ name: "title-only", value: "title-only" },
|
|
553
|
+
],
|
|
554
|
+
defaultValue: "full",
|
|
555
|
+
})
|
|
556
|
+
: (await rl.question("Detail level (1=full, 2=title-only) [1] ")).trim() === "2"
|
|
557
|
+
? "title-only"
|
|
558
|
+
: "full";
|
|
559
|
+
|
|
560
|
+
const adjustGroups = interactive
|
|
561
|
+
? await promptConfirm("Adjust event groups?", false)
|
|
562
|
+
: yesNo(await rl.question("\nAdjust event groups? (y/N) [N] "), false);
|
|
281
563
|
let includeGroups = [];
|
|
282
564
|
let excludeGroups = [];
|
|
283
565
|
if (adjustGroups) {
|
|
284
|
-
const advanced =
|
|
285
|
-
await
|
|
286
|
-
false
|
|
287
|
-
);
|
|
566
|
+
const advanced = interactive
|
|
567
|
+
? await promptConfirm("Show advanced groups?", false)
|
|
568
|
+
: yesNo(await rl.question("Show advanced groups? (y/N) [N] "), false);
|
|
288
569
|
const allowed = advanced
|
|
289
570
|
? GROUPS_BASIC.concat(GROUPS_ADVANCED)
|
|
290
571
|
: GROUPS_BASIC;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
572
|
+
if (interactive) {
|
|
573
|
+
const choices = allowed.map((group) => ({ name: group, value: group }));
|
|
574
|
+
includeGroups = await promptMultiSelect({
|
|
575
|
+
message: "Include groups:",
|
|
576
|
+
choices,
|
|
577
|
+
defaultValues: [],
|
|
578
|
+
});
|
|
579
|
+
excludeGroups = await promptMultiSelect({
|
|
580
|
+
message: "Exclude groups:",
|
|
581
|
+
choices,
|
|
582
|
+
defaultValues: [],
|
|
583
|
+
});
|
|
584
|
+
} else {
|
|
585
|
+
console.log(`Available groups: ${allowed.join(", ")}`);
|
|
586
|
+
const includeInput = await rl.question(
|
|
587
|
+
"Include groups (comma-separated, blank to skip): ",
|
|
588
|
+
);
|
|
589
|
+
const excludeInput = await rl.question(
|
|
590
|
+
"Exclude groups (comma-separated, blank to skip): ",
|
|
591
|
+
);
|
|
592
|
+
includeGroups = parseList(includeInput, allowed);
|
|
593
|
+
excludeGroups = parseList(excludeInput, allowed);
|
|
594
|
+
}
|
|
300
595
|
}
|
|
301
596
|
|
|
302
|
-
const installTN =
|
|
303
|
-
await
|
|
304
|
-
|
|
305
|
-
|
|
597
|
+
const installTN = interactive
|
|
598
|
+
? await promptConfirm("Use terminal-notifier if available?", true)
|
|
599
|
+
: yesNo(
|
|
600
|
+
await rl.question(
|
|
601
|
+
"\nUse terminal-notifier if available? (Y/n) [Y] ",
|
|
602
|
+
),
|
|
603
|
+
true,
|
|
604
|
+
);
|
|
306
605
|
|
|
307
606
|
if (installTN) {
|
|
308
607
|
const hasTN = await commandExists("terminal-notifier");
|
|
309
608
|
const hasBrew = await commandExists("brew");
|
|
310
609
|
if (!hasTN && hasBrew) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
610
|
+
const installNow = interactive
|
|
611
|
+
? await promptConfirm(
|
|
612
|
+
"terminal-notifier not found. Install with Homebrew now?",
|
|
613
|
+
false,
|
|
614
|
+
)
|
|
615
|
+
: yesNo(
|
|
616
|
+
await rl.question(
|
|
617
|
+
"terminal-notifier not found. Install with Homebrew now? (y/N) [N] ",
|
|
618
|
+
),
|
|
619
|
+
false,
|
|
620
|
+
);
|
|
621
|
+
if (installNow) {
|
|
622
|
+
try {
|
|
623
|
+
const { execSync } = await import("node:child_process");
|
|
320
624
|
execSync("brew install terminal-notifier", { stdio: "inherit" });
|
|
321
625
|
} catch {
|
|
322
626
|
console.log("Homebrew install failed. Falling back to AppleScript.");
|
|
@@ -352,25 +656,28 @@ async function main() {
|
|
|
352
656
|
},
|
|
353
657
|
};
|
|
354
658
|
|
|
355
|
-
const
|
|
356
|
-
"
|
|
357
|
-
|
|
358
|
-
|
|
659
|
+
const shouldWrite = interactive
|
|
660
|
+
? await promptConfirm("Write config to .opencode/oc-notify.json?", true)
|
|
661
|
+
: yesNo(
|
|
662
|
+
await rl.question(
|
|
663
|
+
"\nWrite config to .opencode/oc-notify.json? (Y/n) [Y] ",
|
|
664
|
+
),
|
|
665
|
+
true,
|
|
666
|
+
);
|
|
359
667
|
if (shouldWrite) {
|
|
360
668
|
await ensureOpencodeDir(cwd);
|
|
361
669
|
await writeJson(join(cwd, REPO_CONFIG_PATH), config);
|
|
362
670
|
await copySchema(cwd);
|
|
363
671
|
}
|
|
364
672
|
|
|
365
|
-
const
|
|
366
|
-
"Send a test notification now?
|
|
367
|
-
|
|
368
|
-
const sendTest = yesNo(testInput, true);
|
|
673
|
+
const sendTest = interactive
|
|
674
|
+
? await promptConfirm("Send a test notification now?", true)
|
|
675
|
+
: yesNo(await rl.question("Send a test notification now? (Y/n) [Y] "), true);
|
|
369
676
|
if (sendTest) {
|
|
370
677
|
await sendTestNotification(config, repoName);
|
|
371
678
|
}
|
|
372
679
|
|
|
373
|
-
await rl.close();
|
|
680
|
+
if (rl) await rl.close();
|
|
374
681
|
}
|
|
375
682
|
|
|
376
683
|
await main();
|
package/README.md
CHANGED
|
@@ -24,19 +24,7 @@ npx @goodnesshq/opencode-notification install
|
|
|
24
24
|
This only installs repo assets and prints next steps for manual setup.
|
|
25
25
|
|
|
26
26
|
Before running the installer, create a unique ntfy topic and set up mobile notifications. See `docs/ntfy-topic.md`.
|
|
27
|
-
|
|
28
|
-
### Update guidance
|
|
29
|
-
|
|
30
|
-
- One-off: `npx @goodnesshq/opencode-notification@latest setup`
|
|
31
|
-
- Global: `npm update -g @goodnesshq/opencode-notification`
|
|
32
|
-
|
|
33
|
-
## Troubleshooting
|
|
34
|
-
|
|
35
|
-
**OpenCode fails to start after enabling the plugin**
|
|
36
|
-
|
|
37
|
-
- Ensure every entry in `plugin` points to an existing file.
|
|
38
|
-
- Remove stale plugin paths from other repos.
|
|
39
|
-
- Use absolute paths (no `~`).
|
|
27
|
+
The installer uses an interactive, arrow-key prompt flow (with a non-interactive fallback in non-TTY environments).
|
|
40
28
|
|
|
41
29
|
## Config Schema
|
|
42
30
|
|
|
@@ -44,7 +32,7 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
|
|
|
44
32
|
|
|
45
33
|
- `enabled`: master switch
|
|
46
34
|
- `title`: override or template (supports `{repo}` and `{branch}`)
|
|
47
|
-
- `tier`: `
|
|
35
|
+
- `tier`: `focus` | `full` | `custom`
|
|
48
36
|
- `detailLevel`: `full` | `title-only`
|
|
49
37
|
- `responseComplete`: trigger for completion notifications
|
|
50
38
|
- `channels`: `mac` and `ntfy` settings
|
|
@@ -53,17 +41,16 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
|
|
|
53
41
|
|
|
54
42
|
## Tier Defaults
|
|
55
43
|
|
|
56
|
-
- **
|
|
57
|
-
- **
|
|
58
|
-
- **verbose**: standard + `files`, `todos`, `vcs_worktree`, `pty`, `responses`
|
|
44
|
+
- **focus**: `action_required`
|
|
45
|
+
- **full**: `action_required`, `failures`, `change_summary`, `session_lifecycle`
|
|
59
46
|
|
|
60
47
|
## Example Configs
|
|
61
48
|
|
|
62
|
-
###
|
|
49
|
+
### Focus
|
|
63
50
|
```json
|
|
64
51
|
{
|
|
65
52
|
"enabled": true,
|
|
66
|
-
"tier": "
|
|
53
|
+
"tier": "focus",
|
|
67
54
|
"responseComplete": {
|
|
68
55
|
"enabled": true,
|
|
69
56
|
"trigger": "session.idle"
|
|
@@ -75,11 +62,11 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
|
|
|
75
62
|
}
|
|
76
63
|
```
|
|
77
64
|
|
|
78
|
-
###
|
|
65
|
+
### Full
|
|
79
66
|
```json
|
|
80
67
|
{
|
|
81
68
|
"enabled": true,
|
|
82
|
-
"tier": "
|
|
69
|
+
"tier": "full",
|
|
83
70
|
"detailLevel": "full",
|
|
84
71
|
"responseComplete": {
|
|
85
72
|
"enabled": true,
|
|
@@ -88,23 +75,34 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
|
|
|
88
75
|
}
|
|
89
76
|
```
|
|
90
77
|
|
|
91
|
-
###
|
|
78
|
+
### Custom (formerly verbose-style)
|
|
92
79
|
```json
|
|
93
80
|
{
|
|
94
81
|
"enabled": true,
|
|
95
|
-
"tier": "
|
|
82
|
+
"tier": "custom",
|
|
96
83
|
"responseComplete": {
|
|
97
84
|
"enabled": true,
|
|
98
85
|
"trigger": "message.part.updated"
|
|
99
86
|
},
|
|
100
87
|
"overrides": {
|
|
101
|
-
"includeGroups": [
|
|
88
|
+
"includeGroups": [
|
|
89
|
+
"action_required",
|
|
90
|
+
"failures",
|
|
91
|
+
"change_summary",
|
|
92
|
+
"session_lifecycle",
|
|
93
|
+
"files",
|
|
94
|
+
"todos",
|
|
95
|
+
"vcs_worktree",
|
|
96
|
+
"pty",
|
|
97
|
+
"responses",
|
|
98
|
+
"commands_tools"
|
|
99
|
+
],
|
|
102
100
|
"excludeGroups": []
|
|
103
101
|
}
|
|
104
102
|
}
|
|
105
103
|
```
|
|
106
104
|
|
|
107
|
-
### Custom
|
|
105
|
+
### Custom (curated)
|
|
108
106
|
```json
|
|
109
107
|
{
|
|
110
108
|
"enabled": true,
|
|
@@ -114,4 +112,12 @@ The config is defined in `.opencode/oc-notify.schema.json` and supports:
|
|
|
114
112
|
"excludeGroups": ["session_lifecycle"]
|
|
115
113
|
}
|
|
116
114
|
}
|
|
115
|
+
|
|
116
|
+
## Migration from legacy tiers
|
|
117
|
+
|
|
118
|
+
Legacy tiers `minimal`, `standard`, and `verbose` are no longer supported. Update existing configs before notifications resume:
|
|
119
|
+
|
|
120
|
+
- `minimal` -> `custom` with includeGroups: `action_required`, `failures`
|
|
121
|
+
- `standard` -> `full`
|
|
122
|
+
- `verbose` -> `custom` with includeGroups: `action_required`, `failures`, `change_summary`, `session_lifecycle`, `files`, `todos`, `vcs_worktree`, `pty`, `responses`
|
|
117
123
|
```
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@ const DEFAULTS = {
|
|
|
10
10
|
version: 1,
|
|
11
11
|
enabled: true,
|
|
12
12
|
title: "",
|
|
13
|
-
tier: "
|
|
13
|
+
tier: "focus",
|
|
14
14
|
detailLevel: "full",
|
|
15
15
|
responseComplete: {
|
|
16
16
|
enabled: true,
|
|
@@ -71,19 +71,8 @@ const GROUPS = {
|
|
|
71
71
|
};
|
|
72
72
|
|
|
73
73
|
const TIER_GROUPS = {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
verbose: [
|
|
77
|
-
"action_required",
|
|
78
|
-
"failures",
|
|
79
|
-
"change_summary",
|
|
80
|
-
"session_lifecycle",
|
|
81
|
-
"files",
|
|
82
|
-
"todos",
|
|
83
|
-
"vcs_worktree",
|
|
84
|
-
"pty",
|
|
85
|
-
"responses",
|
|
86
|
-
],
|
|
74
|
+
focus: ["action_required"],
|
|
75
|
+
full: ["action_required", "failures", "change_summary", "session_lifecycle"],
|
|
87
76
|
custom: [],
|
|
88
77
|
};
|
|
89
78
|
|
|
@@ -118,8 +107,11 @@ function normalizeArray(value) {
|
|
|
118
107
|
}
|
|
119
108
|
|
|
120
109
|
function normalizeTier(value) {
|
|
121
|
-
if (
|
|
122
|
-
|
|
110
|
+
if (value === undefined || value === null) {
|
|
111
|
+
return { value: DEFAULTS.tier, valid: true };
|
|
112
|
+
}
|
|
113
|
+
if (typeof value !== "string") return { value, valid: false };
|
|
114
|
+
return TIER_GROUPS[value] ? { value, valid: true } : { value, valid: false };
|
|
123
115
|
}
|
|
124
116
|
|
|
125
117
|
function normalizeTrigger(value) {
|
|
@@ -147,11 +139,13 @@ function normalizeConfig(raw, globalConfig) {
|
|
|
147
139
|
globalConfig && typeof globalConfig === "object" && !Array.isArray(globalConfig)
|
|
148
140
|
? globalConfig
|
|
149
141
|
: {};
|
|
142
|
+
const tierInfo = normalizeTier(source.tier);
|
|
150
143
|
const config = {
|
|
151
144
|
version: DEFAULTS.version,
|
|
152
145
|
enabled: normalizeBool(source.enabled, DEFAULTS.enabled),
|
|
153
146
|
title: normalizeString(source.title, DEFAULTS.title),
|
|
154
|
-
tier:
|
|
147
|
+
tier: tierInfo.value,
|
|
148
|
+
tierValid: tierInfo.valid,
|
|
155
149
|
detailLevel: source.detailLevel === "title-only" ? "title-only" : "full",
|
|
156
150
|
responseComplete: {
|
|
157
151
|
enabled: normalizeBool(
|
|
@@ -196,7 +190,7 @@ function normalizeConfig(raw, globalConfig) {
|
|
|
196
190
|
}
|
|
197
191
|
|
|
198
192
|
function buildGroupSet(config) {
|
|
199
|
-
const baseGroups = TIER_GROUPS[config.tier] ??
|
|
193
|
+
const baseGroups = TIER_GROUPS[config.tier] ?? [];
|
|
200
194
|
const groupSet = new Set(baseGroups);
|
|
201
195
|
for (const group of config.overrides.includeGroups) {
|
|
202
196
|
if (GROUPS[group]) groupSet.add(group);
|
|
@@ -655,6 +649,7 @@ export default async function opencodeNotify(input) {
|
|
|
655
649
|
console.warn("[opencode-notifications] invalid config detected", repoConfig.errors);
|
|
656
650
|
}
|
|
657
651
|
if (!config.enabled) return;
|
|
652
|
+
if (!config.tierValid) return;
|
|
658
653
|
|
|
659
654
|
if (payload?.type === "vcs.branch.updated" && payload?.properties?.branch) {
|
|
660
655
|
cachedBranch = payload.properties.branch;
|