@raidiant/notifai 0.5.1 → 1.0.1

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 (57) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +11 -4
  3. package/dist/commands.d.ts +16 -1
  4. package/dist/commands.js +385 -158
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.d.ts +1 -1
  7. package/dist/config-schema.js +2 -14
  8. package/dist/config-schema.js.map +1 -1
  9. package/dist/config.d.ts +19 -8
  10. package/dist/config.js +50 -7
  11. package/dist/config.js.map +1 -1
  12. package/dist/harnesses.d.ts +1 -0
  13. package/dist/harnesses.js +6 -0
  14. package/dist/harnesses.js.map +1 -1
  15. package/dist/hook-adapter.d.ts +10 -1
  16. package/dist/hook-adapter.js +79 -10
  17. package/dist/hook-adapter.js.map +1 -1
  18. package/dist/install-hooks.d.ts +60 -20
  19. package/dist/install-hooks.js +246 -100
  20. package/dist/install-hooks.js.map +1 -1
  21. package/dist/interactive.d.ts +32 -1
  22. package/dist/interactive.js +163 -47
  23. package/dist/interactive.js.map +1 -1
  24. package/dist/main.js +8 -8
  25. package/dist/main.js.map +1 -1
  26. package/dist/native-skills.d.ts +1 -1
  27. package/dist/native-skills.js +23 -58
  28. package/dist/native-skills.js.map +1 -1
  29. package/dist/pack-feedback-slice.d.ts +35 -0
  30. package/dist/pack-feedback-slice.js +126 -0
  31. package/dist/pack-feedback-slice.js.map +1 -0
  32. package/dist/readiness.d.ts +20 -0
  33. package/dist/readiness.js +24 -0
  34. package/dist/readiness.js.map +1 -1
  35. package/package.json +3 -2
  36. package/src/commands.test.ts +411 -42
  37. package/src/commands.ts +468 -178
  38. package/src/config-schema.ts +2 -17
  39. package/src/config.test.ts +29 -4
  40. package/src/config.ts +67 -11
  41. package/src/harnesses.ts +7 -0
  42. package/src/hook-adapter.test.ts +43 -1
  43. package/src/hook-adapter.ts +83 -11
  44. package/src/hooks.test.ts +4 -5
  45. package/src/install-hooks.test.ts +180 -8
  46. package/src/install-hooks.ts +227 -47
  47. package/src/interactive.test.ts +338 -0
  48. package/src/interactive.ts +221 -49
  49. package/src/main.ts +8 -8
  50. package/src/native-skills.test.ts +75 -0
  51. package/src/native-skills.ts +31 -75
  52. package/src/pack-feedback-slice.test.ts +212 -0
  53. package/src/pack-feedback-slice.ts +187 -0
  54. package/src/readiness.test.ts +25 -0
  55. package/src/readiness.ts +38 -0
  56. package/src/skill.test.ts +43 -0
  57. package/src/ui/config-view.test.ts +5 -4
@@ -50,8 +50,8 @@ import {
50
50
  findInstallations,
51
51
  } from './install-hooks.js'
52
52
  import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
53
- import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
54
- import { CONFIG_KEYS, loadConfig } from './config.js'
53
+ import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
54
+ import { CONFIG_KEYS, loadConfig, personalProjectConfigPath } from './config.js'
55
55
  import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
56
56
  import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
57
57
  import type { Tone } from './ui/theme.js'
@@ -123,6 +123,17 @@ class InteractiveIo extends CapturedIo {
123
123
  return this.selectAnswer
124
124
  }
125
125
 
126
+ multiselectAnswer: string[] | null = null
127
+
128
+ async multiselect(
129
+ message: string,
130
+ _options: { value: string; label: string; hint?: string }[],
131
+ _initial?: string[],
132
+ ): Promise<string[] | null> {
133
+ this.prompts.push(message)
134
+ return this.multiselectAnswer
135
+ }
136
+
126
137
  async intro(title: string) {
127
138
  this.intros.push(title)
128
139
  }
@@ -161,7 +172,11 @@ function trustInstalledCodexHooks(cwd: string, env: NodeJS.ProcessEnv): void {
161
172
  )
162
173
  const codexHome = env['CODEX_HOME'] as string
163
174
  mkdirSync(codexHome, { recursive: true })
