@raidiant/notifai 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/LICENSE +202 -0
- package/README.md +76 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +118 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +225 -0
- package/dist/commands.js +1855 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +224 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +31 -0
- package/dist/credentials.js +94 -0
- package/dist/credentials.js.map +1 -0
- package/dist/hooks.d.ts +250 -0
- package/dist/hooks.js +792 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idle.d.ts +16 -0
- package/dist/idle.js +44 -0
- package/dist/idle.js.map +1 -0
- package/dist/install-hooks.d.ts +194 -0
- package/dist/install-hooks.js +579 -0
- package/dist/install-hooks.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +259 -0
- package/dist/main.js.map +1 -0
- package/dist/opencode-plugin.d.ts +65 -0
- package/dist/opencode-plugin.js +181 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/readiness.d.ts +87 -0
- package/dist/readiness.js +40 -0
- package/dist/readiness.js.map +1 -0
- package/dist/send.d.ts +86 -0
- package/dist/send.js +202 -0
- package/dist/send.js.map +1 -0
- package/package.json +56 -0
- package/src/client.test.ts +83 -0
- package/src/client.ts +183 -0
- package/src/commands.test.ts +1199 -0
- package/src/commands.ts +2201 -0
- package/src/config.test.ts +243 -0
- package/src/config.ts +347 -0
- package/src/credentials.ts +119 -0
- package/src/hooks.test.ts +1030 -0
- package/src/hooks.ts +1099 -0
- package/src/idle.test.ts +32 -0
- package/src/idle.ts +41 -0
- package/src/install-hooks.test.ts +500 -0
- package/src/install-hooks.ts +708 -0
- package/src/main.ts +315 -0
- package/src/opencode-plugin.ts +203 -0
- package/src/readiness.ts +88 -0
- package/src/send.ts +272 -0
- package/tsconfig.json +10 -0
package/src/main.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { Command } from 'commander'
|
|
4
|
+
import {
|
|
5
|
+
askCommand,
|
|
6
|
+
authStatusCommand,
|
|
7
|
+
capabilitiesCommand,
|
|
8
|
+
closeCommand,
|
|
9
|
+
configSetCommand,
|
|
10
|
+
configShowCommand,
|
|
11
|
+
devicesCommand,
|
|
12
|
+
doctorCommand,
|
|
13
|
+
hookRunCommand,
|
|
14
|
+
hooksInstallCommand,
|
|
15
|
+
hooksUninstallCommand,
|
|
16
|
+
initCommand,
|
|
17
|
+
loginCommand,
|
|
18
|
+
logoutCommand,
|
|
19
|
+
realIo,
|
|
20
|
+
repliesCommand,
|
|
21
|
+
sendCommand,
|
|
22
|
+
statusCommand,
|
|
23
|
+
type CommandDeps,
|
|
24
|
+
} from './commands.js'
|
|
25
|
+
import { defaultCredentialStore } from './credentials.js'
|
|
26
|
+
import type { Platform } from '@raidiant/notifai-protocol'
|
|
27
|
+
|
|
28
|
+
const deps: CommandDeps = {
|
|
29
|
+
io: realIo(),
|
|
30
|
+
store: defaultCredentialStore(),
|
|
31
|
+
env: process.env,
|
|
32
|
+
cwd: process.cwd(),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Harness hooks deliver their event payload on stdin. Bounded on both time and
|
|
37
|
+
* size: a wrapper that opens the pipe without writing would otherwise hold the
|
|
38
|
+
* read until the harness killed the hook, delaying the user's own prompt.
|
|
39
|
+
*/
|
|
40
|
+
async function readStdin(timeoutMs = 2000, maxBytes = 1_000_000): Promise<string> {
|
|
41
|
+
if (process.stdin.isTTY) return ''
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const chunks: Buffer[] = []
|
|
44
|
+
let total = 0
|
|
45
|
+
let settled = false
|
|
46
|
+
const finish = (): void => {
|
|
47
|
+
if (settled) return
|
|
48
|
+
settled = true
|
|
49
|
+
clearTimeout(timer)
|
|
50
|
+
process.stdin.destroy()
|
|
51
|
+
resolve(Buffer.concat(chunks).toString('utf8'))
|
|
52
|
+
}
|
|
53
|
+
const timer = setTimeout(finish, timeoutMs)
|
|
54
|
+
timer.unref?.()
|
|
55
|
+
process.stdin.on('data', (chunk: Buffer) => {
|
|
56
|
+
total += chunk.length
|
|
57
|
+
if (total > maxBytes) return finish()
|
|
58
|
+
chunks.push(chunk)
|
|
59
|
+
})
|
|
60
|
+
process.stdin.on('end', finish)
|
|
61
|
+
process.stdin.on('error', finish)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const program = new Command('notifai')
|
|
66
|
+
.description('Send native device notifications from agents and local programs')
|
|
67
|
+
.version('0.1.0')
|
|
68
|
+
|
|
69
|
+
program
|
|
70
|
+
.command('login')
|
|
71
|
+
.description('Pair this machine with your NotifAI account via browser approval')
|
|
72
|
+
.option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
|
|
73
|
+
.option('--base-url <url>', 'NotifAI server URL')
|
|
74
|
+
.option('--no-open', 'do not open the approval page in a browser')
|
|
75
|
+
.action(async (opts: { name?: string; baseUrl?: string; open?: boolean }) => {
|
|
76
|
+
process.exit(await loginCommand(deps, opts))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
program
|
|
80
|
+
.command('logout')
|
|
81
|
+
.description('Remove the stored machine credential')
|
|
82
|
+
.action(() => {
|
|
83
|
+
process.exit(logoutCommand(deps))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const auth = program.command('auth').description('Authentication helpers')
|
|
87
|
+
auth
|
|
88
|
+
.command('status')
|
|
89
|
+
.description('Show the stored machine identity')
|
|
90
|
+
.option('--json', 'machine-readable output')
|
|
91
|
+
.action((opts: { json?: boolean }) => {
|
|
92
|
+
process.exit(authStatusCommand(deps, opts))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
program
|
|
96
|
+
.command('devices')
|
|
97
|
+
.description('List registered devices and their delivery readiness')
|
|
98
|
+
.option('--json', 'machine-readable output')
|
|
99
|
+
.action(async (opts: { json?: boolean }) => {
|
|
100
|
+
process.exit(await devicesCommand(deps, opts))
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
program
|
|
104
|
+
.command('capabilities')
|
|
105
|
+
.description('Show a platform capability contract')
|
|
106
|
+
.option('--platform <platform>', 'platform to describe (default: ios)')
|
|
107
|
+
.option('--json', 'machine-readable output')
|
|
108
|
+
.action(async (opts: { json?: boolean; platform?: Platform }) => {
|
|
109
|
+
process.exit(await capabilitiesCommand(deps, opts))
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
program
|
|
113
|
+
.command('send')
|
|
114
|
+
.description('Send a notification')
|
|
115
|
+
.requiredOption('--title <title>', 'notification title')
|
|
116
|
+
.requiredOption('--body <body>', 'notification body')
|
|
117
|
+
.option('--subtitle <subtitle>')
|
|
118
|
+
.option('--detail <markdown>', 'long-form markdown shown only in the app, never on the banner')
|
|
119
|
+
.option('--detail-file <path>', 'read --detail from a file (use - for stdin)')
|
|
120
|
+
.option('--event <event>', 'agent event name, e.g. tests_passed')
|
|
121
|
+
.option('--kind <kind>', 'what this is: update (default) | done | question (requires --reply)')
|
|
122
|
+
.option('--project <id>', 'project identifier, e.g. my-app (lazily registered)')
|
|
123
|
+
.option('--session <id>', 'session identity (env: NOTIFAI_SESSION); presentation varies by surface')
|
|
124
|
+
.option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[]) => [...all, v], [])
|
|
125
|
+
.option('--all', 'target all routable devices (overrides configured devices)')
|
|
126
|
+
.option('--ttl <seconds>', 'delivery window in seconds', (v: string) => Number(v))
|
|
127
|
+
.option('--collapse-key <key>', 'replace earlier notifications with the same key')
|
|
128
|
+
.option('--platform <platform>', 'platform whose optional fields to include (default: ios)')
|
|
129
|
+
.option('--sound <sound>', 'default | done | attention | alert | none')
|
|
130
|
+
.option('--badge <n>', 'app badge count', (v: string) => Number(v))
|
|
131
|
+
.option('--thread-id <id>', 'group related notifications')
|
|
132
|
+
.option('--level <level>', 'interruption level: passive | active | time_sensitive')
|
|
133
|
+
.option('--relevance <score>', '0..1 relevance score', (v: string) => Number(v))
|
|
134
|
+
.option('--target-content-id <id>')
|
|
135
|
+
.option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v], [])
|
|
136
|
+
.option('--image <path|url|media_id>', 'upload or attach an image')
|
|
137
|
+
.option('--reply', 'enable the inline reply action and block for the answer')
|
|
138
|
+
.option('--reply-timeout <seconds>', 'how long to wait for a reply (default: 900)', (v: string) => Number(v))
|
|
139
|
+
.option('--reply-window <seconds>', 'how long the server accepts a reply (default: 86400)', (v: string) => Number(v))
|
|
140
|
+
.option(
|
|
141
|
+
'--reply-choice <label>',
|
|
142
|
+
'with --reply, ask a closed question (2-6 answers); one value splits on commas, or repeat the flag',
|
|
143
|
+
(v: string, all: string[]) => [...all, v], [],
|
|
144
|
+
)
|
|
145
|
+
// Kept registered, and always a usage error with --reply, so the caller gets
|
|
146
|
+
// a message pointing at `ask` instead of "unknown option".
|
|
147
|
+
.option('--no-block', 'rejected with --reply; use `notifai ask` to ask and end the turn')
|
|
148
|
+
.option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
|
|
149
|
+
.option('--no-wait', 'return immediately after acceptance')
|
|
150
|
+
.option('--idempotency-key <key>', 'safe-retry key (default: random)')
|
|
151
|
+
.option('--base-url <url>', 'NotifAI server URL')
|
|
152
|
+
.option('--json', 'print the full submission receipt as JSON')
|
|
153
|
+
.action(async (opts: Record<string, unknown>) => {
|
|
154
|
+
// commander maps --no-wait onto the same "wait" flag; disentangle.
|
|
155
|
+
const noWait = opts['wait'] === false
|
|
156
|
+
const wait = typeof opts['wait'] === 'number' ? opts['wait'] : undefined
|
|
157
|
+
const noBlock = opts['block'] === false
|
|
158
|
+
const sendOpts = { ...opts }
|
|
159
|
+
// Same empty-collector normalisation as `ask`.
|
|
160
|
+
if (Array.isArray(sendOpts['replyChoice']) && sendOpts['replyChoice'].length === 0) {
|
|
161
|
+
delete sendOpts['replyChoice']
|
|
162
|
+
}
|
|
163
|
+
delete sendOpts['block']
|
|
164
|
+
// Long-form detail is usually a build log or a diff summary, which nobody
|
|
165
|
+
// wants to shell-escape onto a command line.
|
|
166
|
+
const detailFile = sendOpts['detailFile']
|
|
167
|
+
delete sendOpts['detailFile']
|
|
168
|
+
if (typeof detailFile === 'string') {
|
|
169
|
+
if (sendOpts['detail'] !== undefined) {
|
|
170
|
+
deps.io.err('Pass either --detail or --detail-file, not both.')
|
|
171
|
+
process.exit(2)
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
sendOpts['detail'] = readFileSync(detailFile === '-' ? 0 : detailFile, 'utf8')
|
|
175
|
+
} catch (err) {
|
|
176
|
+
deps.io.err(`Could not read ${detailFile}: ${String(err)}`)
|
|
177
|
+
process.exit(2)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const flags = { ...sendOpts, noWait, noBlock } as Parameters<typeof sendCommand>[1]
|
|
181
|
+
if (wait === undefined) delete (flags as { wait?: number }).wait
|
|
182
|
+
else flags.wait = wait
|
|
183
|
+
process.exit(await sendCommand(deps, flags))
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
program
|
|
187
|
+
.command('replies <request_id>')
|
|
188
|
+
.description('Retrieve replies for a notification request')
|
|
189
|
+
.option('--wait <seconds>', 'how long to wait for a reply', (v: string) => Number(v))
|
|
190
|
+
.option('--after <seq>', 'return replies after this sequence number', (v: string) => Number(v))
|
|
191
|
+
.option('--json', 'machine-readable output')
|
|
192
|
+
.action(async (requestId: string, opts: { wait?: number; after?: number; json?: boolean }) => {
|
|
193
|
+
process.exit(await repliesCommand(deps, requestId, opts))
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
program
|
|
197
|
+
.command('status <request_id>')
|
|
198
|
+
.description('Show the evidence trail for a notification request')
|
|
199
|
+
.option('--json', 'machine-readable output')
|
|
200
|
+
.action(async (requestId: string, opts: { json?: boolean }) => {
|
|
201
|
+
process.exit(await statusCommand(deps, requestId, opts))
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
program
|
|
205
|
+
.command('ask <question>')
|
|
206
|
+
.description('Register a question for the turn-end hook to route under your presence settings')
|
|
207
|
+
.option(
|
|
208
|
+
'--choice <label>',
|
|
209
|
+
'answers to offer instead of free text (2-6); one value splits on commas, or repeat the flag',
|
|
210
|
+
(v: string, all: string[]) => [...all, v], [],
|
|
211
|
+
)
|
|
212
|
+
.option('--session <id>', 'session id (default: the session working in this directory, else $NOTIFAI_SESSION)')
|
|
213
|
+
.action((question: string, opts: { choice?: string[]; session?: string }) => {
|
|
214
|
+
// commander's collector defaults to []; an empty list means "not asked".
|
|
215
|
+
const flags: Parameters<typeof askCommand>[2] = {
|
|
216
|
+
...(opts.session !== undefined ? { session: opts.session } : {}),
|
|
217
|
+
...(opts.choice?.length ? { choice: opts.choice } : {}),
|
|
218
|
+
}
|
|
219
|
+
process.exit(askCommand(deps, question, flags))
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
program
|
|
223
|
+
.command('close <request_id>')
|
|
224
|
+
.description('Retire a question so late answers are rejected rather than lost')
|
|
225
|
+
.action(async (requestId: string) => {
|
|
226
|
+
process.exit(await closeCommand(deps, requestId))
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
program
|
|
230
|
+
.command('hook <event>')
|
|
231
|
+
.description('Internal: run a harness hook (reads hook JSON on stdin)')
|
|
232
|
+
// Inert, and the point of it is that it is inert: the installed command line
|
|
233
|
+
// carries a marker that says "NotifAI wrote this" independently of which
|
|
234
|
+
// checkout wrote it.
|
|
235
|
+
.option('--owner <name>', 'internal ownership marker')
|
|
236
|
+
.option('--harness <name>', 'internal harness output adapter')
|
|
237
|
+
.action(async (event: string, opts: { harness?: string }) => {
|
|
238
|
+
process.exit(
|
|
239
|
+
await hookRunCommand(deps, event, readStdin, opts.harness === 'cursor' ? 'cursor' : undefined),
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
const hooks = program.command('hooks').description('Install harness hooks for registered-question routing')
|
|
244
|
+
hooks
|
|
245
|
+
.command('install')
|
|
246
|
+
.description('Wire this harness to route registered questions to your devices')
|
|
247
|
+
.option(
|
|
248
|
+
'--harness <name>',
|
|
249
|
+
'claude-code | codex | cursor | opencode (default: detected; OpenCode cannot resume an idle turn from an answer)',
|
|
250
|
+
)
|
|
251
|
+
.option('--global', 'install for every project instead of just this one')
|
|
252
|
+
.action((opts: { harness?: string; global?: boolean }) => {
|
|
253
|
+
process.exit(hooksInstallCommand(deps, opts))
|
|
254
|
+
})
|
|
255
|
+
hooks
|
|
256
|
+
.command('uninstall')
|
|
257
|
+
.description('Remove the hooks this CLI installed')
|
|
258
|
+
.option('--harness <name>', 'claude-code | codex | cursor | opencode (default: detected)')
|
|
259
|
+
.option('--global', 'remove the machine-wide install')
|
|
260
|
+
.action((opts: { harness?: string; global?: boolean }) => {
|
|
261
|
+
process.exit(hooksUninstallCommand(deps, opts))
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const config = program.command('config').description('Show or change configuration')
|
|
265
|
+
config
|
|
266
|
+
.command('show')
|
|
267
|
+
.description('Show the resolved configuration')
|
|
268
|
+
.option('--explain', 'show where each value comes from')
|
|
269
|
+
.option('--json', 'machine-readable output')
|
|
270
|
+
.action((opts: { json?: boolean; explain?: boolean }) => {
|
|
271
|
+
process.exit(configShowCommand(deps, opts))
|
|
272
|
+
})
|
|
273
|
+
config
|
|
274
|
+
.command('set <key> <value>')
|
|
275
|
+
.description(
|
|
276
|
+
'Set a configuration value (choose a layer interactively; unattended defaults machine-global and requires --yes)',
|
|
277
|
+
)
|
|
278
|
+
.option('--project', 'write to the shared .notifai/config.toml instead')
|
|
279
|
+
.option('--local', 'write to personal .notifai/config.local.toml (keep it gitignored)')
|
|
280
|
+
.option('--session <id>', 'apply only to one session')
|
|
281
|
+
.option('--yes', 'skip the confirmation gate')
|
|
282
|
+
.action(
|
|
283
|
+
async (
|
|
284
|
+
key: string,
|
|
285
|
+
value: string,
|
|
286
|
+
opts: { project?: boolean; local?: boolean; session?: string; yes?: boolean },
|
|
287
|
+
) => {
|
|
288
|
+
process.exit(await configSetCommand(deps, key, value, opts))
|
|
289
|
+
},
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
program
|
|
293
|
+
.command('init')
|
|
294
|
+
.description(
|
|
295
|
+
'Set up NotifAI here, idempotently: sign-in, project id, hooks, device readiness. ' +
|
|
296
|
+
'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
|
|
297
|
+
)
|
|
298
|
+
.option('--project-id <id>', 'project identifier slug (default: derived from the directory name)')
|
|
299
|
+
.option('--skills', 'reserved: this build has no published agent-skill source and exits with an error')
|
|
300
|
+
.option('--no-skills', 'suppress the optional agent-skill status line')
|
|
301
|
+
.option('--hooks', 'install harness hooks for registered-question routing')
|
|
302
|
+
.option('--no-hooks', 'skip the hooks without being asked')
|
|
303
|
+
.action(async (opts: { projectId?: string; skills?: boolean; hooks?: boolean }) => {
|
|
304
|
+
process.exit(await initCommand(deps, opts))
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
program
|
|
308
|
+
.command('doctor')
|
|
309
|
+
.description('Audit config, credential, server, contract, device, and hook readiness (no live send)')
|
|
310
|
+
.option('--json', 'machine-readable output')
|
|
311
|
+
.action(async (opts: { json?: boolean }) => {
|
|
312
|
+
process.exit(await doctorCommand(deps, opts))
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
await program.parseAsync(process.argv)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { configHome } from './install-hooks.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The OpenCode adapter.
|
|
6
|
+
*
|
|
7
|
+
* OpenCode's extension point is a TypeScript/JavaScript plugin module, not a
|
|
8
|
+
* command hook, so it needs a different adapter rather than a different path
|
|
9
|
+
* through the existing one. What it is *not* is different logic: the generated
|
|
10
|
+
* plugin shells out to the same `notifai hook <event>` commands Claude Code and
|
|
11
|
+
* Codex invoke, and every decision — presence, the grace window, the reply
|
|
12
|
+
* wait, retirement — stays in one place.
|
|
13
|
+
*
|
|
14
|
+
* The three lifecycle joints map onto the same three events. OpenCode exposes
|
|
15
|
+
* `chat.message` as a direct hook, while session lifecycle notifications arrive
|
|
16
|
+
* through its generic `event` hook:
|
|
17
|
+
*
|
|
18
|
+
* chat.message -> user-prompt-submit (the user is at the keyboard)
|
|
19
|
+
* session.idle -> stop (the turn ended; escalate)
|
|
20
|
+
* session.deleted -> session-end (retire local session state)
|
|
21
|
+
*
|
|
22
|
+
* Permission prompts deliberately stay in OpenCode. The CLI question flow
|
|
23
|
+
* only escalates questions explicitly registered with `notifai ask`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Marker line; also how an install from another checkout is recognised. */
|
|
27
|
+
export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
|
|
28
|
+
|
|
29
|
+
/** Filename we own inside whichever plugins directory is being written. */
|
|
30
|
+
export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
|
|
31
|
+
|
|
32
|
+
/** Bump when an installed generated file must be rewritten to remain functional. */
|
|
33
|
+
const OPENCODE_ADAPTER_VERSION = 2
|
|
34
|
+
|
|
35
|
+
export function opencodePluginDir(
|
|
36
|
+
global: boolean,
|
|
37
|
+
cwd: string,
|
|
38
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
39
|
+
): string {
|
|
40
|
+
return global
|
|
41
|
+
? path.join(configHome(env, 'OPENCODE_CONFIG_DIR', '.config/opencode'), 'plugins')
|
|
42
|
+
: path.join(cwd, '.opencode', 'plugins')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function opencodePluginPath(
|
|
46
|
+
global: boolean,
|
|
47
|
+
cwd: string,
|
|
48
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
49
|
+
): string {
|
|
50
|
+
return path.join(opencodePluginDir(global, cwd, env), OPENCODE_PLUGIN_FILENAME)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface OpencodePluginOptions {
|
|
54
|
+
execPath: string
|
|
55
|
+
scriptPath: string
|
|
56
|
+
/** Child-process lifetime; covers grace, reply wait, and teardown headroom. */
|
|
57
|
+
timeoutSeconds: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The generated module.
|
|
62
|
+
*
|
|
63
|
+
* Written as a string rather than compiled from a source file because it has to
|
|
64
|
+
* embed the absolute interpreter and script paths — a bare `notifai` on PATH
|
|
65
|
+
* fails silently under a plugin host the same way it does under a hook shell.
|
|
66
|
+
*
|
|
67
|
+
* Everything it does is build the hook envelope OpenCode's event gives it and
|
|
68
|
+
* hand that to the CLI on stdin. It deliberately fails open: an adapter error
|
|
69
|
+
* never changes OpenCode's own behavior.
|
|
70
|
+
*/
|
|
71
|
+
export function opencodePluginSource(options: OpencodePluginOptions): string {
|
|
72
|
+
const { execPath, scriptPath, timeoutSeconds } = options
|
|
73
|
+
return `${OPENCODE_PLUGIN_MARKER}
|
|
74
|
+
// Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
|
|
75
|
+
// the next install; change the CLI instead, which is where the logic lives.
|
|
76
|
+
import { spawn } from "node:child_process"
|
|
77
|
+
|
|
78
|
+
const EXEC = ${JSON.stringify(execPath)}
|
|
79
|
+
const SCRIPT = ${JSON.stringify(scriptPath)}
|
|
80
|
+
const TIMEOUT_MS = ${timeoutSeconds * 1000}
|
|
81
|
+
const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Runs \`notifai hook <event>\`, feeding it the same JSON envelope the other
|
|
85
|
+
* harnesses send. Resolves to stdout, or null if anything at all went wrong.
|
|
86
|
+
*/
|
|
87
|
+
function runHook(event, envelope) {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
let child
|
|
90
|
+
try {
|
|
91
|
+
child = spawn(EXEC, [SCRIPT, "hook", event, "--owner", "notifai"], {
|
|
92
|
+
env: process.env,
|
|
93
|
+
shell: false,
|
|
94
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
95
|
+
})
|
|
96
|
+
} catch {
|
|
97
|
+
resolve(null)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let stdout = ""
|
|
102
|
+
let settled = false
|
|
103
|
+
const finish = (value) => {
|
|
104
|
+
if (settled) return
|
|
105
|
+
settled = true
|
|
106
|
+
clearTimeout(timer)
|
|
107
|
+
resolve(value)
|
|
108
|
+
}
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
child.kill("SIGTERM")
|
|
111
|
+
finish(null)
|
|
112
|
+
}, TIMEOUT_MS)
|
|
113
|
+
|
|
114
|
+
child.stdout?.setEncoding("utf-8")
|
|
115
|
+
child.stdout?.on("data", (chunk) => {
|
|
116
|
+
stdout += chunk
|
|
117
|
+
})
|
|
118
|
+
// Hook stderr is diagnostics, not plugin output; discard it here.
|
|
119
|
+
child.stderr?.resume()
|
|
120
|
+
child.on("error", () => finish(null))
|
|
121
|
+
child.on("close", () => finish(stdout))
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
child.stdin?.end(JSON.stringify(envelope))
|
|
125
|
+
} catch {
|
|
126
|
+
finish(null)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const NotifAIPlugin = async ({ directory }) => {
|
|
132
|
+
const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
/**
|
|
136
|
+
* Presence. The user typing is the signal every harness shares, and it is
|
|
137
|
+
* also what publishes the project -> session pointer \`notifai ask\` reads.
|
|
138
|
+
*/
|
|
139
|
+
"chat.message": async (input) => {
|
|
140
|
+
await runHook("user-prompt-submit", {
|
|
141
|
+
session_id: input?.sessionID ?? input?.info?.sessionID,
|
|
142
|
+
cwd,
|
|
143
|
+
hook_event_name: "UserPromptSubmit",
|
|
144
|
+
})
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
/** Session lifecycle events are delivered through OpenCode's event bus. */
|
|
148
|
+
event: async ({ event }) => {
|
|
149
|
+
if (event?.type === "session.idle") {
|
|
150
|
+
await runHook("stop", {
|
|
151
|
+
session_id: event?.properties?.sessionID,
|
|
152
|
+
cwd,
|
|
153
|
+
hook_event_name: "Stop",
|
|
154
|
+
})
|
|
155
|
+
} else if (event?.type === "session.deleted") {
|
|
156
|
+
await runHook("session-end", {
|
|
157
|
+
session_id: event?.properties?.info?.id,
|
|
158
|
+
cwd,
|
|
159
|
+
hook_event_name: "SessionEnd",
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** True when a plugin file on disk is one of ours, whoever installed it. */
|
|
169
|
+
export function isOurOpencodePlugin(contents: string): boolean {
|
|
170
|
+
return contents.includes(OPENCODE_PLUGIN_MARKER)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The interpreter and script a generated plugin will actually run.
|
|
175
|
+
*
|
|
176
|
+
* A plugin is a module rather than a command line, so which *build* it invokes
|
|
177
|
+
* is not visible in the way it is for a settings-file handler — it is baked
|
|
178
|
+
* into the two constants at the top. `doctor` needs it to answer the question
|
|
179
|
+
* it exists to answer ("are two checkouts both installed?"), and without it
|
|
180
|
+
* the plugin's own path stands in for the build and every OpenCode install
|
|
181
|
+
* looks like a second one.
|
|
182
|
+
*
|
|
183
|
+
* Returns null for a plugin we did not generate, or one whose constants have
|
|
184
|
+
* been edited out of recognisable shape.
|
|
185
|
+
*/
|
|
186
|
+
export function opencodePluginTarget(
|
|
187
|
+
contents: string,
|
|
188
|
+
): { exec: string; script: string; current: boolean } | null {
|
|
189
|
+
if (!isOurOpencodePlugin(contents)) return null
|
|
190
|
+
const exec = /^const EXEC = (".*")$/m.exec(contents)?.[1]
|
|
191
|
+
const script = /^const SCRIPT = (".*")$/m.exec(contents)?.[1]
|
|
192
|
+
const version = Number(/^const ADAPTER_VERSION = (\d+)$/m.exec(contents)?.[1] ?? 1)
|
|
193
|
+
if (exec === undefined || script === undefined) return null
|
|
194
|
+
try {
|
|
195
|
+
return {
|
|
196
|
+
exec: JSON.parse(exec) as string,
|
|
197
|
+
script: JSON.parse(script) as string,
|
|
198
|
+
current: version === OPENCODE_ADAPTER_VERSION,
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
}
|
package/src/readiness.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What "set up" means, in one place.
|
|
3
|
+
*
|
|
4
|
+
* `init` and `doctor` used to each carry their own idea of this — a fixed
|
|
5
|
+
* five-step script in one, a flat list of checks in the other — which is two
|
|
6
|
+
* copies of the same knowledge that can disagree, and did: doctor called
|
|
7
|
+
* missing hooks fine while init offered to install them, and only doctor knew
|
|
8
|
+
* what a revoked credential looked like.
|
|
9
|
+
*
|
|
10
|
+
* So the model lives here and both commands render it. `doctor` shows every
|
|
11
|
+
* state at once, because a report is what it is for. `init` walks the same
|
|
12
|
+
* list in order and stops at the first thing standing in the way, because
|
|
13
|
+
* someone handed five tasks does none of them. An agent gets the same states
|
|
14
|
+
* through a third renderer that never prompts.
|
|
15
|
+
*
|
|
16
|
+
* The ordering is a dependency chain, not a preference: there is no point
|
|
17
|
+
* asking the server about devices before there is a credential to ask with,
|
|
18
|
+
* and no point reporting a stale contract to someone who cannot reach the
|
|
19
|
+
* server at all. Assessment stops descending when a prerequisite is missing
|
|
20
|
+
* and marks what follows `unknown` rather than guessing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Whether the gap can be closed from here, and by whom. */
|
|
24
|
+
export type Remedy =
|
|
25
|
+
/**
|
|
26
|
+
* The CLI can do it unattended — no prompt, no human. `command` is how a
|
|
27
|
+
* reader would ask for it later, for the case where it was declined or
|
|
28
|
+
* skipped; it is never needed to perform the action.
|
|
29
|
+
*/
|
|
30
|
+
| { by: 'cli'; summary: string; command?: string }
|
|
31
|
+
/**
|
|
32
|
+
* A human at this terminal. `interactive` marks the ones the CLI can drive
|
|
33
|
+
* on their behalf when someone is actually watching — a browser sign-in is
|
|
34
|
+
* the CLI's to launch but the human's to complete, so an agent must still
|
|
35
|
+
* treat it as out of reach.
|
|
36
|
+
*/
|
|
37
|
+
| { by: 'user-here'; summary: string; command: string; interactive?: boolean }
|
|
38
|
+
/**
|
|
39
|
+
* A human somewhere else entirely — phone in hand, app store, permission
|
|
40
|
+
* dialog. The class the old init could only print a sentence about, and the
|
|
41
|
+
* one most likely to end an onboarding.
|
|
42
|
+
*/
|
|
43
|
+
| { by: 'user-elsewhere'; summary: string }
|
|
44
|
+
|
|
45
|
+
export type StateStatus =
|
|
46
|
+
/** Satisfied. */
|
|
47
|
+
| 'ready'
|
|
48
|
+
/** Not satisfied, and it stands in the way. */
|
|
49
|
+
| 'gap'
|
|
50
|
+
/** Not satisfied, and that is a legitimate choice. */
|
|
51
|
+
| 'optional-gap'
|
|
52
|
+
/** A prerequisite is missing, so this was never evaluated. */
|
|
53
|
+
| 'unknown'
|
|
54
|
+
|
|
55
|
+
export interface ReadinessState {
|
|
56
|
+
id: string
|
|
57
|
+
/** Human words for what this is, not the field name. */
|
|
58
|
+
title: string
|
|
59
|
+
status: StateStatus
|
|
60
|
+
/** The current condition, phrased so it reads under either renderer. */
|
|
61
|
+
detail: string
|
|
62
|
+
remedy?: Remedy
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Readiness {
|
|
66
|
+
states: ReadinessState[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The first thing standing in the way, or null when nothing is. */
|
|
70
|
+
export function firstBlocker(readiness: Readiness): ReadinessState | null {
|
|
71
|
+
return readiness.states.find((s) => s.status === 'gap') ?? null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Ready enough to be useful, which is not the same as every box ticked.
|
|
76
|
+
*
|
|
77
|
+
* Hooks and the agent skill are genuinely optional — `send` works without
|
|
78
|
+
* them — so an install that declined both is finished, not half-done. Saying
|
|
79
|
+
* otherwise trains people to ignore the summary line.
|
|
80
|
+
*/
|
|
81
|
+
export function isReady(readiness: Readiness): boolean {
|
|
82
|
+
return firstBlocker(readiness) === null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Everything that could still be done, blocking or not, in dependency order. */
|
|
86
|
+
export function openItems(readiness: Readiness): ReadinessState[] {
|
|
87
|
+
return readiness.states.filter((s) => s.status === 'gap' || s.status === 'optional-gap')
|
|
88
|
+
}
|