@raidiant/notifai 0.5.1 → 1.0.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/CHANGELOG.md +165 -0
- package/README.md +11 -4
- package/dist/commands.d.ts +16 -1
- package/dist/commands.js +371 -157
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +2 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +19 -8
- package/dist/config.js +50 -7
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +1 -0
- package/dist/harnesses.js +6 -0
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +10 -1
- package/dist/hook-adapter.js +79 -10
- package/dist/hook-adapter.js.map +1 -1
- package/dist/install-hooks.d.ts +54 -17
- package/dist/install-hooks.js +258 -97
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +32 -1
- package/dist/interactive.js +163 -47
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +8 -8
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +1 -1
- package/dist/native-skills.js +23 -58
- package/dist/native-skills.js.map +1 -1
- package/dist/pack-feedback-slice.d.ts +35 -0
- package/dist/pack-feedback-slice.js +126 -0
- package/dist/pack-feedback-slice.js.map +1 -0
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +24 -0
- package/dist/readiness.js.map +1 -1
- package/package.json +3 -2
- package/src/commands.test.ts +364 -31
- package/src/commands.ts +450 -177
- package/src/config-schema.ts +2 -17
- package/src/config.test.ts +29 -4
- package/src/config.ts +67 -11
- package/src/harnesses.ts +7 -0
- package/src/hook-adapter.test.ts +43 -1
- package/src/hook-adapter.ts +83 -11
- package/src/hooks.test.ts +4 -5
- package/src/install-hooks.test.ts +154 -2
- package/src/install-hooks.ts +231 -44
- package/src/interactive.test.ts +338 -0
- package/src/interactive.ts +221 -49
- package/src/main.ts +8 -8
- package/src/native-skills.test.ts +75 -0
- package/src/native-skills.ts +31 -75
- package/src/pack-feedback-slice.test.ts +212 -0
- package/src/pack-feedback-slice.ts +187 -0
- package/src/readiness.test.ts +25 -0
- package/src/readiness.ts +38 -0
- package/src/skill.test.ts +43 -0
- package/src/ui/config-view.test.ts +5 -4
package/src/commands.test.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -403,7 +414,9 @@ describe('command contracts', () => {
|
|
|
403
414
|
expect(source).not.toContain(".option('--badge")
|
|
404
415
|
expect(source).not.toContain(".option('--relevance")
|
|
405
416
|
expect(source).not.toContain(".option('--target-content-id")
|
|
406
|
-
expect(source).toContain('Kind profiles')
|
|
417
|
+
expect(source).toContain('Kind profiles apply automatically')
|
|
418
|
+
expect(source).toContain('Pass --sound or --level only to override')
|
|
419
|
+
expect(source).toContain('override kind profile / saved config')
|
|
407
420
|
})
|
|
408
421
|
|
|
409
422
|
it('rejects a question nobody will wait for', async () => {
|
|
@@ -1253,6 +1266,86 @@ describe('Cursor hook commands', () => {
|
|
|
1253
1266
|
})
|
|
1254
1267
|
})
|
|
1255
1268
|
|
|
1269
|
+
describe('Codex hook representation', () => {
|
|
1270
|
+
const execPath = process.execPath
|
|
1271
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
1272
|
+
|
|
1273
|
+
function writeInlineStop(repo: string, command: string): string {
|
|
1274
|
+
const file = path.join(repo, '.codex', 'config.toml')
|
|
1275
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
1276
|
+
writeFileSync(
|
|
1277
|
+
file,
|
|
1278
|
+
['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', `command = "${command}"`, ''].join(
|
|
1279
|
+
'\n',
|
|
1280
|
+
),
|
|
1281
|
+
)
|
|
1282
|
+
return file
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
it('installs into existing inline [hooks] and does not create hooks.json', () => {
|
|
1286
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-inline-install-'))
|
|
1287
|
+
const toml = writeInlineStop(cwd, 'gdh-stop')
|
|
1288
|
+
const io = new CapturedIo()
|
|
1289
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
1290
|
+
|
|
1291
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
1292
|
+
|
|
1293
|
+
expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
|
|
1294
|
+
const text = readFileSync(toml, 'utf8')
|
|
1295
|
+
expect(text).toContain('gdh-stop')
|
|
1296
|
+
expect(text).toContain('--owner notifai')
|
|
1297
|
+
expect(text).toContain('[[hooks.UserPromptSubmit]]')
|
|
1298
|
+
expect(io.outLines.join('\n')).toContain(toml)
|
|
1299
|
+
expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
it('collapses a leftover hooks.json onto inline [hooks] and says both Stops will run', async () => {
|
|
1303
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-collapse-'))
|
|
1304
|
+
const toml = writeInlineStop(cwd, 'gdh-stop')
|
|
1305
|
+
const json = path.join(cwd, '.codex', 'hooks.json')
|
|
1306
|
+
const env = isolatedEnv(cwd)
|
|
1307
|
+
const io = new CapturedIo()
|
|
1308
|
+
const client = {
|
|
1309
|
+
health: async () => false,
|
|
1310
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1311
|
+
listDevices: async () => ({ devices: [] }),
|
|
1312
|
+
} as unknown as ApiClient
|
|
1313
|
+
const deps = { ...makeDeps(io, client), cwd, env }
|
|
1314
|
+
|
|
1315
|
+
applyPlan(json, {
|
|
1316
|
+
hooks: buildHookConfig({
|
|
1317
|
+
adapterPath: hookAdapterPath(deps.hookAdapterHome),
|
|
1318
|
+
harness: 'codex',
|
|
1319
|
+
}),
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
io.outLines = []
|
|
1323
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
1324
|
+
const before = io.outLines.join('\n')
|
|
1325
|
+
expect(before).toMatch(/hooks \(codex representation\)/)
|
|
1326
|
+
expect(before).toMatch(/prefer a single representation/)
|
|
1327
|
+
expect(before).toMatch(/Stop handlers will both (run|fire)/i)
|
|
1328
|
+
expect(before).toContain('notifai hooks install --harness codex')
|
|
1329
|
+
|
|
1330
|
+
io.outLines = []
|
|
1331
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
1332
|
+
expect(existsSync(json)).toBe(false)
|
|
1333
|
+
expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
|
|
1334
|
+
expect(readFileSync(toml, 'utf8')).toContain('--owner notifai')
|
|
1335
|
+
expect(io.outLines.join('\n')).toMatch(/collaps/i)
|
|
1336
|
+
|
|
1337
|
+
const installations = findInstallations(cwd, env, deps.hookAdapterHome).filter(
|
|
1338
|
+
(installation) => installation.harness === 'codex',
|
|
1339
|
+
)
|
|
1340
|
+
expect(installations).toHaveLength(1)
|
|
1341
|
+
expect(installations[0]?.file).toBe(toml)
|
|
1342
|
+
|
|
1343
|
+
expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
|
|
1344
|
+
expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
|
|
1345
|
+
expect(readFileSync(toml, 'utf8')).not.toContain('--owner notifai')
|
|
1346
|
+
})
|
|
1347
|
+
})
|
|
1348
|
+
|
|
1256
1349
|
describe('harness activation guidance', () => {
|
|
1257
1350
|
const execPath = process.execPath
|
|
1258
1351
|
const scriptPath = fileURLToPath(import.meta.url)
|
|
@@ -1266,9 +1359,10 @@ describe('harness activation guidance', () => {
|
|
|
1266
1359
|
hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath }),
|
|
1267
1360
|
).toBe(EXIT.ok)
|
|
1268
1361
|
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
)
|
|
1362
|
+
const output = io.outLines.join('\n')
|
|
1363
|
+
expect(output).toContain('Installed claude-code hooks in')
|
|
1364
|
+
expect(output).toContain('Send one Claude Code prompt, then check `notifai doctor`.')
|
|
1365
|
+
expect(output).not.toMatch(/timeout|asynchronous|600s/i)
|
|
1272
1366
|
})
|
|
1273
1367
|
|
|
1274
1368
|
it('does not invent a Codex hook trust gate', () => {
|
|
@@ -1285,6 +1379,52 @@ describe('harness activation guidance', () => {
|
|
|
1285
1379
|
expect(output).not.toMatch(/trust|approve/i)
|
|
1286
1380
|
})
|
|
1287
1381
|
|
|
1382
|
+
it('names the installed harness in the close, never a different one', () => {
|
|
1383
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-close-'))
|
|
1384
|
+
const io = new CapturedIo()
|
|
1385
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
1386
|
+
|
|
1387
|
+
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
1388
|
+
|
|
1389
|
+
const output = io.outLines.join('\n')
|
|
1390
|
+
expect(output).toContain('Installed cursor hooks in')
|
|
1391
|
+
expect(output).toContain('Send one Cursor prompt, then check `notifai doctor`.')
|
|
1392
|
+
expect(output).not.toMatch(/Codex|Claude Code|OpenCode/)
|
|
1393
|
+
expect(output).not.toMatch(/timeout|worktree|fails closed/i)
|
|
1394
|
+
})
|
|
1395
|
+
|
|
1396
|
+
it('pins hooks to npx when that is how this CLI is running', () => {
|
|
1397
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-npx-hooks-'))
|
|
1398
|
+
const npmCli = path.join(cwd, 'npm-cli.js')
|
|
1399
|
+
writeFileSync(npmCli, '')
|
|
1400
|
+
const io = new CapturedIo()
|
|
1401
|
+
const deps = {
|
|
1402
|
+
...makeDeps(io, {} as ApiClient),
|
|
1403
|
+
cwd,
|
|
1404
|
+
env: {
|
|
1405
|
+
npm_command: 'exec',
|
|
1406
|
+
npm_execpath: npmCli,
|
|
1407
|
+
HOME: path.join(cwd, 'home'),
|
|
1408
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
1409
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
1410
|
+
},
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
expect(
|
|
1414
|
+
hooksInstallCommand(deps, {
|
|
1415
|
+
harness: 'claude-code',
|
|
1416
|
+
execPath: process.execPath,
|
|
1417
|
+
scriptPath: path.join(cwd, '_npx', 'hash', 'node_modules', '@raidiant', 'notifai', 'dist', 'main.js'),
|
|
1418
|
+
}),
|
|
1419
|
+
).toBe(EXIT.ok)
|
|
1420
|
+
|
|
1421
|
+
const inspected = inspectHookAdapter(deps.hookAdapterHome)
|
|
1422
|
+
expect(inspected.problems).toEqual([])
|
|
1423
|
+
expect(inspected.target && 'spec' in inspected.target ? inspected.target.spec : null).toMatch(
|
|
1424
|
+
/^@raidiant\/notifai@/,
|
|
1425
|
+
)
|
|
1426
|
+
})
|
|
1427
|
+
|
|
1288
1428
|
it('keeps OpenCode permission prompts local and reports unsupported continuation', async () => {
|
|
1289
1429
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-activation-'))
|
|
1290
1430
|
const io = new CapturedIo()
|
|
@@ -1310,8 +1450,9 @@ describe('harness activation guidance', () => {
|
|
|
1310
1450
|
EXIT.ok,
|
|
1311
1451
|
)
|
|
1312
1452
|
|
|
1313
|
-
expect(io.outLines.join('\n')).toContain('
|
|
1314
|
-
expect(io.outLines.join('\n')).toContain('
|
|
1453
|
+
expect(io.outLines.join('\n')).toContain('Installed opencode hooks in')
|
|
1454
|
+
expect(io.outLines.join('\n')).toContain('Send one OpenCode prompt, then check `notifai doctor`.')
|
|
1455
|
+
expect(io.outLines.join('\n')).not.toMatch(/Permission prompts|exactly-once continuation/)
|
|
1315
1456
|
const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
|
|
1316
1457
|
const plugin = readFileSync(pluginFile, 'utf8')
|
|
1317
1458
|
expect(plugin).toContain('const TIMEOUT_MS = 540000')
|
|
@@ -1602,10 +1743,36 @@ describe('config surfaces', () => {
|
|
|
1602
1743
|
const io = new CapturedIo()
|
|
1603
1744
|
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
1604
1745
|
expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
|
|
1605
|
-
expect(io.outLines[0]).toMatch(/^
|
|
1746
|
+
expect(io.outLines[0]).toMatch(/^wait_seconds = /)
|
|
1606
1747
|
for (const line of io.outLines) expect(line).toMatch(/^[a-z_]+ = /)
|
|
1607
1748
|
})
|
|
1608
1749
|
|
|
1750
|
+
it('gives agents the current value, source, and one-line meaning', () => {
|
|
1751
|
+
const io = new CapturedIo()
|
|
1752
|
+
expect(configShowCommand(configDeps(io), { json: true })).toBe(EXIT.ok)
|
|
1753
|
+
const parsed = JSON.parse(io.outLines.join('\n')) as Record<
|
|
1754
|
+
string,
|
|
1755
|
+
{ value: unknown; source: string; summary: string }
|
|
1756
|
+
>
|
|
1757
|
+
expect(parsed['base_url']).toBeUndefined()
|
|
1758
|
+
expect(parsed['ttl_seconds']).toEqual({
|
|
1759
|
+
value: 86400,
|
|
1760
|
+
source: 'default',
|
|
1761
|
+
summary: expect.stringContaining('deliver'),
|
|
1762
|
+
})
|
|
1763
|
+
expect(parsed['ask_grace_seconds']?.value).toBe(0)
|
|
1764
|
+
expect(parsed['ask_grace_seconds']?.summary).toBeTruthy()
|
|
1765
|
+
expect(Object.keys(parsed).sort()).toEqual([...CONFIG_KEYS].sort())
|
|
1766
|
+
})
|
|
1767
|
+
|
|
1768
|
+
it('refuses to treat the service origin as a setting', async () => {
|
|
1769
|
+
const io = new CapturedIo()
|
|
1770
|
+
expect(await configSetCommand(configDeps(io), 'base_url', 'https://attacker.example', { yes: true })).toBe(
|
|
1771
|
+
EXIT.usage,
|
|
1772
|
+
)
|
|
1773
|
+
expect(io.errLines[0]).toBe('Unknown setting "base_url".')
|
|
1774
|
+
})
|
|
1775
|
+
|
|
1609
1776
|
it('explains each setting once a human is at the terminal', () => {
|
|
1610
1777
|
const io = new InteractiveIo()
|
|
1611
1778
|
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
@@ -1787,11 +1954,11 @@ describe('interactive command UX', () => {
|
|
|
1787
1954
|
}
|
|
1788
1955
|
|
|
1789
1956
|
expect(await configSetCommand(deps, 'sound', 'done', {})).toBe(EXIT.ok)
|
|
1957
|
+
const localFile = personalProjectConfigPath(cwd, deps.env)
|
|
1790
1958
|
expect(io.prompts[0]).toBe('Where should this setting live?')
|
|
1791
|
-
expect(io.prompts[1]).toContain(
|
|
1792
|
-
expect(readFileSync(
|
|
1793
|
-
|
|
1794
|
-
)
|
|
1959
|
+
expect(io.prompts[1]).toContain(localFile)
|
|
1960
|
+
expect(readFileSync(localFile, 'utf8')).toContain('sound = "done"')
|
|
1961
|
+
expect(existsSync(path.join(cwd, '.notifai', 'config.local.toml'))).toBe(false)
|
|
1795
1962
|
})
|
|
1796
1963
|
|
|
1797
1964
|
it('bypasses interactive config selection with --yes and uses the global default', async () => {
|
|
@@ -1814,13 +1981,14 @@ describe('interactive command UX', () => {
|
|
|
1814
1981
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-unset-layer-'))
|
|
1815
1982
|
const io = new InteractiveIo()
|
|
1816
1983
|
io.selectAnswer = 'local'
|
|
1817
|
-
const
|
|
1984
|
+
const env = { XDG_CONFIG_HOME: path.join(cwd, 'xdg') }
|
|
1985
|
+
const localFile = personalProjectConfigPath(cwd, env)
|
|
1818
1986
|
mkdirSync(path.dirname(localFile), { recursive: true })
|
|
1819
1987
|
writeFileSync(localFile, 'sound = "done"\n')
|
|
1820
1988
|
const deps = {
|
|
1821
1989
|
...makeDeps(io, {} as ApiClient),
|
|
1822
1990
|
cwd,
|
|
1823
|
-
env
|
|
1991
|
+
env,
|
|
1824
1992
|
}
|
|
1825
1993
|
|
|
1826
1994
|
expect(await configUnsetCommand(deps, 'sound', {})).toBe(EXIT.ok)
|
|
@@ -2020,13 +2188,21 @@ describe('init', () => {
|
|
|
2020
2188
|
cwd,
|
|
2021
2189
|
env: isolatedEnv(cwd),
|
|
2022
2190
|
nativeSkills,
|
|
2191
|
+
hookInstallTarget: {
|
|
2192
|
+
execPath: process.execPath,
|
|
2193
|
+
scriptPath: fileURLToPath(import.meta.url),
|
|
2194
|
+
},
|
|
2023
2195
|
}
|
|
2024
2196
|
}
|
|
2025
2197
|
|
|
2026
2198
|
it('writes the project identifier into .notifai/config.toml and is idempotent', async () => {
|
|
2027
2199
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'My Project-'))
|
|
2028
2200
|
const io = new CapturedIo()
|
|
2029
|
-
const deps = {
|
|
2201
|
+
const deps: CommandDeps = {
|
|
2202
|
+
...makeDeps(io, {} as ApiClient),
|
|
2203
|
+
cwd,
|
|
2204
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
2205
|
+
}
|
|
2030
2206
|
|
|
2031
2207
|
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
2032
2208
|
const configPath = path.join(cwd, '.notifai', 'config.toml')
|
|
@@ -2038,9 +2214,10 @@ describe('init', () => {
|
|
|
2038
2214
|
|
|
2039
2215
|
io.outLines = []
|
|
2040
2216
|
expect(await initCommand(deps, { skills: false })).toBe(EXIT.failed)
|
|
2041
|
-
// Idempotent: the second run re-derives the same slug and
|
|
2042
|
-
//
|
|
2043
|
-
expect(io.outLines.join('\n')).
|
|
2217
|
+
// Idempotent: the second run re-derives the same slug and does not reprint
|
|
2218
|
+
// doctor's ready-state dump.
|
|
2219
|
+
expect(io.outLines.join('\n')).toMatch(/^Next:/m)
|
|
2220
|
+
expect(io.outLines.join('\n')).not.toContain('Project identity:')
|
|
2044
2221
|
expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
|
|
2045
2222
|
})
|
|
2046
2223
|
|
|
@@ -2272,6 +2449,48 @@ describe('init', () => {
|
|
|
2272
2449
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
2273
2450
|
})
|
|
2274
2451
|
|
|
2452
|
+
it('installs every detected project harness when --hooks is set', async () => {
|
|
2453
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-all-detected-'))
|
|
2454
|
+
mkdirSync(path.join(cwd, '.claude'))
|
|
2455
|
+
mkdirSync(path.join(cwd, '.codex'))
|
|
2456
|
+
const io = new CapturedIo()
|
|
2457
|
+
const calls = { submit: 0 }
|
|
2458
|
+
const nativeSkills: NativeSkills = {
|
|
2459
|
+
add: async () => 0,
|
|
2460
|
+
list: async () => ({ skills: [] }),
|
|
2461
|
+
}
|
|
2462
|
+
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
2463
|
+
await initCommand(deps, { hooks: true, skills: false })
|
|
2464
|
+
const wired = findInstallations(cwd, deps.env, deps.hookAdapterHome).map(
|
|
2465
|
+
(installation) => installation.harness,
|
|
2466
|
+
)
|
|
2467
|
+
expect(wired).toEqual(['claude-code', 'codex'])
|
|
2468
|
+
expect(existsSync(path.join(cwd, '.claude', 'settings.local.json'))).toBe(true)
|
|
2469
|
+
expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(true)
|
|
2470
|
+
expect(io.outLines.join('\n')).toContain('Installed claude-code hooks')
|
|
2471
|
+
expect(io.outLines.join('\n')).toContain('Installed codex hooks')
|
|
2472
|
+
})
|
|
2473
|
+
|
|
2474
|
+
it('lets a human keep a subset of the detected harnesses', async () => {
|
|
2475
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-pick-'))
|
|
2476
|
+
mkdirSync(path.join(cwd, '.claude'))
|
|
2477
|
+
mkdirSync(path.join(cwd, '.codex'))
|
|
2478
|
+
const io = new InteractiveIo()
|
|
2479
|
+
io.multiselectAnswer = ['claude-code']
|
|
2480
|
+
const calls = { submit: 0 }
|
|
2481
|
+
const nativeSkills: NativeSkills = {
|
|
2482
|
+
add: async () => 0,
|
|
2483
|
+
list: async () => ({ skills: [] }),
|
|
2484
|
+
}
|
|
2485
|
+
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
2486
|
+
expect(await initCommand(deps, { skills: false })).toBe(EXIT.ok)
|
|
2487
|
+
expect(io.prompts.some((prompt) => prompt.includes('Which agent harnesses'))).toBe(true)
|
|
2488
|
+
const wired = findInstallations(cwd, deps.env, deps.hookAdapterHome).map(
|
|
2489
|
+
(installation) => installation.harness,
|
|
2490
|
+
)
|
|
2491
|
+
expect(wired).toEqual(['claude-code'])
|
|
2492
|
+
})
|
|
2493
|
+
|
|
2275
2494
|
it('reports an optional native installer failure without blocking remaining setup', async () => {
|
|
2276
2495
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-failed-'))
|
|
2277
2496
|
const io = new InteractiveIo()
|
|
@@ -2506,9 +2725,6 @@ describe('init', () => {
|
|
|
2506
2725
|
expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
|
|
2507
2726
|
expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
|
|
2508
2727
|
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
2728
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
2513
2729
|
expect(submitCalls).toBe(1)
|
|
2514
2730
|
expect(submittedDraft?.draft.event).toBe('setup_verified')
|
|
@@ -2664,9 +2880,7 @@ describe('init', () => {
|
|
|
2664
2880
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
2665
2881
|
expect(submitCalls).toBe(2)
|
|
2666
2882
|
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
|
-
)
|
|
2883
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
2670
2884
|
})
|
|
2671
2885
|
|
|
2672
2886
|
it('reports a proof-state write failure instead of crashing or sending twice', async () => {
|
|
@@ -2837,6 +3051,126 @@ describe('init', () => {
|
|
|
2837
3051
|
})
|
|
2838
3052
|
})
|
|
2839
3053
|
|
|
3054
|
+
describe('readiness assessment cost', () => {
|
|
3055
|
+
it('does not re-probe the service after init closes a local-only gap', async () => {
|
|
3056
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-local-reassess-'))
|
|
3057
|
+
const io = new CapturedIo()
|
|
3058
|
+
let healthCalls = 0
|
|
3059
|
+
const client = {
|
|
3060
|
+
health: async () => {
|
|
3061
|
+
healthCalls += 1
|
|
3062
|
+
return true
|
|
3063
|
+
},
|
|
3064
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3065
|
+
} as unknown as ApiClient
|
|
3066
|
+
const deps: CommandDeps = {
|
|
3067
|
+
...makeDeps(io, client),
|
|
3068
|
+
cwd,
|
|
3069
|
+
env: isolatedEnv(cwd),
|
|
3070
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
3074
|
+
expect(healthCalls).toBe(1)
|
|
3075
|
+
expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain('project =')
|
|
3076
|
+
})
|
|
3077
|
+
|
|
3078
|
+
it('reuses remote probes when only local state could have changed', async () => {
|
|
3079
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-local-only-'))
|
|
3080
|
+
const io = new CapturedIo()
|
|
3081
|
+
let healthCalls = 0
|
|
3082
|
+
let deviceCalls = 0
|
|
3083
|
+
let evidenceCalls = 0
|
|
3084
|
+
const client = {
|
|
3085
|
+
health: async () => {
|
|
3086
|
+
healthCalls += 1
|
|
3087
|
+
return true
|
|
3088
|
+
},
|
|
3089
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3090
|
+
listDevices: async () => {
|
|
3091
|
+
deviceCalls += 1
|
|
3092
|
+
return { devices: [] }
|
|
3093
|
+
},
|
|
3094
|
+
accessStatus: async () => ({ email: 'user@example.com' }),
|
|
3095
|
+
evidence: async () => {
|
|
3096
|
+
evidenceCalls += 1
|
|
3097
|
+
throw new Error('unused')
|
|
3098
|
+
},
|
|
3099
|
+
} as unknown as ApiClient
|
|
3100
|
+
const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
|
|
3101
|
+
|
|
3102
|
+
const previous = await assessReadiness(deps)
|
|
3103
|
+
expect(healthCalls).toBe(1)
|
|
3104
|
+
expect(deviceCalls).toBe(1)
|
|
3105
|
+
|
|
3106
|
+
const next = await assessReadiness(deps, { previous, refresh: ['local'] })
|
|
3107
|
+
expect(healthCalls).toBe(1)
|
|
3108
|
+
expect(deviceCalls).toBe(1)
|
|
3109
|
+
expect(evidenceCalls).toBe(0)
|
|
3110
|
+
expect(next.states.find((state) => state.id === 'server')).toEqual(
|
|
3111
|
+
previous.states.find((state) => state.id === 'server'),
|
|
3112
|
+
)
|
|
3113
|
+
})
|
|
3114
|
+
|
|
3115
|
+
it('renders a supplied doctor report without probing again', async () => {
|
|
3116
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'doctor-reuse-'))
|
|
3117
|
+
const io = new CapturedIo()
|
|
3118
|
+
let healthCalls = 0
|
|
3119
|
+
const client = {
|
|
3120
|
+
health: async () => {
|
|
3121
|
+
healthCalls += 1
|
|
3122
|
+
return true
|
|
3123
|
+
},
|
|
3124
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3125
|
+
} as unknown as ApiClient
|
|
3126
|
+
const deps: CommandDeps = {
|
|
3127
|
+
...makeDeps(io, client),
|
|
3128
|
+
cwd,
|
|
3129
|
+
env: isolatedEnv(cwd),
|
|
3130
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
const readiness = await assessReadiness(deps)
|
|
3134
|
+
expect(healthCalls).toBe(1)
|
|
3135
|
+
expect(await doctorCommand(deps, { json: true }, { readiness })).toBe(EXIT.failed)
|
|
3136
|
+
expect(healthCalls).toBe(1)
|
|
3137
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toHaveProperty('states')
|
|
3138
|
+
})
|
|
3139
|
+
|
|
3140
|
+
it('treats a lock-file pin as installed without asking npx', async () => {
|
|
3141
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-lock-skill-'))
|
|
3142
|
+
writeFileSync(
|
|
3143
|
+
path.join(cwd, 'skills-lock.json'),
|
|
3144
|
+
`${JSON.stringify({
|
|
3145
|
+
skills: {
|
|
3146
|
+
notifai: {
|
|
3147
|
+
source: 'Raidiant-io/notifai',
|
|
3148
|
+
sourceType: 'github',
|
|
3149
|
+
ref: RELEASE_REF,
|
|
3150
|
+
},
|
|
3151
|
+
},
|
|
3152
|
+
})}\n`,
|
|
3153
|
+
)
|
|
3154
|
+
const io = new CapturedIo()
|
|
3155
|
+
const client = {
|
|
3156
|
+
health: async () => true,
|
|
3157
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3158
|
+
listDevices: async () => ({ devices: [] }),
|
|
3159
|
+
} as unknown as ApiClient
|
|
3160
|
+
|
|
3161
|
+
const readiness = await assessReadiness({
|
|
3162
|
+
...makeDeps(io, client),
|
|
3163
|
+
cwd,
|
|
3164
|
+
env: { ...isolatedEnv(cwd), PATH: '/nonexistent' },
|
|
3165
|
+
nativeSkills: realNativeSkills,
|
|
3166
|
+
})
|
|
3167
|
+
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
3168
|
+
status: 'ready',
|
|
3169
|
+
detail: `installed from ${SKILLS_SOURCE} in the project scope`,
|
|
3170
|
+
})
|
|
3171
|
+
})
|
|
3172
|
+
})
|
|
3173
|
+
|
|
2840
3174
|
describe('an outage is not an answer', () => {
|
|
2841
3175
|
/**
|
|
2842
3176
|
* The dangerous shape: the first poll succeeds, then connectivity drops and
|
|
@@ -3507,13 +3841,12 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3507
3841
|
|
|
3508
3842
|
it('shows resolved question-routing values with their winning config sources', async () => {
|
|
3509
3843
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-routing-config-'))
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
)
|
|
3844
|
+
const env = { XDG_CONFIG_HOME: path.join(cwd, 'xdg') }
|
|
3845
|
+
const localFile = personalProjectConfigPath(cwd, env)
|
|
3846
|
+
mkdirSync(path.dirname(localFile), { recursive: true })
|
|
3847
|
+
writeFileSync(localFile, 'ask_notifications = false\nask_grace_seconds = 90\n')
|
|
3515
3848
|
const io = new CapturedIo()
|
|
3516
|
-
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env
|
|
3849
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3517
3850
|
|
|
3518
3851
|
await doctorCommand(deps, {})
|
|
3519
3852
|
|