@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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +76 -0
  3. package/dist/client.d.ts +40 -0
  4. package/dist/client.js +118 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/commands.d.ts +225 -0
  7. package/dist/commands.js +1855 -0
  8. package/dist/commands.js.map +1 -0
  9. package/dist/config.d.ts +138 -0
  10. package/dist/config.js +224 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/credentials.d.ts +31 -0
  13. package/dist/credentials.js +94 -0
  14. package/dist/credentials.js.map +1 -0
  15. package/dist/hooks.d.ts +250 -0
  16. package/dist/hooks.js +792 -0
  17. package/dist/hooks.js.map +1 -0
  18. package/dist/idle.d.ts +16 -0
  19. package/dist/idle.js +44 -0
  20. package/dist/idle.js.map +1 -0
  21. package/dist/install-hooks.d.ts +194 -0
  22. package/dist/install-hooks.js +579 -0
  23. package/dist/install-hooks.js.map +1 -0
  24. package/dist/main.d.ts +2 -0
  25. package/dist/main.js +259 -0
  26. package/dist/main.js.map +1 -0
  27. package/dist/opencode-plugin.d.ts +65 -0
  28. package/dist/opencode-plugin.js +181 -0
  29. package/dist/opencode-plugin.js.map +1 -0
  30. package/dist/readiness.d.ts +87 -0
  31. package/dist/readiness.js +40 -0
  32. package/dist/readiness.js.map +1 -0
  33. package/dist/send.d.ts +86 -0
  34. package/dist/send.js +202 -0
  35. package/dist/send.js.map +1 -0
  36. package/package.json +56 -0
  37. package/src/client.test.ts +83 -0
  38. package/src/client.ts +183 -0
  39. package/src/commands.test.ts +1199 -0
  40. package/src/commands.ts +2201 -0
  41. package/src/config.test.ts +243 -0
  42. package/src/config.ts +347 -0
  43. package/src/credentials.ts +119 -0
  44. package/src/hooks.test.ts +1030 -0
  45. package/src/hooks.ts +1099 -0
  46. package/src/idle.test.ts +32 -0
  47. package/src/idle.ts +41 -0
  48. package/src/install-hooks.test.ts +500 -0
  49. package/src/install-hooks.ts +708 -0
  50. package/src/main.ts +315 -0
  51. package/src/opencode-plugin.ts +203 -0
  52. package/src/readiness.ts +88 -0
  53. package/src/send.ts +272 -0
  54. 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
+ }
@@ -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
+ }