@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.
- package/CHANGELOG.md +173 -0
- package/README.md +11 -4
- package/dist/commands.d.ts +16 -1
- package/dist/commands.js +385 -158
- 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 +60 -20
- package/dist/install-hooks.js +246 -100
- 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 +411 -42
- package/src/commands.ts +468 -178
- 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 +180 -8
- package/src/install-hooks.ts +227 -47
- 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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
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('
|
|
1314
|
-
expect(io.outLines.join('\n')).toContain('
|
|
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\)
|
|
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(/^
|
|
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(
|
|
1792
|
-
expect(readFileSync(
|
|
1793
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
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
|
|
2042
|
-
//
|
|
2043
|
-
expect(io.outLines.join('\n')).
|
|
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
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
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
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
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:
|
|
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: {
|
|
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)
|