@raidiant/notifai 0.1.6 → 0.1.8

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.
@@ -97,6 +97,22 @@ describe('hook config', () => {
97
97
  expect(config['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
98
98
  expect(config['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
99
99
  })
100
+
101
+ it('stamps the exact harness on every generated command', () => {
102
+ const config = buildHookConfig({
103
+ execPath: EXEC,
104
+ scriptPath: SCRIPT,
105
+ replyTimeoutSeconds: 240,
106
+ graceSeconds: 0,
107
+ harness: 'claude-code',
108
+ })
109
+ expect(
110
+ Object.values(config)
111
+ .flatMap((groups) => groups)
112
+ .flatMap((group) => group.hooks)
113
+ .every((handler) => handler.command.includes('--harness claude-code')),
114
+ ).toBe(true)
115
+ })
100
116
  })
101
117
 
102
118
  describe('merging into existing settings', () => {
@@ -450,6 +466,7 @@ describe('the OpenCode adapter', () => {
450
466
  it('shells out to the same hook commands the other harnesses run', () => {
451
467
  // The whole point: presence, escalation and retirement stay in the CLI.
452
468
  expect(source).toContain('"hook", event')
469
+ expect(source).toContain('"--harness", "opencode"')
453
470
  expect(source).toContain(JSON.stringify(SCRIPT))
454
471
  expect(source).toContain(JSON.stringify(EXEC))
455
472
  })
@@ -464,6 +481,18 @@ describe('the OpenCode adapter', () => {
464
481
  expect(source).not.toContain('"permission.ask"')
465
482
  })
466
483
 
484
+ it('injects collected late answers into the next OpenCode user message', () => {
485
+ expect(source).toContain('hookSpecificOutput?.additionalContext')
486
+ expect(source).toContain('synthetic: true')
487
+ })
488
+
489
+ it('injects an exact active-harness and session marker into OpenCode shells', () => {
490
+ expect(source).toContain('"shell.env"')
491
+ expect(source).toContain('NOTIFAI_ACTIVE_HARNESS')
492
+ expect(source).toContain('NOTIFAI_ACTIVE_SESSION_ID')
493
+ expect(source).toContain('input.sessionID')
494
+ })
495
+
467
496
  it('carries the ownership marker so a second checkout replaces it', () => {
468
497
  expect(source).toContain(OPENCODE_PLUGIN_MARKER)
469
498
  expect(isOurOpencodePlugin(source)).toBe(true)
@@ -487,11 +516,17 @@ describe('the OpenCode adapter', () => {
487
516
  // rather than a command line, so answering with the plugin's own path made
488
517
  // an ordinary OpenCode install look like a second checkout and turned the
489
518
  // duplicate check red on a healthy machine.
490
- expect(opencodePluginTarget(source)).toEqual({ exec: EXEC, script: SCRIPT, current: true })
519
+ expect(opencodePluginTarget(source)).toEqual({
520
+ exec: EXEC,
521
+ script: SCRIPT,
522
+ current: true,
523
+ timeoutSeconds: 240,
524
+ })
491
525
  expect(opencodePluginTarget(source.replace(/^const ADAPTER_VERSION = .*\n/m, ''))).toEqual({
492
526
  exec: EXEC,
493
527
  script: SCRIPT,
494
528
  current: false,
529
+ timeoutSeconds: 240,
495
530
  })
496
531
  expect(opencodePluginTarget('export const SomeoneElse = () => ({})')).toBeNull()
497
532
  // Ours, but with the constants edited beyond recognition: no answer beats
@@ -1,18 +1,11 @@
1
1
  import {
2
- closeSync,
3
2
  existsSync,
4
- fsyncSync,
5
- lstatSync,
6
- mkdirSync,
7
- openSync,
8
3
  readFileSync,
9
4
  statSync,
10
- renameSync,
11
- unlinkSync,
12
- writeFileSync,
13
5
  } from 'node:fs'
14
6
  import os from 'node:os'
15
7
  import path from 'node:path'
8
+ import { atomicWriteFileSync } from './atomic-file.js'
16
9
  import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
17
10
 
18
11
  /**
@@ -73,22 +66,22 @@ export function hookCommand(
73
66
  execPath: string,
74
67
  scriptPath: string,
75
68
  event: string,
76
- harness?: 'cursor',
69
+ harness?: Harness,
77
70
  ): string {
78
71
  return (
79
72
  `${quote(execPath)} ${quote(scriptPath)} hook ${event} ${OWNER_MARKER}` +
80
- (harness === 'cursor' ? ' --harness cursor' : '')
73
+ (harness === undefined ? '' : ` --harness ${harness}`)
81
74
  )
82
75
  }
83
76
 
84
77
  /**
85
- * Says "NotifAI installed this" without saying which checkout did.
78
+ * Says "Notifai installed this" without saying which checkout did.
86
79
  *
87
80
  * Ownership used to be matched on the absolute script path, so installing from
88
81
  * a second checkout did not recognise the first one's handlers as ours. Both
89
82
  * stayed, the harness ran both, and one question produced two notifications;
90
83
  * uninstalling the second silently left the first running. A marker that every
91
- * NotifAI build writes and no build's path appears in fixes that by making
84
+ * Notifai build writes and no build's path appears in fixes that by making
92
85
  * ownership a property of the handler rather than of the machine it was
93
86
  * installed from.
94
87
  *
@@ -114,6 +107,8 @@ export interface BuildOptions {
114
107
  replyTimeoutSeconds: number
115
108
  /** Seconds the question waits in the terminal before it is pushed at all. */
116
109
  graceSeconds: number
110
+ /** The installed adapter stamps its exact harness into project pointers. */
111
+ harness?: Harness
117
112
  }
118
113
 
119
114
  /** Command-hook harnesses kill at 600s; generated adapters use the same safe ceiling. */
@@ -139,7 +134,7 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
139
134
  hooks: [
140
135
  {
141
136
  type: 'command',
142
- command: hookCommand(execPath, scriptPath, 'user-prompt-submit'),
137
+ command: hookCommand(execPath, scriptPath, 'user-prompt-submit', options.harness),
143
138
  // Claude Code caps UserPromptSubmit at 30s; stay well inside it so
144
139
  // a slow network can never delay the user's own prompt.
145
140
  timeout: 15,
@@ -152,7 +147,7 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
152
147
  hooks: [
153
148
  {
154
149
  type: 'command',
155
- command: hookCommand(execPath, scriptPath, 'stop'),
150
+ command: hookCommand(execPath, scriptPath, 'stop', options.harness),
156
151
  timeout: blockingTimeout,
157
152
  },
158
153
  ],
@@ -163,7 +158,7 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
163
158
  hooks: [
164
159
  {
165
160
  type: 'command',
166
- command: hookCommand(execPath, scriptPath, 'session-end'),
161
+ command: hookCommand(execPath, scriptPath, 'session-end', options.harness),
167
162
  // Both harnesses give SessionEnd a ~1-3s budget, so this handler
168
163
  // only touches local state.
169
164
  timeout: 3,
@@ -205,9 +200,9 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
205
200
  {
206
201
  command: hookCommand(options.execPath, options.scriptPath, 'stop', 'cursor'),
207
202
  timeout: blockingTimeout,
208
- // One companion-device answer produces one automatic follow-up turn. There is no
209
- // useful reason for Cursor to repeat it, even if state cleanup fails.
210
- loop_limit: 1,
203
+ // A continuation may register a real follow-up question. Match the
204
+ // session-state cap so those chains are useful but never unbounded.
205
+ loop_limit: 3,
211
206
  },
212
207
  ],
213
208
  sessionEnd: [
@@ -568,49 +563,7 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
568
563
  * support.
569
564
  */
570
565
  export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
571
- mkdirSync(path.dirname(file), { recursive: true })
572
- const mode = targetMode(file)
573
- const temp = path.join(path.dirname(file), `.${path.basename(file)}.notifai-${process.pid}.tmp`)
574
-
575
- const handle = openSync(temp, 'w', mode)
576
- try {
577
- writeFileSync(handle, `${JSON.stringify(document, null, 2)}\n`)
578
- fsyncSync(handle)
579
- } finally {
580
- closeSync(handle)
581
- }
582
- try {
583
- renameSync(temp, file)
584
- } catch (err) {
585
- // Leaving the temp behind would be a second, quieter mess on top of the
586
- // failure the caller is about to hear about.
587
- try {
588
- unlinkSync(temp)
589
- } catch {
590
- // Nothing useful to do; the rename error is the one that matters.
591
- }
592
- throw err
593
- }
594
- }
595
-
596
- /** Mode to preserve, or the mode for a new file. Refuses a non-regular target. */
597
- function targetMode(file: string): number {
598
- let stat
599
- try {
600
- stat = lstatSync(file)
601
- } catch (err) {
602
- // A settings file can carry tokens, so a file we create is ours alone.
603
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0o600
604
- throw err
605
- }
606
- if (stat.isSymbolicLink()) {
607
- throw new Error(
608
- `${file} is a symlink; refusing to write through it. Replace it with a regular file, ` +
609
- 'or install to a different location.',
610
- )
611
- }
612
- if (!stat.isFile()) throw new Error(`${file} is not a regular file; refusing to replace it.`)
613
- return stat.mode & 0o777
566
+ atomicWriteFileSync(file, `${JSON.stringify(document, null, 2)}\n`)
614
567
  }
615
568
 
616
569
  export function loadSettings(file: string): SettingsDocument {
@@ -633,6 +586,8 @@ export interface InstalledHandler {
633
586
  /** Index of the handler within its group. */
634
587
  handlerIndex: number
635
588
  command: string
589
+ /** Declared process lifetime, used to detect config/install drift. */
590
+ timeout?: number
636
591
  }
637
592
 
638
593
  export interface Installation {
@@ -645,9 +600,9 @@ export interface Installation {
645
600
  }
646
601
 
647
602
  /**
648
- * Any NotifAI handler, whatever checkout wrote it.
603
+ * Any Notifai handler, whatever checkout wrote it.
649
604
  *
650
- * Deliberately looser than `isOurHandler`: this answers "has NotifAI been set
605
+ * Deliberately looser than `isOurHandler`: this answers "has Notifai been set
651
606
  * up here at all", and a handler installed from a second checkout is still
652
607
  * evidence that it has (and is itself worth reporting).
653
608
  */
@@ -659,7 +614,7 @@ function isNotifaiHandler(handler: HookHandler): boolean {
659
614
  return isNotifaiCommand(handler.command)
660
615
  }
661
616
 
662
- /** Every place either harness would read a NotifAI handler from. */
617
+ /** Every place either harness would read a Notifai handler from. */
663
618
  export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.env): Installation[] {
664
619
  const found: Installation[] = []
665
620
  for (const harness of HARNESSES) {
@@ -687,6 +642,7 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
687
642
  groupIndex: 0,
688
643
  handlerIndex: 0,
689
644
  command: hookCommand(target.exec, target.script, hookEvent),
645
+ ...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
690
646
  })),
691
647
  })
692
648
  continue
@@ -699,7 +655,10 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
699
655
  continue
700
656
  }
701
657
  const handlers = locateCursorHandlers(document)
702
- if (handlers.length > 0) found.push({ harness, file, global, handlers })
658
+ if (handlers.length > 0) {
659
+ const problems = harnessMarkerProblems(harness, handlers)
660
+ found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
661
+ }
703
662
  continue
704
663
  }
705
664
  let document: SettingsDocument
@@ -709,18 +668,35 @@ export function findInstallations(cwd: string, env: NodeJS.ProcessEnv = process.
709
668
  continue
710
669
  }
711
670
  const handlers = locateHandlers(document)
712
- if (handlers.length > 0) found.push({ harness, file, global, handlers })
671
+ if (handlers.length > 0) {
672
+ const problems = harnessMarkerProblems(harness, handlers)
673
+ found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
674
+ }
713
675
  }
714
676
  }
715
677
  return found
716
678
  }
717
679
 
680
+ function harnessMarkerProblems(harness: Harness, handlers: InstalledHandler[]): string[] {
681
+ return handlers.every((handler) => handler.command.includes(`--harness ${harness}`))
682
+ ? []
683
+ : [
684
+ `installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``,
685
+ ]
686
+ }
687
+
718
688
  function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandler[] {
719
689
  const handlers: InstalledHandler[] = []
720
690
  for (const [event, eventHandlers] of Object.entries(document.hooks ?? {})) {
721
691
  eventHandlers.forEach((handler, handlerIndex) => {
722
692
  if (isNotifaiCommand(handler.command)) {
723
- handlers.push({ event, groupIndex: 0, handlerIndex, command: handler.command })
693
+ handlers.push({
694
+ event,
695
+ groupIndex: 0,
696
+ handlerIndex,
697
+ command: handler.command,
698
+ ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
699
+ })
724
700
  }
725
701
  })
726
702
  }
@@ -733,7 +709,13 @@ function locateHandlers(document: SettingsDocument): InstalledHandler[] {
733
709
  groups.forEach((group, groupIndex) => {
734
710
  group.hooks?.forEach((handler, handlerIndex) => {
735
711
  if (isNotifaiHandler(handler)) {
736
- handlers.push({ event, groupIndex, handlerIndex, command: handler.command })
712
+ handlers.push({
713
+ event,
714
+ groupIndex,
715
+ handlerIndex,
716
+ command: handler.command,
717
+ ...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
718
+ })
737
719
  }
738
720
  })
739
721
  })
package/src/main.ts CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
5
5
  import { Command } from 'commander'
6
6
  import {
7
7
  askCommand,
8
+ accessStatusCommand,
8
9
  authStatusCommand,
9
10
  capabilitiesCommand,
10
11
  closeCommand,
@@ -25,13 +26,16 @@ import {
25
26
  type CommandDeps,
26
27
  } from './commands.js'
27
28
  import { defaultCredentialStore } from './credentials.js'
29
+ import { HARNESSES } from './install-hooks.js'
28
30
  import type { Platform } from '@raidiant/notifai-protocol'
31
+ import { nativeSkills, type SkillScope } from './native-skills.js'
29
32
 
30
33
  const deps: CommandDeps = {
31
34
  io: realIo(),
32
35
  store: defaultCredentialStore(),
33
36
  env: process.env,
34
37
  cwd: process.cwd(),
38
+ nativeSkills,
35
39
  }
36
40
 
37
41
  /**
@@ -89,11 +93,36 @@ const program = new Command('notifai')
89
93
  .description('Send native device notifications from agents and local programs')
90
94
  .version(version())
91
95
 
96
+ // Setup leads: `init` is the single entry command for first-run friendliness.
97
+ program
98
+ .command('init')
99
+ .description(
100
+ 'Set up Notifai here, idempotently: sign-in, project id, hooks, device readiness, live receipt proof. ' +
101
+ 'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
102
+ )
103
+ .option('--project-id <id>', 'project identifier slug (default: derived from the directory name)')
104
+ .option('--skills', 'install/update the agent skill from its pinned public release')
105
+ .option('--no-skills', 'suppress the optional agent-skill status line')
106
+ .option('--skills-scope <scope>', 'unattended skill scope: project or global')
107
+ .option('--hooks', 'install harness hooks for registered-question routing')
108
+ .option('--no-hooks', 'skip the hooks without being asked')
109
+ .action(async (opts: { projectId?: string; skills?: boolean; skillsScope?: SkillScope; hooks?: boolean }) => {
110
+ process.exit(await initCommand(deps, opts))
111
+ })
112
+
113
+ program
114
+ .command('doctor')
115
+ .description('Audit config, credential, server, contract, device, hook, and saved receipt proof; exits nonzero when any line is FAIL (no live send)')
116
+ .option('--json', 'machine-readable output')
117
+ .action(async (opts: { json?: boolean }) => {
118
+ process.exit(await doctorCommand(deps, opts))
119
+ })
120
+
92
121
  program
93
122
  .command('login')
94
- .description('Pair this machine with your NotifAI account via browser approval')
123
+ .description('Pair this machine with your Notifai account via browser approval')
95
124
  .option('--name <name>', 'machine name shown in the dashboard (default: hostname)')
96
- .option('--base-url <url>', 'NotifAI server URL')
125
+ .option('--base-url <url>', 'Notifai server URL')
97
126
  .option('--no-open', 'do not open the approval page in a browser')
98
127
  .action(async (opts: { name?: string; baseUrl?: string; open?: boolean }) => {
99
128
  process.exit(await loginCommand(deps, opts))
@@ -114,6 +143,13 @@ auth
114
143
  .action((opts: { json?: boolean }) => {
115
144
  process.exit(authStatusCommand(deps, opts))
116
145
  })
146
+ auth
147
+ .command('access')
148
+ .description('Show the account plan and access decision')
149
+ .option('--json', 'machine-readable output')
150
+ .action(async (opts: { json?: boolean }) => {
151
+ process.exit(await accessStatusCommand(deps, opts))
152
+ })
117
153
 
118
154
  program
119
155
  .command('devices')
@@ -132,7 +168,7 @@ program
132
168
  process.exit(await capabilitiesCommand(deps, opts))
133
169
  })
134
170
 
135
- program
171
+ const send = program
136
172
  .command('send')
137
173
  .description('Send a notification')
138
174
  .requiredOption('--title <title>', 'notification title')
@@ -148,18 +184,15 @@ program
148
184
  .option('--all', 'target all routable devices (overrides configured devices)')
149
185
  .option('--ttl <seconds>', 'delivery window in seconds', (v: string) => Number(v))
150
186
  .option('--collapse-key <key>', 'replace earlier notifications with the same key')
151
- .option('--platform <platform>', 'platform whose optional fields to include (default: ios)')
187
+ .option('--platform <platform>', 'limit optional fields to ios or macos (default: both)')
152
188
  .option('--sound <sound>', 'default | done | attention | alert | none')
153
- .option('--badge <n>', 'app badge count', (v: string) => Number(v))
154
189
  .option('--thread-id <id>', 'group related notifications')
155
190
  .option('--level <level>', 'interruption level: passive | active | time_sensitive')
156
- .option('--relevance <score>', '0..1 relevance score', (v: string) => Number(v))
157
- .option('--target-content-id <id>')
158
191
  .option('--data <key=value>', 'custom data (repeatable)', (v: string, all: string[]) => [...all, v], [])
159
192
  .option('--image <path|url|media_id>', 'upload or attach an image')
160
193
  .option('--reply', 'enable the inline reply action and block for the answer')
161
194
  .option('--reply-timeout <seconds>', 'how long to wait for a reply (default: 900)', (v: string) => Number(v))
162
- .option('--reply-window <seconds>', 'how long the server accepts a reply (default: 86400)', (v: string) => Number(v))
195
+ .option('--reply-window <seconds>', 'how long the server accepts a reply (default: 3600)', (v: string) => Number(v))
163
196
  .option(
164
197
  '--reply-choice <label>',
165
198
  'with --reply, ask a closed question (2-6 answers); one value splits on commas, or repeat the flag',
@@ -171,7 +204,7 @@ program
171
204
  .option('--wait <seconds>', 'how long to wait for provider outcomes', (v: string) => Number(v))
172
205
  .option('--no-wait', 'return immediately after acceptance')
173
206
  .option('--idempotency-key <key>', 'safe-retry key (default: random)')
174
- .option('--base-url <url>', 'NotifAI server URL')
207
+ .option('--base-url <url>', 'Notifai server URL')
175
208
  .option('--json', 'print the full submission receipt as JSON')
176
209
  .action(async (opts: Record<string, unknown>) => {
177
210
  // commander maps --no-wait onto the same "wait" flag; disentangle.
@@ -206,13 +239,19 @@ program
206
239
  process.exit(await sendCommand(deps, flags))
207
240
  })
208
241
 
242
+ send.addHelpText(
243
+ 'after',
244
+ `\nKind profiles (used unless --sound, --level, or saved user config overrides them):\n update sound none level passive\n done sound done level passive\n question sound attention level active (--reply)\n`,
245
+ )
246
+
209
247
  program
210
- .command('replies <request_id>')
248
+ .command('replies [request_id]')
211
249
  .description('Retrieve replies for a notification request')
250
+ .option('--pending', 'use the pushed question pending for this project session')
212
251
  .option('--wait <seconds>', 'how long to wait for a reply', (v: string) => Number(v))
213
252
  .option('--after <seq>', 'return replies after this sequence number', (v: string) => Number(v))
214
253
  .option('--json', 'machine-readable output')
215
- .action(async (requestId: string, opts: { wait?: number; after?: number; json?: boolean }) => {
254
+ .action(async (requestId: string | undefined, opts: { wait?: number; after?: number; json?: boolean; pending?: boolean }) => {
216
255
  process.exit(await repliesCommand(deps, requestId, opts))
217
256
  })
218
257
 
@@ -253,13 +292,14 @@ program
253
292
  .command('hook <event>')
254
293
  .description('Internal: run a harness hook (reads hook JSON on stdin)')
255
294
  // Inert, and the point of it is that it is inert: the installed command line
256
- // carries a marker that says "NotifAI wrote this" independently of which
295
+ // carries a marker that says "Notifai wrote this" independently of which
257
296
  // checkout wrote it.
258
297
  .option('--owner <name>', 'internal ownership marker')
259
298
  .option('--harness <name>', 'internal harness output adapter')
260
299
  .action(async (event: string, opts: { harness?: string }) => {
300
+ const harness = HARNESSES.find((candidate) => candidate === opts.harness)
261
301
  process.exit(
262
- await hookRunCommand(deps, event, readStdin, opts.harness === 'cursor' ? 'cursor' : undefined),
302
+ await hookRunCommand(deps, event, readStdin, harness),
263
303
  )
264
304
  })
265
305
 
@@ -269,7 +309,7 @@ hooks
269
309
  .description('Wire this harness to route registered questions to your devices')
270
310
  .option(
271
311
  '--harness <name>',
272
- 'claude-code | codex | cursor | opencode (default: detected; OpenCode cannot resume an idle turn from an answer)',
312
+ 'claude-code | codex | cursor | opencode (default: detected; OpenCode delivers answers on the next prompt)',
273
313
  )
274
314
  .option('--global', 'install for every project instead of just this one')
275
315
  .action((opts: { harness?: string; global?: boolean }) => {
@@ -312,27 +352,4 @@ config
312
352
  },
313
353
  )
314
354
 
315
- program
316
- .command('init')
317
- .description(
318
- 'Set up NotifAI here, idempotently: sign-in, project id, hooks, device readiness, live receipt proof. ' +
319
- 'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
320
- )
321
- .option('--project-id <id>', 'project identifier slug (default: derived from the directory name)')
322
- .option('--skills', 'install/update the agent skill from its pinned public release')
323
- .option('--no-skills', 'suppress the optional agent-skill status line')
324
- .option('--hooks', 'install harness hooks for registered-question routing')
325
- .option('--no-hooks', 'skip the hooks without being asked')
326
- .action(async (opts: { projectId?: string; skills?: boolean; hooks?: boolean }) => {
327
- process.exit(await initCommand(deps, opts))
328
- })
329
-
330
- program
331
- .command('doctor')
332
- .description('Audit config, credential, server, contract, device, hook, and saved receipt proof (no live send)')
333
- .option('--json', 'machine-readable output')
334
- .action(async (opts: { json?: boolean }) => {
335
- process.exit(await doctorCommand(deps, opts))
336
- })
337
-
338
355
  await program.parseAsync(process.argv)
@@ -0,0 +1,177 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+
6
+ /** The two scopes offered by the skills installer. */
7
+ export type SkillScope = 'project' | 'global'
8
+
9
+ /** The installer-managed evidence needed by Notifai readiness. */
10
+ export interface NativeSkill {
11
+ name: string
12
+ scope: SkillScope
13
+ path: string
14
+ source: string | null
15
+ sourceType: string | null
16
+ sourceUrl: string | null
17
+ ref: string | null
18
+ }
19
+
20
+ export interface SkillsListResult {
21
+ skills: NativeSkill[]
22
+ error?: string
23
+ }
24
+
25
+ export interface SkillsAddOptions {
26
+ source: string
27
+ skill: string
28
+ scope?: SkillScope
29
+ cwd: string
30
+ env: NodeJS.ProcessEnv
31
+ }
32
+
33
+ export interface NativeSkills {
34
+ /** Launch the native interactive `npx skills add` flow. */
35
+ add(options: SkillsAddOptions): Promise<number>
36
+ /** Ask the native installer for its managed install inventory. */
37
+ list(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): Promise<SkillsListResult>
38
+ }
39
+
40
+ interface JsonSkill {
41
+ name?: unknown
42
+ path?: unknown
43
+ scope?: unknown
44
+ source?: unknown
45
+ sourceType?: unknown
46
+ sourceUrl?: unknown
47
+ }
48
+
49
+ interface LockEntry {
50
+ source?: unknown
51
+ sourceType?: unknown
52
+ sourceUrl?: unknown
53
+ ref?: unknown
54
+ }
55
+
56
+ interface LockFile {
57
+ skills?: Record<string, LockEntry>
58
+ }
59
+
60
+ function skillLockPath(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): string {
61
+ if (scope === 'project') return path.join(cwd, 'skills-lock.json')
62
+ const stateHome = env['XDG_STATE_HOME']
63
+ const home = env['HOME'] ?? env['USERPROFILE'] ?? os.homedir()
64
+ return stateHome !== undefined && stateHome !== ''
65
+ ? path.join(stateHome, 'skills', '.skill-lock.json')
66
+ : path.join(home, '.agents', '.skill-lock.json')
67
+ }
68
+
69
+ function readLock(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): LockFile {
70
+ const file = skillLockPath(scope, cwd, env)
71
+ if (!existsSync(file)) return {}
72
+ try {
73
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as LockFile
74
+ return parsed && typeof parsed === 'object' ? parsed : {}
75
+ } catch {
76
+ return {}
77
+ }
78
+ }
79
+
80
+ function lockEntryFor(lock: LockFile, name: string): LockEntry | undefined {
81
+ const direct = lock.skills?.[name]
82
+ if (direct) return direct
83
+ const normalized = name.toLowerCase().replace(/[\s_]+/g, '-')
84
+ const key = Object.keys(lock.skills ?? {}).find(
85
+ (candidate) => candidate.toLowerCase().replace(/[\s_]+/g, '-') === normalized,
86
+ )
87
+ return key === undefined ? undefined : lock.skills?.[key]
88
+ }
89
+
90
+ function jsonArray(stdout: string): unknown[] | null {
91
+ const start = stdout.indexOf('[')
92
+ const end = stdout.lastIndexOf(']')
93
+ if (start < 0 || end < start) return null
94
+ try {
95
+ const parsed: unknown = JSON.parse(stdout.slice(start, end + 1))
96
+ return Array.isArray(parsed) ? parsed : null
97
+ } catch {
98
+ return null
99
+ }
100
+ }
101
+
102
+ function managedSkills(stdout: string, scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): NativeSkill[] | null {
103
+ const entries = jsonArray(stdout)
104
+ if (entries === null) return null
105
+ const lock = readLock(scope, cwd, env)
106
+ return entries.flatMap((entry): NativeSkill[] => {
107
+ if (entry === null || typeof entry !== 'object') return []
108
+ const value = entry as JsonSkill
109
+ if (
110
+ typeof value.name !== 'string' ||
111
+ typeof value.path !== 'string' ||
112
+ value.scope !== scope
113
+ ) {
114
+ return []
115
+ }
116
+ const lockEntry = lockEntryFor(lock, value.name)
117
+ return [
118
+ {
119
+ name: value.name,
120
+ scope,
121
+ path: value.path,
122
+ source: typeof value.source === 'string' ? value.source : null,
123
+ sourceType: typeof value.sourceType === 'string' ? value.sourceType : null,
124
+ sourceUrl: typeof value.sourceUrl === 'string' ? value.sourceUrl : null,
125
+ ref: typeof lockEntry?.ref === 'string' ? lockEntry.ref : null,
126
+ },
127
+ ]
128
+ })
129
+ }
130
+
131
+ function run(
132
+ args: string[],
133
+ options: { cwd: string; env: NodeJS.ProcessEnv; stdio: 'inherit' | 'capture' },
134
+ ): Promise<{ code: number; stdout: string }> {
135
+ return new Promise((resolve) => {
136
+ const child = spawn('npx', args, {
137
+ cwd: options.cwd,
138
+ env: options.env,
139
+ stdio: options.stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'ignore'],
140
+ })
141
+ if (options.stdio === 'capture') {
142
+ let stdout = ''
143
+ child.stdout?.on('data', (chunk: Buffer | string) => {
144
+ stdout += chunk.toString()
145
+ })
146
+ child.on('error', () => resolve({ code: 1, stdout }))
147
+ child.on('exit', (code) => resolve({ code: code ?? 1, stdout }))
148
+ return
149
+ }
150
+ child.on('error', () => resolve({ code: 1, stdout: '' }))
151
+ child.on('exit', (code) => resolve({ code: code ?? 1, stdout: '' }))
152
+ })
153
+ }
154
+
155
+ /** The only process/filesystem adapter Notifai needs for the external installer. */
156
+ export const nativeSkills: NativeSkills = {
157
+ async add(options) {
158
+ const args = ['-y', 'skills', 'add', options.source, '--skill', options.skill]
159
+ if (options.scope === 'global') args.push('--global')
160
+ // An explicit scope is the unattended contract. Native `--yes` keeps all
161
+ // remaining installer prompts non-interactive after the scope is chosen.
162
+ if (options.scope !== undefined) args.push('--yes')
163
+ return (await run(args, { cwd: options.cwd, env: options.env, stdio: 'inherit' })).code
164
+ },
165
+
166
+ async list(scope, cwd, env) {
167
+ const result = await run(
168
+ ['-y', 'skills', 'list', '--json', ...(scope === 'global' ? ['--global'] : [])],
169
+ { cwd, env, stdio: 'capture' },
170
+ )
171
+ if (result.code !== 0) return { skills: [], error: `npx skills list exited with code ${result.code}` }
172
+ const skills = managedSkills(result.stdout, scope, cwd, env)
173
+ return skills === null
174
+ ? { skills: [], error: 'npx skills list returned invalid JSON' }
175
+ : { skills }
176
+ },
177
+ }