@goodnesshq/opencode-notification 0.1.5 → 0.1.6

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.
@@ -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";
@@ -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,189 @@ async function main() {
202
443
  console.log("Installed .opencode/notify-init.mjs and schema in this repo.");
203
444
  return;
204
445
  }
205
- const rl = createInterface({ input: process.stdin, output: process.stdout });
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 enableInput = await rl.question(
210
- "Enable notifications for this repo? (Y/n) [Y] ",
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 = await rl.question(
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
- console.log(
224
- "\nSelect a tier:\n 1) Minimal\n 2) Standard\n 3) Verbose\n 4) Custom",
225
- );
226
- const tierInput = await rl.question("> [2] ");
227
- const tier =
228
- tierInput.trim() === "1"
229
- ? "minimal"
230
- : tierInput.trim() === "3"
231
- ? "verbose"
232
- : tierInput.trim() === "4"
233
- ? "custom"
234
- : "standard";
235
-
236
- console.log(
237
- "\nResponse-complete trigger:\n 1) session.idle (coarse, low noise)\n 2) message.updated (precise)\n 3) message.part.updated (very precise, more noise)",
238
- );
239
- const triggerInput = await rl.question("> [2] ");
240
- const trigger =
241
- triggerInput.trim() === "1"
242
- ? "session.idle"
243
- : triggerInput.trim() === "3"
244
- ? "message.part.updated"
245
- : "message.updated";
246
-
247
- const macInput = await rl.question(
248
- "\nEnable macOS Notification Center? (Y/n) [Y] ",
249
- );
250
- const macEnabled = yesNo(macInput, true);
464
+ const tier = interactive
465
+ ? await promptSelect({
466
+ message: "Select a tier:",
467
+ choices: [
468
+ { name: "Minimal", value: "minimal" },
469
+ { name: "Standard", value: "standard" },
470
+ { name: "Verbose", value: "verbose" },
471
+ { name: "Custom", value: "custom" },
472
+ ],
473
+ defaultValue: "standard",
474
+ })
475
+ : await (async () => {
476
+ console.log(
477
+ "\nSelect a tier:\n 1) Minimal\n 2) Standard\n 3) Verbose\n 4) Custom",
478
+ );
479
+ const tierInput = await rl.question("> [2] ");
480
+ return tierInput.trim() === "1"
481
+ ? "minimal"
482
+ : tierInput.trim() === "3"
483
+ ? "verbose"
484
+ : tierInput.trim() === "4"
485
+ ? "custom"
486
+ : "standard";
487
+ })();
488
+
489
+ const trigger = interactive
490
+ ? await promptSelect({
491
+ message: "Response-complete trigger:",
492
+ choices: [
493
+ { name: "session.idle (coarse, low noise)", value: "session.idle" },
494
+ { name: "message.updated (precise)", value: "message.updated" },
495
+ {
496
+ name: "message.part.updated (very precise, more noise)",
497
+ value: "message.part.updated",
498
+ },
499
+ ],
500
+ defaultValue: "message.updated",
501
+ })
502
+ : await (async () => {
503
+ console.log(
504
+ "\nResponse-complete trigger:\n 1) session.idle (coarse, low noise)\n 2) message.updated (precise)\n 3) message.part.updated (very precise, more noise)",
505
+ );
506
+ const triggerInput = await rl.question("> [2] ");
507
+ return triggerInput.trim() === "1"
508
+ ? "session.idle"
509
+ : triggerInput.trim() === "3"
510
+ ? "message.part.updated"
511
+ : "message.updated";
512
+ })();
513
+
514
+ const macEnabled = interactive
515
+ ? await promptConfirm("Enable macOS Notification Center?", true)
516
+ : yesNo(
517
+ await rl.question(
518
+ "\nEnable macOS Notification Center? (Y/n) [Y] ",
519
+ ),
520
+ true,
521
+ );
251
522
 
252
- const ntfyInput = await rl.question("Enable ntfy notifications? (Y/n) [Y] ");
253
- const ntfyEnabled = yesNo(ntfyInput, true);
523
+ const ntfyEnabled = interactive
524
+ ? await promptConfirm("Enable ntfy notifications?", true)
525
+ : yesNo(await rl.question("Enable ntfy notifications? (Y/n) [Y] "), true);
254
526
 
255
527
  let globalConfig = await readJson(GLOBAL_CONFIG_PATH);
256
528
  if (!globalConfig) globalConfig = {};
257
529
  if (!globalConfig.ntfy) globalConfig.ntfy = {};
258
530
 
259
531
  if (ntfyEnabled) {
260
- const serverInput = await rl.question(
261
- `ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
262
- );
532
+ const serverInput = interactive
533
+ ? await promptInput(
534
+ `ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
535
+ )
536
+ : await rl.question(
537
+ `ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
538
+ );
263
539
  const topicDefault = globalConfig.ntfy.topic || DEFAULTS.channels.ntfy.topic;
264
540
  const topicLabel = topicDefault || "<your-topic>";
265
- const topicInput = await rl.question(
266
- `ntfy topic (blank = ${topicLabel}; see docs/ntfy-topic.md): `,
267
- );
541
+ const topicInput = interactive
542
+ ? await promptInput(
543
+ `ntfy topic (blank = ${topicLabel}; see docs/ntfy-topic.md): `,
544
+ )
545
+ : await rl.question(
546
+ `ntfy topic (blank = ${topicLabel}; see docs/ntfy-topic.md): `,
547
+ );
268
548
  if (serverInput.trim()) globalConfig.ntfy.server = serverInput.trim();
269
549
  if (topicInput.trim()) globalConfig.ntfy.topic = topicInput.trim();
270
550
  }
271
551
 
272
- const detailInput = await rl.question(
273
- "Detail level (1=full, 2=title-only) [1] ",
274
- );
275
- const detailLevel = detailInput.trim() === "2" ? "title-only" : "full";
276
-
277
- const adjustGroups = yesNo(
278
- await rl.question("\nAdjust event groups? (y/N) [N] "),
279
- false,
280
- );
552
+ const detailLevel = interactive
553
+ ? await promptSelect({
554
+ message: "Detail level:",
555
+ choices: [
556
+ { name: "full", value: "full" },
557
+ { name: "title-only", value: "title-only" },
558
+ ],
559
+ defaultValue: "full",
560
+ })
561
+ : (await rl.question("Detail level (1=full, 2=title-only) [1] ")).trim() === "2"
562
+ ? "title-only"
563
+ : "full";
564
+
565
+ const adjustGroups = interactive
566
+ ? await promptConfirm("Adjust event groups?", false)
567
+ : yesNo(await rl.question("\nAdjust event groups? (y/N) [N] "), false);
281
568
  let includeGroups = [];
282
569
  let excludeGroups = [];
283
570
  if (adjustGroups) {
284
- const advanced = yesNo(
285
- await rl.question("Show advanced groups? (y/N) [N] "),
286
- false,
287
- );
571
+ const advanced = interactive
572
+ ? await promptConfirm("Show advanced groups?", false)
573
+ : yesNo(await rl.question("Show advanced groups? (y/N) [N] "), false);
288
574
  const allowed = advanced
289
575
  ? GROUPS_BASIC.concat(GROUPS_ADVANCED)
290
576
  : GROUPS_BASIC;
291
- console.log(`Available groups: ${allowed.join(", ")}`);
292
- const includeInput = await rl.question(
293
- "Include groups (comma-separated, blank to skip): ",
294
- );
295
- const excludeInput = await rl.question(
296
- "Exclude groups (comma-separated, blank to skip): ",
297
- );
298
- includeGroups = parseList(includeInput, allowed);
299
- excludeGroups = parseList(excludeInput, allowed);
577
+ if (interactive) {
578
+ const choices = allowed.map((group) => ({ name: group, value: group }));
579
+ includeGroups = await promptMultiSelect({
580
+ message: "Include groups:",
581
+ choices,
582
+ defaultValues: [],
583
+ });
584
+ excludeGroups = await promptMultiSelect({
585
+ message: "Exclude groups:",
586
+ choices,
587
+ defaultValues: [],
588
+ });
589
+ } else {
590
+ console.log(`Available groups: ${allowed.join(", ")}`);
591
+ const includeInput = await rl.question(
592
+ "Include groups (comma-separated, blank to skip): ",
593
+ );
594
+ const excludeInput = await rl.question(
595
+ "Exclude groups (comma-separated, blank to skip): ",
596
+ );
597
+ includeGroups = parseList(includeInput, allowed);
598
+ excludeGroups = parseList(excludeInput, allowed);
599
+ }
300
600
  }
301
601
 
302
- const installTN = yesNo(
303
- await rl.question("\nUse terminal-notifier if available? (Y/n) [Y] "),
304
- true,
305
- );
602
+ const installTN = interactive
603
+ ? await promptConfirm("Use terminal-notifier if available?", true)
604
+ : yesNo(
605
+ await rl.question(
606
+ "\nUse terminal-notifier if available? (Y/n) [Y] ",
607
+ ),
608
+ true,
609
+ );
306
610
 
307
611
  if (installTN) {
308
612
  const hasTN = await commandExists("terminal-notifier");
309
613
  const hasBrew = await commandExists("brew");
310
614
  if (!hasTN && hasBrew) {
311
- const installNow = yesNo(
312
- await rl.question(
313
- "terminal-notifier not found. Install with Homebrew now? (y/N) [N] ",
314
- ),
315
- false,
316
- );
317
- if (installNow) {
318
- try {
319
- const { execSync } = await import("node:child_process");
615
+ const installNow = interactive
616
+ ? await promptConfirm(
617
+ "terminal-notifier not found. Install with Homebrew now?",
618
+ false,
619
+ )
620
+ : yesNo(
621
+ await rl.question(
622
+ "terminal-notifier not found. Install with Homebrew now? (y/N) [N] ",
623
+ ),
624
+ false,
625
+ );
626
+ if (installNow) {
627
+ try {
628
+ const { execSync } = await import("node:child_process");
320
629
  execSync("brew install terminal-notifier", { stdio: "inherit" });
321
630
  } catch {
322
631
  console.log("Homebrew install failed. Falling back to AppleScript.");
@@ -352,25 +661,28 @@ async function main() {
352
661
  },
353
662
  };
354
663
 
355
- const writeInput = await rl.question(
356
- "\nWrite config to .opencode/oc-notify.json? (Y/n) [Y] ",
357
- );
358
- const shouldWrite = yesNo(writeInput, true);
664
+ const shouldWrite = interactive
665
+ ? await promptConfirm("Write config to .opencode/oc-notify.json?", true)
666
+ : yesNo(
667
+ await rl.question(
668
+ "\nWrite config to .opencode/oc-notify.json? (Y/n) [Y] ",
669
+ ),
670
+ true,
671
+ );
359
672
  if (shouldWrite) {
360
673
  await ensureOpencodeDir(cwd);
361
674
  await writeJson(join(cwd, REPO_CONFIG_PATH), config);
362
675
  await copySchema(cwd);
363
676
  }
364
677
 
365
- const testInput = await rl.question(
366
- "Send a test notification now? (Y/n) [Y] ",
367
- );
368
- const sendTest = yesNo(testInput, true);
678
+ const sendTest = interactive
679
+ ? await promptConfirm("Send a test notification now?", true)
680
+ : yesNo(await rl.question("Send a test notification now? (Y/n) [Y] "), true);
369
681
  if (sendTest) {
370
682
  await sendTestNotification(config, repoName);
371
683
  }
372
684
 
373
- await rl.close();
685
+ if (rl) await rl.close();
374
686
  }
375
687
 
376
688
  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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodnesshq/opencode-notification",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Per-repo notification plugin for OpenCode with macOS and ntfy delivery.",
5
5
  "type": "module",
6
6
  "bin": {