@raidiant/notifai 0.5.0 → 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/claude-wake.d.ts +101 -0
- package/dist/claude-wake.js +397 -0
- package/dist/claude-wake.js.map +1 -0
- package/dist/codex-wake.d.ts +84 -0
- package/dist/codex-wake.js +238 -0
- package/dist/codex-wake.js.map +1 -0
- package/dist/commands.d.ts +31 -11
- package/dist/commands.js +679 -232
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +3 -3
- package/dist/config-schema.js +11 -49
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +23 -45
- package/dist/config.js +61 -39
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +4 -13
- package/dist/harnesses.js +12 -17
- 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/hooks.d.ts +119 -65
- package/dist/hooks.js +330 -285
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +76 -24
- package/dist/install-hooks.js +301 -125
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +32 -1
- package/dist/interactive.js +165 -51
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +14 -21
- 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/dist/release.d.ts +31 -0
- package/dist/release.js +52 -0
- package/dist/release.js.map +1 -0
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +11 -10
- package/src/commands.test.ts +386 -36
- package/src/commands.ts +473 -182
- 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 +14 -20
- 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/release.ts +51 -0
- package/src/skill.test.ts +43 -0
- package/src/ui/config-view.test.ts +5 -4
- package/dist/idle.d.ts +0 -16
- package/dist/idle.js +0 -44
- package/dist/idle.js.map +0 -1
package/src/commands.test.ts
CHANGED
|
@@ -50,12 +50,28 @@ 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'
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The release tag this build pins the agent skill to.
|
|
61
|
+
*
|
|
62
|
+
* Read from the manifest for the same reason the CLI itself does: a fixture
|
|
63
|
+
* carrying a literal tag stops matching the moment the version is bumped, and
|
|
64
|
+
* a test that has to be hand-edited on every release is a copy of the constant
|
|
65
|
+
* it is supposed to be checking.
|
|
66
|
+
*/
|
|
67
|
+
const RELEASE_REF = `v${
|
|
68
|
+
(
|
|
69
|
+
JSON.parse(
|
|
70
|
+
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
|
|
71
|
+
) as { version: string }
|
|
72
|
+
).version
|
|
73
|
+
}`
|
|
74
|
+
|
|
59
75
|
class CapturedIo implements CommandIo {
|
|
60
76
|
outLines: string[] = []
|
|
61
77
|
errLines: string[] = []
|
|
@@ -107,6 +123,17 @@ class InteractiveIo extends CapturedIo {
|
|
|
107
123
|
return this.selectAnswer
|
|
108
124
|
}
|
|
109
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
|
+
|
|
110
137
|
async intro(title: string) {
|
|
111
138
|
this.intros.push(title)
|
|
112
139
|
}
|
|
@@ -387,7 +414,9 @@ describe('command contracts', () => {
|
|
|
387
414
|
expect(source).not.toContain(".option('--badge")
|
|
388
415
|
expect(source).not.toContain(".option('--relevance")
|
|
389
416
|
expect(source).not.toContain(".option('--target-content-id")
|
|
390
|
-
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')
|
|
391
420
|
})
|
|
392
421
|
|
|
393
422
|
it('rejects a question nobody will wait for', async () => {
|
|
@@ -1237,6 +1266,86 @@ describe('Cursor hook commands', () => {
|
|
|
1237
1266
|
})
|
|
1238
1267
|
})
|
|
1239
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
|
+
|
|
1240
1349
|
describe('harness activation guidance', () => {
|
|
1241
1350
|
const execPath = process.execPath
|
|
1242
1351
|
const scriptPath = fileURLToPath(import.meta.url)
|
|
@@ -1250,9 +1359,10 @@ describe('harness activation guidance', () => {
|
|
|
1250
1359
|
hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath }),
|
|
1251
1360
|
).toBe(EXIT.ok)
|
|
1252
1361
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
)
|
|
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)
|
|
1256
1366
|
})
|
|
1257
1367
|
|
|
1258
1368
|
it('does not invent a Codex hook trust gate', () => {
|
|
@@ -1269,6 +1379,52 @@ describe('harness activation guidance', () => {
|
|
|
1269
1379
|
expect(output).not.toMatch(/trust|approve/i)
|
|
1270
1380
|
})
|
|
1271
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
|
+
|
|
1272
1428
|
it('keeps OpenCode permission prompts local and reports unsupported continuation', async () => {
|
|
1273
1429
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-activation-'))
|
|
1274
1430
|
const io = new CapturedIo()
|
|
@@ -1294,8 +1450,9 @@ describe('harness activation guidance', () => {
|
|
|
1294
1450
|
EXIT.ok,
|
|
1295
1451
|
)
|
|
1296
1452
|
|
|
1297
|
-
expect(io.outLines.join('\n')).toContain('
|
|
1298
|
-
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/)
|
|
1299
1456
|
const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
|
|
1300
1457
|
const plugin = readFileSync(pluginFile, 'utf8')
|
|
1301
1458
|
expect(plugin).toContain('const TIMEOUT_MS = 540000')
|
|
@@ -1586,10 +1743,36 @@ describe('config surfaces', () => {
|
|
|
1586
1743
|
const io = new CapturedIo()
|
|
1587
1744
|
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
1588
1745
|
expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
|
|
1589
|
-
expect(io.outLines[0]).toMatch(/^
|
|
1746
|
+
expect(io.outLines[0]).toMatch(/^wait_seconds = /)
|
|
1590
1747
|
for (const line of io.outLines) expect(line).toMatch(/^[a-z_]+ = /)
|
|
1591
1748
|
})
|
|
1592
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
|
+
|
|
1593
1776
|
it('explains each setting once a human is at the terminal', () => {
|
|
1594
1777
|
const io = new InteractiveIo()
|
|
1595
1778
|
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
@@ -1771,11 +1954,11 @@ describe('interactive command UX', () => {
|
|
|
1771
1954
|
}
|
|
1772
1955
|
|
|
1773
1956
|
expect(await configSetCommand(deps, 'sound', 'done', {})).toBe(EXIT.ok)
|
|
1957
|
+
const localFile = personalProjectConfigPath(cwd, deps.env)
|
|
1774
1958
|
expect(io.prompts[0]).toBe('Where should this setting live?')
|
|
1775
|
-
expect(io.prompts[1]).toContain(
|
|
1776
|
-
expect(readFileSync(
|
|
1777
|
-
|
|
1778
|
-
)
|
|
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)
|
|
1779
1962
|
})
|
|
1780
1963
|
|
|
1781
1964
|
it('bypasses interactive config selection with --yes and uses the global default', async () => {
|
|
@@ -1798,13 +1981,14 @@ describe('interactive command UX', () => {
|
|
|
1798
1981
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-unset-layer-'))
|
|
1799
1982
|
const io = new InteractiveIo()
|
|
1800
1983
|
io.selectAnswer = 'local'
|
|
1801
|
-
const
|
|
1984
|
+
const env = { XDG_CONFIG_HOME: path.join(cwd, 'xdg') }
|
|
1985
|
+
const localFile = personalProjectConfigPath(cwd, env)
|
|
1802
1986
|
mkdirSync(path.dirname(localFile), { recursive: true })
|
|
1803
1987
|
writeFileSync(localFile, 'sound = "done"\n')
|
|
1804
1988
|
const deps = {
|
|
1805
1989
|
...makeDeps(io, {} as ApiClient),
|
|
1806
1990
|
cwd,
|
|
1807
|
-
env
|
|
1991
|
+
env,
|
|
1808
1992
|
}
|
|
1809
1993
|
|
|
1810
1994
|
expect(await configUnsetCommand(deps, 'sound', {})).toBe(EXIT.ok)
|
|
@@ -1974,7 +2158,7 @@ describe('init', () => {
|
|
|
1974
2158
|
source: 'Raidiant-io/notifai',
|
|
1975
2159
|
sourceType: 'github',
|
|
1976
2160
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1977
|
-
ref:
|
|
2161
|
+
ref: RELEASE_REF,
|
|
1978
2162
|
}
|
|
1979
2163
|
}
|
|
1980
2164
|
|
|
@@ -2004,13 +2188,21 @@ describe('init', () => {
|
|
|
2004
2188
|
cwd,
|
|
2005
2189
|
env: isolatedEnv(cwd),
|
|
2006
2190
|
nativeSkills,
|
|
2191
|
+
hookInstallTarget: {
|
|
2192
|
+
execPath: process.execPath,
|
|
2193
|
+
scriptPath: fileURLToPath(import.meta.url),
|
|
2194
|
+
},
|
|
2007
2195
|
}
|
|
2008
2196
|
}
|
|
2009
2197
|
|
|
2010
2198
|
it('writes the project identifier into .notifai/config.toml and is idempotent', async () => {
|
|
2011
2199
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'My Project-'))
|
|
2012
2200
|
const io = new CapturedIo()
|
|
2013
|
-
const deps = {
|
|
2201
|
+
const deps: CommandDeps = {
|
|
2202
|
+
...makeDeps(io, {} as ApiClient),
|
|
2203
|
+
cwd,
|
|
2204
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
2205
|
+
}
|
|
2014
2206
|
|
|
2015
2207
|
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
2016
2208
|
const configPath = path.join(cwd, '.notifai', 'config.toml')
|
|
@@ -2022,9 +2214,10 @@ describe('init', () => {
|
|
|
2022
2214
|
|
|
2023
2215
|
io.outLines = []
|
|
2024
2216
|
expect(await initCommand(deps, { skills: false })).toBe(EXIT.failed)
|
|
2025
|
-
// Idempotent: the second run re-derives the same slug and
|
|
2026
|
-
//
|
|
2027
|
-
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:')
|
|
2028
2221
|
expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
|
|
2029
2222
|
})
|
|
2030
2223
|
|
|
@@ -2076,8 +2269,9 @@ describe('init', () => {
|
|
|
2076
2269
|
expect(io.errLines).toEqual([])
|
|
2077
2270
|
})
|
|
2078
2271
|
|
|
2079
|
-
it('pins the skill installer to the tagged
|
|
2080
|
-
expect(SKILLS_SOURCE).toBe(
|
|
2272
|
+
it('pins the skill installer to the tagged release this build actually is', () => {
|
|
2273
|
+
expect(SKILLS_SOURCE).toBe(`Raidiant-io/notifai#${RELEASE_REF}`)
|
|
2274
|
+
// `#` selects a Git ref; `@` would select a skill name instead.
|
|
2081
2275
|
expect(SKILLS_SOURCE).not.toContain('@v')
|
|
2082
2276
|
})
|
|
2083
2277
|
|
|
@@ -2100,7 +2294,7 @@ describe('init', () => {
|
|
|
2100
2294
|
source: 'Raidiant-io/notifai',
|
|
2101
2295
|
sourceType: 'github',
|
|
2102
2296
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
2103
|
-
ref:
|
|
2297
|
+
ref: RELEASE_REF,
|
|
2104
2298
|
},
|
|
2105
2299
|
],
|
|
2106
2300
|
}),
|
|
@@ -2255,6 +2449,48 @@ describe('init', () => {
|
|
|
2255
2449
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
2256
2450
|
})
|
|
2257
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
|
+
|
|
2258
2494
|
it('reports an optional native installer failure without blocking remaining setup', async () => {
|
|
2259
2495
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-failed-'))
|
|
2260
2496
|
const io = new InteractiveIo()
|
|
@@ -2489,9 +2725,6 @@ describe('init', () => {
|
|
|
2489
2725
|
expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
|
|
2490
2726
|
expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
|
|
2491
2727
|
expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
|
|
2492
|
-
expect(io.outLines.join('\n')).toContain(
|
|
2493
|
-
"Companion Receipt (the app's delivery confirmation) observed from iPhone",
|
|
2494
|
-
)
|
|
2495
2728
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
2496
2729
|
expect(submitCalls).toBe(1)
|
|
2497
2730
|
expect(submittedDraft?.draft.event).toBe('setup_verified')
|
|
@@ -2647,9 +2880,7 @@ describe('init', () => {
|
|
|
2647
2880
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
2648
2881
|
expect(submitCalls).toBe(2)
|
|
2649
2882
|
expect(io.outLines.join('\n')).toContain('saved proof had expired; sent replacement req_replacement')
|
|
2650
|
-
expect(io.outLines.join('\n')).toContain(
|
|
2651
|
-
"Companion Receipt (the app's delivery confirmation) observed from iPhone",
|
|
2652
|
-
)
|
|
2883
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
2653
2884
|
})
|
|
2654
2885
|
|
|
2655
2886
|
it('reports a proof-state write failure instead of crashing or sending twice', async () => {
|
|
@@ -2820,6 +3051,126 @@ describe('init', () => {
|
|
|
2820
3051
|
})
|
|
2821
3052
|
})
|
|
2822
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
|
+
|
|
2823
3174
|
describe('an outage is not an answer', () => {
|
|
2824
3175
|
/**
|
|
2825
3176
|
* The dangerous shape: the first poll succeeds, then connectivity drops and
|
|
@@ -3237,7 +3588,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3237
3588
|
source: 'Raidiant-io/notifai',
|
|
3238
3589
|
sourceType: 'github',
|
|
3239
3590
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
3240
|
-
ref:
|
|
3591
|
+
ref: RELEASE_REF,
|
|
3241
3592
|
},
|
|
3242
3593
|
]
|
|
3243
3594
|
: [],
|
|
@@ -3490,13 +3841,12 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3490
3841
|
|
|
3491
3842
|
it('shows resolved question-routing values with their winning config sources', async () => {
|
|
3492
3843
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-routing-config-'))
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
)
|
|
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')
|
|
3498
3848
|
const io = new CapturedIo()
|
|
3499
|
-
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env
|
|
3849
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3500
3850
|
|
|
3501
3851
|
await doctorCommand(deps, {})
|
|
3502
3852
|
|