@goodnesshq/opencode-notification 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opencode/notify-init.mjs +374 -0
- package/.opencode/oc-notify.schema.json +101 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/bin/ocn.mjs +81 -0
- package/package.json +24 -0
- package/plugins/opencode-notifications.mjs +690 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile, mkdir, copyFile, stat } from "node:fs/promises";
|
|
3
|
+
import { join, basename, dirname } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const REPO_CONFIG_PATH = ".opencode/oc-notify.json";
|
|
9
|
+
const GLOBAL_CONFIG_PATH = join(
|
|
10
|
+
homedir(),
|
|
11
|
+
".config",
|
|
12
|
+
"opencode",
|
|
13
|
+
"oc-notify.json",
|
|
14
|
+
);
|
|
15
|
+
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
16
|
+
const SCRIPT_DIR = dirname(SCRIPT_PATH);
|
|
17
|
+
const SCHEMA_SOURCE_PATH = join(SCRIPT_DIR, "oc-notify.schema.json");
|
|
18
|
+
|
|
19
|
+
const DEFAULTS = {
|
|
20
|
+
enabled: true,
|
|
21
|
+
title: "",
|
|
22
|
+
tier: "standard",
|
|
23
|
+
detailLevel: "full",
|
|
24
|
+
responseComplete: {
|
|
25
|
+
enabled: true,
|
|
26
|
+
trigger: "message.updated",
|
|
27
|
+
},
|
|
28
|
+
channels: {
|
|
29
|
+
mac: {
|
|
30
|
+
enabled: true,
|
|
31
|
+
method: "auto",
|
|
32
|
+
},
|
|
33
|
+
ntfy: {
|
|
34
|
+
enabled: true,
|
|
35
|
+
server: "https://ntfy.sh",
|
|
36
|
+
topic: "oc-goodness-attn-1407537",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
overrides: {
|
|
40
|
+
includeGroups: [],
|
|
41
|
+
excludeGroups: [],
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const GROUPS_BASIC = [
|
|
46
|
+
"action_required",
|
|
47
|
+
"failures",
|
|
48
|
+
"change_summary",
|
|
49
|
+
"session_lifecycle",
|
|
50
|
+
"responses",
|
|
51
|
+
"vcs_worktree",
|
|
52
|
+
"todos",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const GROUPS_ADVANCED = [
|
|
56
|
+
"files",
|
|
57
|
+
"pty",
|
|
58
|
+
"commands_tools",
|
|
59
|
+
"message_lifecycle",
|
|
60
|
+
"message_parts",
|
|
61
|
+
"system",
|
|
62
|
+
"lsp",
|
|
63
|
+
"tui",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
async function commandExists(command) {
|
|
67
|
+
try {
|
|
68
|
+
const { execSync } = await import("node:child_process");
|
|
69
|
+
execSync(`command -v ${command}`, { stdio: "ignore" });
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function yesNo(value, fallback) {
|
|
77
|
+
if (!value) return fallback;
|
|
78
|
+
const normalized = value.trim().toLowerCase();
|
|
79
|
+
if (normalized === "y" || normalized === "yes") return true;
|
|
80
|
+
if (normalized === "n" || normalized === "no") return false;
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseList(input, allowed) {
|
|
85
|
+
if (!input) return [];
|
|
86
|
+
const items = input
|
|
87
|
+
.split(",")
|
|
88
|
+
.map((item) => item.trim())
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
if (!allowed) return items;
|
|
91
|
+
return items.filter((item) => allowed.includes(item));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readJson(path) {
|
|
95
|
+
try {
|
|
96
|
+
const content = await readFile(path, "utf8");
|
|
97
|
+
return JSON.parse(content);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function writeJson(path, data) {
|
|
104
|
+
const content = JSON.stringify(data, null, 2) + "\n";
|
|
105
|
+
await writeFile(path, content, "utf8");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function fileExists(path) {
|
|
109
|
+
try {
|
|
110
|
+
await stat(path);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function ensureOpencodeDir(cwd) {
|
|
118
|
+
await mkdir(join(cwd, ".opencode"), { recursive: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function copySchema(cwd) {
|
|
122
|
+
try {
|
|
123
|
+
await copyFile(
|
|
124
|
+
SCHEMA_SOURCE_PATH,
|
|
125
|
+
join(cwd, ".opencode", "oc-notify.schema.json"),
|
|
126
|
+
);
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async function sendTestNotification(config, repoName) {
|
|
132
|
+
const title = config.title || `${repoName}@test`;
|
|
133
|
+
const subtitle = "OpenCode Notify";
|
|
134
|
+
const message = "Test notification";
|
|
135
|
+
const useTerminalNotifier =
|
|
136
|
+
config.channels.mac.enabled && (await commandExists("terminal-notifier"));
|
|
137
|
+
|
|
138
|
+
if (config.channels.mac.enabled) {
|
|
139
|
+
try {
|
|
140
|
+
const { execFileSync } = await import("node:child_process");
|
|
141
|
+
if (useTerminalNotifier && config.channels.mac.method !== "applescript") {
|
|
142
|
+
execFileSync("terminal-notifier", [
|
|
143
|
+
"-title",
|
|
144
|
+
title,
|
|
145
|
+
"-message",
|
|
146
|
+
message,
|
|
147
|
+
"-subtitle",
|
|
148
|
+
subtitle,
|
|
149
|
+
]);
|
|
150
|
+
} else {
|
|
151
|
+
const script = [
|
|
152
|
+
"on run argv",
|
|
153
|
+
"set theMessage to item 1 of argv",
|
|
154
|
+
"set theTitle to item 2 of argv",
|
|
155
|
+
"set theSubtitle to item 3 of argv",
|
|
156
|
+
'if theSubtitle is "" then',
|
|
157
|
+
" display notification theMessage with title theTitle",
|
|
158
|
+
"else",
|
|
159
|
+
" display notification theMessage with title theTitle subtitle theSubtitle",
|
|
160
|
+
"end if",
|
|
161
|
+
"end run",
|
|
162
|
+
].join("\n");
|
|
163
|
+
execFileSync("osascript", ["-e", script, message, title, subtitle]);
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
console.log("Test macOS notification failed.");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (config.channels.ntfy.enabled) {
|
|
171
|
+
try {
|
|
172
|
+
const { execFileSync } = await import("node:child_process");
|
|
173
|
+
const server = config.channels.ntfy.server.replace(/\/$/, "");
|
|
174
|
+
const url = `${server}/${config.channels.ntfy.topic}`;
|
|
175
|
+
execFileSync("curl", [
|
|
176
|
+
"-sS",
|
|
177
|
+
"-H",
|
|
178
|
+
`Title: ${title}`,
|
|
179
|
+
"-H",
|
|
180
|
+
"Priority: 3",
|
|
181
|
+
"-H",
|
|
182
|
+
"Tags: opencode,info",
|
|
183
|
+
"-d",
|
|
184
|
+
message,
|
|
185
|
+
url,
|
|
186
|
+
]);
|
|
187
|
+
} catch {
|
|
188
|
+
console.log("Test ntfy notification failed.");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function main() {
|
|
194
|
+
const cwd = process.cwd();
|
|
195
|
+
const repoName = basename(cwd);
|
|
196
|
+
const args = process.argv.slice(2);
|
|
197
|
+
|
|
198
|
+
if (args.includes("--install")) {
|
|
199
|
+
await ensureOpencodeDir(cwd);
|
|
200
|
+
await copyFile(SCRIPT_PATH, join(cwd, ".opencode", "notify-init.mjs"));
|
|
201
|
+
await copySchema(cwd);
|
|
202
|
+
console.log("Installed .opencode/notify-init.mjs and schema in this repo.");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
206
|
+
|
|
207
|
+
console.log("OpenCode Notify — per-repo setup\n");
|
|
208
|
+
|
|
209
|
+
const enableInput = await rl.question(
|
|
210
|
+
"Enable notifications for this repo? (Y/n) [Y] ",
|
|
211
|
+
);
|
|
212
|
+
const enabled = yesNo(enableInput, true);
|
|
213
|
+
if (!enabled) {
|
|
214
|
+
await rl.close();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const titleInput = await rl.question(
|
|
219
|
+
`Notification title (blank = "${repoName}@<branch>"): `,
|
|
220
|
+
);
|
|
221
|
+
const title = titleInput.trim();
|
|
222
|
+
|
|
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);
|
|
251
|
+
|
|
252
|
+
const ntfyInput = await rl.question("Enable ntfy notifications? (Y/n) [Y] ");
|
|
253
|
+
const ntfyEnabled = yesNo(ntfyInput, true);
|
|
254
|
+
|
|
255
|
+
let globalConfig = await readJson(GLOBAL_CONFIG_PATH);
|
|
256
|
+
if (!globalConfig) globalConfig = {};
|
|
257
|
+
if (!globalConfig.ntfy) globalConfig.ntfy = {};
|
|
258
|
+
|
|
259
|
+
if (ntfyEnabled) {
|
|
260
|
+
const serverInput = await rl.question(
|
|
261
|
+
`ntfy server (blank = ${globalConfig.ntfy.server || DEFAULTS.channels.ntfy.server}): `,
|
|
262
|
+
);
|
|
263
|
+
const topicInput = await rl.question(
|
|
264
|
+
`ntfy topic (blank = ${globalConfig.ntfy.topic || DEFAULTS.channels.ntfy.topic}): `,
|
|
265
|
+
);
|
|
266
|
+
if (serverInput.trim()) globalConfig.ntfy.server = serverInput.trim();
|
|
267
|
+
if (topicInput.trim()) globalConfig.ntfy.topic = topicInput.trim();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const detailInput = await rl.question(
|
|
271
|
+
"Detail level (1=full, 2=title-only) [1] ",
|
|
272
|
+
);
|
|
273
|
+
const detailLevel = detailInput.trim() === "2" ? "title-only" : "full";
|
|
274
|
+
|
|
275
|
+
const adjustGroups = yesNo(
|
|
276
|
+
await rl.question("\nAdjust event groups? (y/N) [N] "),
|
|
277
|
+
false,
|
|
278
|
+
);
|
|
279
|
+
let includeGroups = [];
|
|
280
|
+
let excludeGroups = [];
|
|
281
|
+
if (adjustGroups) {
|
|
282
|
+
const advanced = yesNo(
|
|
283
|
+
await rl.question("Show advanced groups? (y/N) [N] "),
|
|
284
|
+
false,
|
|
285
|
+
);
|
|
286
|
+
const allowed = advanced
|
|
287
|
+
? GROUPS_BASIC.concat(GROUPS_ADVANCED)
|
|
288
|
+
: GROUPS_BASIC;
|
|
289
|
+
console.log(`Available groups: ${allowed.join(", ")}`);
|
|
290
|
+
const includeInput = await rl.question(
|
|
291
|
+
"Include groups (comma-separated, blank to skip): ",
|
|
292
|
+
);
|
|
293
|
+
const excludeInput = await rl.question(
|
|
294
|
+
"Exclude groups (comma-separated, blank to skip): ",
|
|
295
|
+
);
|
|
296
|
+
includeGroups = parseList(includeInput, allowed);
|
|
297
|
+
excludeGroups = parseList(excludeInput, allowed);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const installTN = yesNo(
|
|
301
|
+
await rl.question("\nUse terminal-notifier if available? (Y/n) [Y] "),
|
|
302
|
+
true,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
if (installTN) {
|
|
306
|
+
const hasTN = await commandExists("terminal-notifier");
|
|
307
|
+
const hasBrew = await commandExists("brew");
|
|
308
|
+
if (!hasTN && hasBrew) {
|
|
309
|
+
const installNow = yesNo(
|
|
310
|
+
await rl.question(
|
|
311
|
+
"terminal-notifier not found. Install with Homebrew now? (y/N) [N] ",
|
|
312
|
+
),
|
|
313
|
+
false,
|
|
314
|
+
);
|
|
315
|
+
if (installNow) {
|
|
316
|
+
try {
|
|
317
|
+
const { execSync } = await import("node:child_process");
|
|
318
|
+
execSync("brew install terminal-notifier", { stdio: "inherit" });
|
|
319
|
+
} catch {
|
|
320
|
+
console.log("Homebrew install failed. Falling back to AppleScript.");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const config = {
|
|
327
|
+
version: 1,
|
|
328
|
+
enabled: true,
|
|
329
|
+
title,
|
|
330
|
+
tier,
|
|
331
|
+
detailLevel,
|
|
332
|
+
responseComplete: {
|
|
333
|
+
enabled: true,
|
|
334
|
+
trigger,
|
|
335
|
+
},
|
|
336
|
+
channels: {
|
|
337
|
+
mac: {
|
|
338
|
+
enabled: macEnabled,
|
|
339
|
+
method: installTN ? "auto" : "applescript",
|
|
340
|
+
},
|
|
341
|
+
ntfy: {
|
|
342
|
+
enabled: ntfyEnabled,
|
|
343
|
+
server: globalConfig.ntfy?.server || DEFAULTS.channels.ntfy.server,
|
|
344
|
+
topic: globalConfig.ntfy?.topic || DEFAULTS.channels.ntfy.topic,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
overrides: {
|
|
348
|
+
includeGroups,
|
|
349
|
+
excludeGroups,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const writeInput = await rl.question(
|
|
354
|
+
"\nWrite config to .opencode/oc-notify.json? (Y/n) [Y] ",
|
|
355
|
+
);
|
|
356
|
+
const shouldWrite = yesNo(writeInput, true);
|
|
357
|
+
if (shouldWrite) {
|
|
358
|
+
await ensureOpencodeDir(cwd);
|
|
359
|
+
await writeJson(join(cwd, REPO_CONFIG_PATH), config);
|
|
360
|
+
await copySchema(cwd);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const testInput = await rl.question(
|
|
364
|
+
"Send a test notification now? (Y/n) [Y] ",
|
|
365
|
+
);
|
|
366
|
+
const sendTest = yesNo(testInput, true);
|
|
367
|
+
if (sendTest) {
|
|
368
|
+
await sendTestNotification(config, repoName);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
await rl.close();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
await main();
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "OpenCode Notifications Repo Config",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"additionalProperties": false,
|
|
6
|
+
"properties": {
|
|
7
|
+
"version": {
|
|
8
|
+
"type": "integer",
|
|
9
|
+
"minimum": 1
|
|
10
|
+
},
|
|
11
|
+
"enabled": {
|
|
12
|
+
"type": "boolean"
|
|
13
|
+
},
|
|
14
|
+
"title": {
|
|
15
|
+
"type": "string"
|
|
16
|
+
},
|
|
17
|
+
"tier": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"enum": ["minimal", "standard", "verbose", "custom"]
|
|
20
|
+
},
|
|
21
|
+
"detailLevel": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"enum": ["full", "title-only"]
|
|
24
|
+
},
|
|
25
|
+
"responseComplete": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"enabled": {
|
|
30
|
+
"type": "boolean"
|
|
31
|
+
},
|
|
32
|
+
"trigger": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"enum": ["session.idle", "message.updated", "message.part.updated"]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"channels": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"additionalProperties": false,
|
|
41
|
+
"properties": {
|
|
42
|
+
"mac": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": false,
|
|
45
|
+
"properties": {
|
|
46
|
+
"enabled": {
|
|
47
|
+
"type": "boolean"
|
|
48
|
+
},
|
|
49
|
+
"method": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"enum": ["auto", "applescript", "terminal-notifier"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"ntfy": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"additionalProperties": false,
|
|
58
|
+
"properties": {
|
|
59
|
+
"enabled": {
|
|
60
|
+
"type": "boolean"
|
|
61
|
+
},
|
|
62
|
+
"server": {
|
|
63
|
+
"type": "string"
|
|
64
|
+
},
|
|
65
|
+
"topic": {
|
|
66
|
+
"type": "string"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"overrides": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"additionalProperties": false,
|
|
75
|
+
"properties": {
|
|
76
|
+
"includeGroups": {
|
|
77
|
+
"type": "array",
|
|
78
|
+
"items": {
|
|
79
|
+
"type": "string"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"excludeGroups": {
|
|
83
|
+
"type": "array",
|
|
84
|
+
"items": {
|
|
85
|
+
"type": "string"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"dedupe": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"additionalProperties": false,
|
|
93
|
+
"properties": {
|
|
94
|
+
"ttlSeconds": {
|
|
95
|
+
"type": "number",
|
|
96
|
+
"minimum": 0
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# OpenCode Notifications
|
|
2
|
+
|
|
3
|
+
This repo implements a per-repo notification plugin for OpenCode. It uses a repo-local config file (`.opencode/oc-notify.json`) and sends notifications to macOS Notification Center and ntfy.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
1) Install the CLI (global) or use `npx`:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @goodnesshq/opencode-notification
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
2) Install the repo installer (once per repo):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
ocn install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
3) Add the plugin to your global OpenCode config (use an absolute path; `~` is not expanded).
|
|
20
|
+
|
|
21
|
+
`~/.config/opencode/opencode.json`:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"$schema": "https://opencode.ai/config.json",
|
|
26
|
+
"plugin": [
|
|
27
|
+
"<path-from-ocn-plugin-path>"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
4) Run the repo installer:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
node .opencode/notify-init.mjs
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This writes `.opencode/oc-notify.json` with your repo-specific settings.
|
|
39
|
+
|
|
40
|
+
### Update guidance
|
|
41
|
+
|
|
42
|
+
- One-off: `npx @goodnesshq/opencode-notification@latest install`
|
|
43
|
+
- Global: `npm update -g @goodnesshq/opencode-notification`
|
|
44
|
+
|
|
45
|
+
## Troubleshooting
|
|
46
|
+
|
|
47
|
+
**OpenCode fails to start after enabling the plugin**
|
|
48
|
+
|
|
49
|
+
- Ensure every entry in `plugin` points to an existing file.
|
|
50
|
+
- Remove stale plugin paths from other repos.
|
|
51
|
+
- Use absolute paths (no `~`).
|
|
52
|
+
|
|
53
|
+
## Config Schema
|
|
54
|
+
|
|
55
|
+
The config is defined in `.opencode/oc-notify.schema.json` and supports:
|
|
56
|
+
|
|
57
|
+
- `enabled`: master switch
|
|
58
|
+
- `title`: override or template (supports `{repo}` and `{branch}`)
|
|
59
|
+
- `tier`: `minimal` | `standard` | `verbose` | `custom`
|
|
60
|
+
- `detailLevel`: `full` | `title-only`
|
|
61
|
+
- `responseComplete`: trigger for completion notifications
|
|
62
|
+
- `channels`: `mac` and `ntfy` settings
|
|
63
|
+
- `overrides`: include/exclude groups
|
|
64
|
+
- `dedupe`: in-memory TTL settings
|
|
65
|
+
|
|
66
|
+
## Tier Defaults
|
|
67
|
+
|
|
68
|
+
- **minimal**: `action_required`, `failures`
|
|
69
|
+
- **standard**: minimal + `change_summary`, `session_lifecycle`
|
|
70
|
+
- **verbose**: standard + `files`, `todos`, `vcs_worktree`, `pty`, `responses`
|
|
71
|
+
|
|
72
|
+
## Example Configs
|
|
73
|
+
|
|
74
|
+
### Minimal
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"enabled": true,
|
|
78
|
+
"tier": "minimal",
|
|
79
|
+
"responseComplete": {
|
|
80
|
+
"enabled": true,
|
|
81
|
+
"trigger": "session.idle"
|
|
82
|
+
},
|
|
83
|
+
"channels": {
|
|
84
|
+
"mac": { "enabled": true, "method": "auto" },
|
|
85
|
+
"ntfy": { "enabled": true, "server": "https://ntfy.sh", "topic": "oc-goodness-attn-1407537" }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Standard
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"enabled": true,
|
|
94
|
+
"tier": "standard",
|
|
95
|
+
"detailLevel": "full",
|
|
96
|
+
"responseComplete": {
|
|
97
|
+
"enabled": true,
|
|
98
|
+
"trigger": "message.updated"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Verbose
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"enabled": true,
|
|
107
|
+
"tier": "verbose",
|
|
108
|
+
"responseComplete": {
|
|
109
|
+
"enabled": true,
|
|
110
|
+
"trigger": "message.part.updated"
|
|
111
|
+
},
|
|
112
|
+
"overrides": {
|
|
113
|
+
"includeGroups": ["commands_tools"],
|
|
114
|
+
"excludeGroups": []
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Custom
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"enabled": true,
|
|
123
|
+
"tier": "custom",
|
|
124
|
+
"overrides": {
|
|
125
|
+
"includeGroups": ["action_required", "failures", "change_summary"],
|
|
126
|
+
"excludeGroups": ["session_lifecycle"]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
package/bin/ocn.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { copyFile, mkdir, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
7
|
+
const ASSET_NOTIFY_INIT = join(PACKAGE_ROOT, ".opencode", "notify-init.mjs");
|
|
8
|
+
const ASSET_SCHEMA = join(PACKAGE_ROOT, ".opencode", "oc-notify.schema.json");
|
|
9
|
+
const PLUGIN_PATH = join(PACKAGE_ROOT, "plugins", "opencode-notifications.mjs");
|
|
10
|
+
|
|
11
|
+
function usage() {
|
|
12
|
+
console.log("Usage: ocn <command>\n\nCommands:\n install Copy installer assets into the current repo\n plugin-path Print absolute path to the plugin entry file\n");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function ensureDir(path) {
|
|
16
|
+
await mkdir(path, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function fileExists(path) {
|
|
20
|
+
try {
|
|
21
|
+
await stat(path);
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function install() {
|
|
29
|
+
const cwd = process.cwd();
|
|
30
|
+
const repoName = basename(cwd);
|
|
31
|
+
const opencodeDir = join(cwd, ".opencode");
|
|
32
|
+
await ensureDir(opencodeDir);
|
|
33
|
+
|
|
34
|
+
if (!(await fileExists(ASSET_NOTIFY_INIT)) || !(await fileExists(ASSET_SCHEMA))) {
|
|
35
|
+
console.error("ocn: installer assets missing from package");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await copyFile(ASSET_NOTIFY_INIT, join(opencodeDir, "notify-init.mjs"));
|
|
40
|
+
await copyFile(ASSET_SCHEMA, join(opencodeDir, "oc-notify.schema.json"));
|
|
41
|
+
|
|
42
|
+
console.log(`Installed OpenCode Notifications installer in ${repoName}.`);
|
|
43
|
+
console.log("Next steps:");
|
|
44
|
+
console.log(" 1) Run: node .opencode/notify-init.mjs");
|
|
45
|
+
console.log(" 2) Add the plugin path to ~/.config/opencode/opencode.json");
|
|
46
|
+
console.log(" ocn plugin-path");
|
|
47
|
+
console.log("\nUpdate guidance:");
|
|
48
|
+
console.log(" - One-off: npx @goodnesshq/opencode-notification@latest install");
|
|
49
|
+
console.log(" - Global: npm update -g @goodnesshq/opencode-notification");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function pluginPath() {
|
|
53
|
+
if (!(await fileExists(PLUGIN_PATH))) {
|
|
54
|
+
console.error("ocn: plugin entry file missing from package");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
console.log(PLUGIN_PATH);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
const [command] = process.argv.slice(2);
|
|
62
|
+
if (!command || command === "-h" || command === "--help") {
|
|
63
|
+
usage();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (command === "install") {
|
|
68
|
+
await install();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (command === "plugin-path") {
|
|
73
|
+
await pluginPath();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
usage();
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await main();
|