164
- writeFileSync(path.join(codexHome, 'config.toml'), sections.join('\n'))
175
+ const file = path.join(codexHome, 'config.toml')
176
+ // Trust lives in the same config.toml as inline [hooks]. Overwriting the
177
+ // file would delete the global handlers this helper is trying to trust.
178
+ const existing = existsSync(file) ? readFileSync(file, 'utf8').trimEnd() : ''
179
+ writeFileSync(file, [existing, ...sections].filter((block) => block.length > 0).join('\n') + '\n')
165
180
  }
166
181
 
167
182
  function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
@@ -403,7 +418,9 @@ describe('command contracts', () => {
403
418
  expect(source).not.toContain(".option('--badge")
404
419
  expect(source).not.toContain(".option('--relevance")
405
420
  expect(source).not.toContain(".option('--target-content-id")
406
- expect(source).toContain('Kind profiles')
421
+ expect(source).toContain('Kind profiles apply automatically')
422
+ expect(source).toContain('Pass --sound or --level only to override')
423
+ expect(source).toContain('override kind profile / saved config')
407
424
  })
408
425
 
409
426
  it('rejects a question nobody will wait for', async () => {
@@ -1165,7 +1182,7 @@ describe('Cursor hook commands', () => {
1165
1182
  it('installs native Cursor hooks with bounded chained answer continuations', () => {
1166
1183
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
1167
1184
  const io = new CapturedIo()
1168
- const deps = { ...makeDeps(io, {} as ApiClient), cwd }
1185
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
1169
1186
 
1170
1187
  expect(
1171
1188
  hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath }),
@@ -1227,7 +1244,7 @@ describe('Cursor hook commands', () => {
1227
1244
  it('uninstalls only Notifai Cursor hooks and preserves foreign hooks', () => {
1228
1245
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-uninstall-'))
1229
1246
  const io = new CapturedIo()
1230
- const deps = { ...makeDeps(io, {} as ApiClient), cwd }
1247
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
1231
1248
  expect(
1232
1249
  hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath }),
1233
1250
  ).toBe(EXIT.ok)
@@ -1253,6 +1270,86 @@ describe('Cursor hook commands', () => {
1253
1270
  })
1254
1271
  })
1255
1272
 
1273
+ describe('Codex hook representation', () => {
1274
+ const execPath = process.execPath
1275
+ const scriptPath = fileURLToPath(import.meta.url)
1276
+
1277
+ function writeInlineStop(repo: string, command: string): string {
1278
+ const file = path.join(repo, '.codex', 'config.toml')
1279
+ mkdirSync(path.dirname(file), { recursive: true })
1280
+ writeFileSync(
1281
+ file,
1282
+ ['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', `command = "${command}"`, ''].join(
1283
+ '\n',
1284
+ ),
1285
+ )
1286
+ return file
1287
+ }
1288
+
1289
+ it('installs into existing inline [hooks] and does not create hooks.json', () => {
1290
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-inline-install-'))
1291
+ const toml = writeInlineStop(cwd, 'gdh-stop')
1292
+ const io = new CapturedIo()
1293
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
1294
+
1295
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1296
+
1297
+ expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
1298
+ const text = readFileSync(toml, 'utf8')
1299
+ expect(text).toContain('gdh-stop')
1300
+ expect(text).toContain('--owner notifai')
1301
+ expect(text).toContain('[[hooks.UserPromptSubmit]]')
1302
+ expect(io.outLines.join('\n')).toContain(toml)
1303
+ expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
1304
+ })
1305
+
1306
+ it('collapses a leftover hooks.json onto inline [hooks] and says both Stops will run', async () => {
1307
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-collapse-'))
1308
+ const toml = writeInlineStop(cwd, 'gdh-stop')
1309
+ const json = path.join(cwd, '.codex', 'hooks.json')
1310
+ const env = isolatedEnv(cwd)
1311
+ const io = new CapturedIo()
1312
+ const client = {
1313
+ health: async () => false,
1314
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1315
+ listDevices: async () => ({ devices: [] }),
1316
+ } as unknown as ApiClient
1317
+ const deps = { ...makeDeps(io, client), cwd, env }
1318
+
1319
+ applyPlan(json, {
1320
+ hooks: buildHookConfig({
1321
+ adapterPath: hookAdapterPath(deps.hookAdapterHome),
1322
+ harness: 'codex',
1323
+ }),
1324
+ })
1325
+
1326
+ io.outLines = []
1327
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
1328
+ const before = io.outLines.join('\n')
1329
+ expect(before).toMatch(/hooks \(codex representation\)/)
1330
+ expect(before).toMatch(/prefer a single representation/)
1331
+ expect(before).toMatch(/Stop handlers will both (run|fire)/i)
1332
+ expect(before).toContain('notifai hooks install --harness codex')
1333
+
1334
+ io.outLines = []
1335
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1336
+ expect(existsSync(json)).toBe(false)
1337
+ expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
1338
+ expect(readFileSync(toml, 'utf8')).toContain('--owner notifai')
1339
+ expect(io.outLines.join('\n')).toMatch(/collaps/i)
1340
+
1341
+ const installations = findInstallations(cwd, env, deps.hookAdapterHome).filter(
1342
+ (installation) => installation.harness === 'codex',
1343
+ )
1344
+ expect(installations).toHaveLength(1)
1345
+ expect(installations[0]?.file).toBe(toml)
1346
+
1347
+ expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
1348
+ expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
1349
+ expect(readFileSync(toml, 'utf8')).not.toContain('--owner notifai')
1350
+ })
1351
+ })
1352
+
1256
1353
  describe('harness activation guidance', () => {
1257
1354
  const execPath = process.execPath
1258
1355
  const scriptPath = fileURLToPath(import.meta.url)
@@ -1260,21 +1357,22 @@ describe('harness activation guidance', () => {
1260
1357
  it('does not require a Claude Code restart for project hook files', () => {
1261
1358
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
1262
1359
  const io = new CapturedIo()
1263
- const deps = { ...makeDeps(io, {} as ApiClient), cwd }
1360
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
1264
1361
 
1265
1362
  expect(
1266
1363
  hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath }),
1267
1364
  ).toBe(EXIT.ok)
1268
1365
 
1269
- expect(io.outLines.join('\n')).toContain(
1270
- 'Claude Code reloads project hook files without a restart.',
1271
- )
1366
+ const output = io.outLines.join('\n')
1367
+ expect(output).toContain('Installed claude-code hooks in')
1368
+ expect(output).toContain('Send one Claude Code prompt, then check `notifai doctor`.')
1369
+ expect(output).not.toMatch(/timeout|asynchronous|600s/i)
1272
1370
  })
1273
1371
 
1274
1372
  it('does not invent a Codex hook trust gate', () => {
1275
1373
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-activation-'))
1276
1374
  const io = new CapturedIo()
1277
- const deps = { ...makeDeps(io, {} as ApiClient), cwd }
1375
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
1278
1376
 
1279
1377
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(
1280
1378
  EXIT.ok,
@@ -1285,6 +1383,52 @@ describe('harness activation guidance', () => {
1285
1383
  expect(output).not.toMatch(/trust|approve/i)
1286
1384
  })
1287
1385
 
1386
+ it('names the installed harness in the close, never a different one', () => {
1387
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-close-'))
1388
+ const io = new CapturedIo()
1389
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
1390
+
1391
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
1392
+
1393
+ const output = io.outLines.join('\n')
1394
+ expect(output).toContain('Installed cursor hooks in')
1395
+ expect(output).toContain('Send one Cursor prompt, then check `notifai doctor`.')
1396
+ expect(output).not.toMatch(/Codex|Claude Code|OpenCode/)
1397
+ expect(output).not.toMatch(/timeout|worktree|fails closed/i)
1398
+ })
1399
+
1400
+ it('pins hooks to npx when that is how this CLI is running', () => {
1401
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-npx-hooks-'))
1402
+ const npmCli = path.join(cwd, 'npm-cli.js')
1403
+ writeFileSync(npmCli, '')
1404
+ const io = new CapturedIo()
1405
+ const deps = {
1406
+ ...makeDeps(io, {} as ApiClient),
1407
+ cwd,
1408
+ env: {
1409
+ npm_command: 'exec',
1410
+ npm_execpath: npmCli,
1411
+ HOME: path.join(cwd, 'home'),
1412
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
1413
+ XDG_STATE_HOME: path.join(cwd, 'state'),
1414
+ },
1415
+ }
1416
+
1417
+ expect(
1418
+ hooksInstallCommand(deps, {
1419
+ harness: 'claude-code',
1420
+ execPath: process.execPath,
1421
+ scriptPath: path.join(cwd, '_npx', 'hash', 'node_modules', '@raidiant', 'notifai', 'dist', 'main.js'),
1422
+ }),
1423
+ ).toBe(EXIT.ok)
1424
+
1425
+ const inspected = inspectHookAdapter(deps.hookAdapterHome)
1426
+ expect(inspected.problems).toEqual([])
1427
+ expect(inspected.target && 'spec' in inspected.target ? inspected.target.spec : null).toMatch(
1428
+ /^@raidiant\/notifai@/,
1429
+ )
1430
+ })
1431
+
1288
1432
  it('keeps OpenCode permission prompts local and reports unsupported continuation', async () => {
1289
1433
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-activation-'))
1290
1434
  const io = new CapturedIo()
@@ -1310,8 +1454,9 @@ describe('harness activation guidance', () => {
1310
1454
  EXIT.ok,
1311
1455
  )
1312
1456
 
1313
- expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
1314
- expect(io.outLines.join('\n')).toContain('does not expose a proven exactly-once continuation')
1457
+ expect(io.outLines.join('\n')).toContain('Installed opencode hooks in')
1458
+ expect(io.outLines.join('\n')).toContain('Send one OpenCode prompt, then check `notifai doctor`.')
1459
+ expect(io.outLines.join('\n')).not.toMatch(/Permission prompts|exactly-once continuation/)
1315
1460
  const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
1316
1461
  const plugin = readFileSync(pluginFile, 'utf8')
1317
1462
  expect(plugin).toContain('const TIMEOUT_MS = 540000')
@@ -1416,10 +1561,11 @@ describe('stable hook installation', () => {
1416
1561
  ).toBe(EXIT.ok)
1417
1562
  const global = readFileSync(path.join(env.CLAUDE_CONFIG_DIR, 'settings.json'), 'utf8')
1418
1563
  expect(global).toBe(firstDefinition)
1564
+ expect(readFileSync(local, 'utf8')).not.toContain('--owner notifai')
1419
1565
 
1420
1566
  io.outLines = []
1421
1567
  await doctorCommand(deps, {})
1422
- expect(io.outLines.join('\n')).toMatch(/hooks \(duplicates\).*project or global routing/is)
1568
+ expect(io.outLines.join('\n')).not.toMatch(/hooks \(duplicates\)/)
1423
1569
 
1424
1570
  expect(hooksUninstallCommand(deps, { harness: 'claude-code' })).toBe(EXIT.ok)
1425
1571
  expect(existsSync(hookAdapterPath(deps.hookAdapterHome))).toBe(true)
@@ -1429,6 +1575,27 @@ describe('stable hook installation', () => {
1429
1575
  ),
1430
1576
  ).toHaveLength(1)
1431
1577
  })
1578
+
1579
+ it('does not add a project copy when global hooks already cover the machine', () => {
1580
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-enough-'))
1581
+ const io = new CapturedIo()
1582
+ const env = isolatedEnv(cwd)
1583
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
1584
+ const execPath = process.execPath
1585
+ const scriptPath = fileURLToPath(import.meta.url)
1586
+
1587
+ expect(hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath })).toBe(
1588
+ EXIT.ok,
1589
+ )
1590
+ io.outLines = []
1591
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
1592
+ expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(false)
1593
+ expect(io.outLines.join('\n')).toMatch(/already cover this machine/)
1594
+ expect(io.outLines.join('\n')).toMatch(/uninstall --harness codex --global/)
1595
+ expect(
1596
+ findInstallations(cwd, env, deps.hookAdapterHome).filter((item) => item.harness === 'codex'),
1597
+ ).toEqual([expect.objectContaining({ global: true })])
1598
+ })
1432
1599
  })
1433
1600
 
1434
1601
  describe('projectSlugFrom', () => {
@@ -1602,10 +1769,36 @@ describe('config surfaces', () => {
1602
1769
  const io = new CapturedIo()
1603
1770
  expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
1604
1771
  expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
1605
- expect(io.outLines[0]).toMatch(/^base_url = "/)
1772
+ expect(io.outLines[0]).toMatch(/^wait_seconds = /)
1606
1773
  for (const line of io.outLines) expect(line).toMatch(/^[a-z_]+ = /)
1607
1774
  })
1608
1775
 
1776
+ it('gives agents the current value, source, and one-line meaning', () => {
1777
+ const io = new CapturedIo()
1778
+ expect(configShowCommand(configDeps(io), { json: true })).toBe(EXIT.ok)
1779
+ const parsed = JSON.parse(io.outLines.join('\n')) as Record<
1780
+ string,
1781
+ { value: unknown; source: string; summary: string }
1782
+ >
1783
+ expect(parsed['base_url']).toBeUndefined()
1784
+ expect(parsed['ttl_seconds']).toEqual({
1785
+ value: 86400,
1786
+ source: 'default',
1787
+ summary: expect.stringContaining('deliver'),
1788
+ })
1789
+ expect(parsed['ask_grace_seconds']?.value).toBe(0)
1790
+ expect(parsed['ask_grace_seconds']?.summary).toBeTruthy()
1791
+ expect(Object.keys(parsed).sort()).toEqual([...CONFIG_KEYS].sort())
1792
+ })
1793
+
1794
+ it('refuses to treat the service origin as a setting', async () => {
1795
+ const io = new CapturedIo()
1796
+ expect(await configSetCommand(configDeps(io), 'base_url', 'https://attacker.example', { yes: true })).toBe(
1797
+ EXIT.usage,
1798
+ )
1799
+ expect(io.errLines[0]).toBe('Unknown setting "base_url".')
1800
+ })
1801
+
1609
1802
  it('explains each setting once a human is at the terminal', () => {
1610
1803
  const io = new InteractiveIo()
1611
1804
  expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
@@ -1787,11 +1980,11 @@ describe('interactive command UX', () => {
1787
1980
  }
1788
1981
 
1789
1982
  expect(await configSetCommand(deps, 'sound', 'done', {})).toBe(EXIT.ok)
1983
+ const localFile = personalProjectConfigPath(cwd, deps.env)
1790
1984
  expect(io.prompts[0]).toBe('Where should this setting live?')
1791
- expect(io.prompts[1]).toContain(path.join(cwd, '.notifai', 'config.local.toml'))
1792
- expect(readFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'utf8')).toContain(
1793
- 'sound = "done"',
1794
- )
1985
+ expect(io.prompts[1]).toContain(localFile)
1986
+ expect(readFileSync(localFile, 'utf8')).toContain('sound = "done"')
1987
+ expect(existsSync(path.join(cwd, '.notifai', 'config.local.toml'))).toBe(false)
1795
1988
  })
1796
1989
 
1797
1990
  it('bypasses interactive config selection with --yes and uses the global default', async () => {
@@ -1814,13 +2007,14 @@ describe('interactive command UX', () => {
1814
2007
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-unset-layer-'))
1815
2008
  const io = new InteractiveIo()
1816
2009
  io.selectAnswer = 'local'
1817
- const localFile = path.join(cwd, '.notifai', 'config.local.toml')
2010
+ const env = { XDG_CONFIG_HOME: path.join(cwd, 'xdg') }
2011
+ const localFile = personalProjectConfigPath(cwd, env)
1818
2012
  mkdirSync(path.dirname(localFile), { recursive: true })
1819
2013
  writeFileSync(localFile, 'sound = "done"\n')
1820
2014
  const deps = {
1821
2015
  ...makeDeps(io, {} as ApiClient),
1822
2016
  cwd,
1823
- env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') },
2017
+ env,
1824
2018
  }
1825
2019
 
1826
2020
  expect(await configUnsetCommand(deps, 'sound', {})).toBe(EXIT.ok)
@@ -2020,13 +2214,21 @@ describe('init', () => {
2020
2214
  cwd,
2021
2215
  env: isolatedEnv(cwd),
2022
2216
  nativeSkills,
2217
+ hookInstallTarget: {
2218
+ execPath: process.execPath,
2219
+ scriptPath: fileURLToPath(import.meta.url),
2220
+ },
2023
2221
  }
2024
2222
  }
2025
2223
 
2026
2224
  it('writes the project identifier into .notifai/config.toml and is idempotent', async () => {
2027
2225
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'My Project-'))
2028
2226
  const io = new CapturedIo()
2029
- const deps = { ...makeDeps(io, {} as ApiClient), cwd }
2227
+ const deps: CommandDeps = {
2228
+ ...makeDeps(io, {} as ApiClient),
2229
+ cwd,
2230
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
2231
+ }
2030
2232
 
2031
2233
  expect(await initCommand(deps, {})).toBe(EXIT.failed)
2032
2234
  const configPath = path.join(cwd, '.notifai', 'config.toml')
@@ -2038,9 +2240,10 @@ describe('init', () => {
2038
2240
 
2039
2241
  io.outLines = []
2040
2242
  expect(await initCommand(deps, { skills: false })).toBe(EXIT.failed)
2041
- // Idempotent: the second run re-derives the same slug and says so as a
2042
- // settled state rather than repeating the write.
2043
- expect(io.outLines.join('\n')).toContain('Project identity: "my-project-')
2243
+ // Idempotent: the second run re-derives the same slug and does not reprint
2244
+ // doctor's ready-state dump.
2245
+ expect(io.outLines.join('\n')).toMatch(/^Next:/m)
2246
+ expect(io.outLines.join('\n')).not.toContain('Project identity:')
2044
2247
  expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
2045
2248
  })
2046
2249
 
@@ -2272,6 +2475,49 @@ describe('init', () => {
2272
2475
  expect(io.outLines.join('\n')).toContain('All set.')
2273
2476
  })
2274
2477
 
2478
+ it('installs every detected project harness when --hooks is set', async () => {
2479
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-all-detected-'))
2480
+ mkdirSync(path.join(cwd, '.claude'))
2481
+ mkdirSync(path.join(cwd, '.codex'))
2482
+ const io = new CapturedIo()
2483
+ const calls = { submit: 0 }
2484
+ const nativeSkills: NativeSkills = {
2485
+ add: async () => 0,
2486
+ list: async () => ({ skills: [] }),
2487
+ }
2488
+ const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
2489
+ await initCommand(deps, { hooks: true, skills: false })
2490
+ const wired = findInstallations(cwd, deps.env, deps.hookAdapterHome).map(
2491
+ (installation) => installation.harness,
2492
+ )
2493
+ expect(wired).toEqual(['claude-code', 'codex'])
2494
+ expect(existsSync(path.join(cwd, '.claude', 'settings.local.json'))).toBe(true)
2495
+ expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(true)
2496
+ expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
2497
+ expect(io.outLines.join('\n')).toContain('Installed claude-code hooks')
2498
+ expect(io.outLines.join('\n')).toContain('Installed codex hooks')
2499
+ })
2500
+
2501
+ it('lets a human keep a subset of the detected harnesses', async () => {
2502
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-pick-'))
2503
+ mkdirSync(path.join(cwd, '.claude'))
2504
+ mkdirSync(path.join(cwd, '.codex'))
2505
+ const io = new InteractiveIo()
2506
+ io.multiselectAnswer = ['claude-code']
2507
+ const calls = { submit: 0 }
2508
+ const nativeSkills: NativeSkills = {
2509
+ add: async () => 0,
2510
+ list: async () => ({ skills: [] }),
2511
+ }
2512
+ const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
2513
+ expect(await initCommand(deps, { skills: false })).toBe(EXIT.ok)
2514
+ expect(io.prompts.some((prompt) => prompt.includes('Which agent harnesses'))).toBe(true)
2515
+ const wired = findInstallations(cwd, deps.env, deps.hookAdapterHome).map(
2516
+ (installation) => installation.harness,
2517
+ )
2518
+ expect(wired).toEqual(['claude-code'])
2519
+ })
2520
+
2275
2521
  it('reports an optional native installer failure without blocking remaining setup', async () => {
2276
2522
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-failed-'))
2277
2523
  const io = new InteractiveIo()
@@ -2413,6 +2659,7 @@ describe('init', () => {
2413
2659
  ...makeDeps(io, client),
2414
2660
  cwd,
2415
2661
  env: {
2662
+ HOME: path.join(cwd, 'home'),
2416
2663
  XDG_CONFIG_HOME: path.join(cwd, 'config'),
2417
2664
  XDG_STATE_HOME: path.join(cwd, 'state'),
2418
2665
  CODEX_HOME: path.join(cwd, 'codex'),
@@ -2506,9 +2753,6 @@ describe('init', () => {
2506
2753
  expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
2507
2754
  expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
2508
2755
  expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
2509
- expect(io.outLines.join('\n')).toContain(
2510
- "Companion Receipt (the app's delivery confirmation) observed from iPhone",
2511
- )
2512
2756
  expect(io.outLines.join('\n')).toContain('All set.')
2513
2757
  expect(submitCalls).toBe(1)
2514
2758
  expect(submittedDraft?.draft.event).toBe('setup_verified')
@@ -2664,9 +2908,7 @@ describe('init', () => {
2664
2908
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
2665
2909
  expect(submitCalls).toBe(2)
2666
2910
  expect(io.outLines.join('\n')).toContain('saved proof had expired; sent replacement req_replacement')
2667
- expect(io.outLines.join('\n')).toContain(
2668
- "Companion Receipt (the app's delivery confirmation) observed from iPhone",
2669
- )
2911
+ expect(io.outLines.join('\n')).toContain('All set.')
2670
2912
  })
2671
2913
 
2672
2914
  it('reports a proof-state write failure instead of crashing or sending twice', async () => {
@@ -2837,6 +3079,126 @@ describe('init', () => {
2837
3079
  })
2838
3080
  })
2839
3081
 
3082
+ describe('readiness assessment cost', () => {
3083
+ it('does not re-probe the service after init closes a local-only gap', async () => {
3084
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-local-reassess-'))
3085
+ const io = new CapturedIo()
3086
+ let healthCalls = 0
3087
+ const client = {
3088
+ health: async () => {
3089
+ healthCalls += 1
3090
+ return true
3091
+ },
3092
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3093
+ } as unknown as ApiClient
3094
+ const deps: CommandDeps = {
3095
+ ...makeDeps(io, client),
3096
+ cwd,
3097
+ env: isolatedEnv(cwd),
3098
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
3099
+ }
3100
+
3101
+ expect(await initCommand(deps, {})).toBe(EXIT.failed)
3102
+ expect(healthCalls).toBe(1)
3103
+ expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain('project =')
3104
+ })
3105
+
3106
+ it('reuses remote probes when only local state could have changed', async () => {
3107
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-local-only-'))
3108
+ const io = new CapturedIo()
3109
+ let healthCalls = 0
3110
+ let deviceCalls = 0
3111
+ let evidenceCalls = 0
3112
+ const client = {
3113
+ health: async () => {
3114
+ healthCalls += 1
3115
+ return true
3116
+ },
3117
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3118
+ listDevices: async () => {
3119
+ deviceCalls += 1
3120
+ return { devices: [] }
3121
+ },
3122
+ accessStatus: async () => ({ email: 'user@example.com' }),
3123
+ evidence: async () => {
3124
+ evidenceCalls += 1
3125
+ throw new Error('unused')
3126
+ },
3127
+ } as unknown as ApiClient
3128
+ const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
3129
+
3130
+ const previous = await assessReadiness(deps)
3131
+ expect(healthCalls).toBe(1)
3132
+ expect(deviceCalls).toBe(1)
3133
+
3134
+ const next = await assessReadiness(deps, { previous, refresh: ['local'] })
3135
+ expect(healthCalls).toBe(1)
3136
+ expect(deviceCalls).toBe(1)
3137
+ expect(evidenceCalls).toBe(0)
3138
+ expect(next.states.find((state) => state.id === 'server')).toEqual(
3139
+ previous.states.find((state) => state.id === 'server'),
3140
+ )
3141
+ })
3142
+
3143
+ it('renders a supplied doctor report without probing again', async () => {
3144
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'doctor-reuse-'))
3145
+ const io = new CapturedIo()
3146
+ let healthCalls = 0
3147
+ const client = {
3148
+ health: async () => {
3149
+ healthCalls += 1
3150
+ return true
3151
+ },
3152
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3153
+ } as unknown as ApiClient
3154
+ const deps: CommandDeps = {
3155
+ ...makeDeps(io, client),
3156
+ cwd,
3157
+ env: isolatedEnv(cwd),
3158
+ store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
3159
+ }
3160
+
3161
+ const readiness = await assessReadiness(deps)
3162
+ expect(healthCalls).toBe(1)
3163
+ expect(await doctorCommand(deps, { json: true }, { readiness })).toBe(EXIT.failed)
3164
+ expect(healthCalls).toBe(1)
3165
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toHaveProperty('states')
3166
+ })
3167
+
3168
+ it('treats a lock-file pin as installed without asking npx', async () => {
3169
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-lock-skill-'))
3170
+ writeFileSync(
3171
+ path.join(cwd, 'skills-lock.json'),
3172
+ `${JSON.stringify({
3173
+ skills: {
3174
+ notifai: {
3175
+ source: 'Raidiant-io/notifai',
3176
+ sourceType: 'github',
3177
+ ref: RELEASE_REF,
3178
+ },
3179
+ },
3180
+ })}\n`,
3181
+ )
3182
+ const io = new CapturedIo()
3183
+ const client = {
3184
+ health: async () => true,
3185
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
3186
+ listDevices: async () => ({ devices: [] }),
3187
+ } as unknown as ApiClient
3188
+
3189
+ const readiness = await assessReadiness({
3190
+ ...makeDeps(io, client),
3191
+ cwd,
3192
+ env: { ...isolatedEnv(cwd), PATH: '/nonexistent' },
3193
+ nativeSkills: realNativeSkills,
3194
+ })
3195
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
3196
+ status: 'ready',
3197
+ detail: `installed from ${SKILLS_SOURCE} in the project scope`,
3198
+ })
3199
+ })
3200
+ })
3201
+
2840
3202
  describe('an outage is not an answer', () => {
2841
3203
  /**
2842
3204
  * The dangerous shape: the first poll succeeds, then connectivity drops and
@@ -3351,9 +3713,12 @@ describe('asking before the hooks have ever run', () => {
3351
3713
  }
3352
3714
  const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
3353
3715
  expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3354
- expect(
3355
- hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath }),
3356
- ).toBe(EXIT.ok)
3716
+ applyPlan(path.join(env.CODEX_HOME, 'config.toml'), {
3717
+ hooks: buildHookConfig({
3718
+ adapterPath: hookAdapterPath(deps.hookAdapterHome),
3719
+ harness: 'codex',
3720
+ }),
3721
+ })
3357
3722
  trustInstalledCodexHooks(cwd, env)
3358
3723
  writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3359
3724
  writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
@@ -3507,13 +3872,12 @@ describe('asking before the hooks have ever run', () => {
3507
3872
 
3508
3873
  it('shows resolved question-routing values with their winning config sources', async () => {
3509
3874
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-routing-config-'))
3510
- mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
3511
- writeFileSync(
3512
- path.join(cwd, '.notifai', 'config.local.toml'),
3513
- 'ask_notifications = false\nask_grace_seconds = 90\n',
3514
- )
3875
+ const env = { XDG_CONFIG_HOME: path.join(cwd, 'xdg') }
3876
+ const localFile = personalProjectConfigPath(cwd, env)
3877
+ mkdirSync(path.dirname(localFile), { recursive: true })
3878
+ writeFileSync(localFile, 'ask_notifications = false\nask_grace_seconds = 90\n')
3515
3879
  const io = new CapturedIo()
3516
- const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
3880
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
3517
3881
 
3518
3882
  await doctorCommand(deps, {})
3519
3883
 
@@ -3717,7 +4081,7 @@ describe('asking before the hooks have ever run', () => {
3717
4081
  const deps = {
3718
4082
  ...makeDeps(io, {} as ApiClient),
3719
4083
  cwd,
3720
- env: { XDG_STATE_HOME: path.join(cwd, 'state') },
4084
+ env: isolatedEnv(cwd),
3721
4085
  }
3722
4086
 
3723
4087
  expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
@@ -3754,7 +4118,12 @@ describe('asking before the hooks have ever run', () => {
3754
4118
  const deps = {
3755
4119
  ...makeDeps(io, {} as ApiClient),
3756
4120
  cwd,
3757
- env: { XDG_STATE_HOME: cwd, CODEX_HOME: path.join(cwd, 'none'), CLAUDE_CONFIG_DIR: path.join(cwd, 'none') },
4121
+ env: {
4122
+ HOME: path.join(cwd, 'home'),
4123
+ XDG_STATE_HOME: cwd,
4124
+ CODEX_HOME: path.join(cwd, 'none'),
4125
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'none'),
4126
+ },
3758
4127
  }
3759
4128
 
3760
4129
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)