@raidiant/notifai 2.0.0 → 2.0.2
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 +15 -0
- package/dist/commands.js +63 -56
- package/dist/commands.js.map +1 -1
- package/dist/install-hooks.d.ts +14 -15
- package/dist/install-hooks.js +26 -55
- package/dist/install-hooks.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +64 -13
- package/src/commands.ts +78 -70
- package/src/install-hooks.test.ts +16 -62
- package/src/install-hooks.ts +32 -56
package/src/commands.ts
CHANGED
|
@@ -102,7 +102,6 @@ import {
|
|
|
102
102
|
applyPlan,
|
|
103
103
|
buildCursorHookConfig,
|
|
104
104
|
buildHookConfig,
|
|
105
|
-
collapseUnusedCodexRepresentation,
|
|
106
105
|
codexLayerDir,
|
|
107
106
|
codexLayerPaths,
|
|
108
107
|
codexRepresentationProblems,
|
|
@@ -121,6 +120,7 @@ import {
|
|
|
121
120
|
removeCursorHooks,
|
|
122
121
|
removeHooks,
|
|
123
122
|
settingsFile,
|
|
123
|
+
withCodexLayerTransaction,
|
|
124
124
|
type Harness,
|
|
125
125
|
type Installation,
|
|
126
126
|
} from './install-hooks.js'
|
|
@@ -2382,12 +2382,17 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2382
2382
|
return EXIT.failed
|
|
2383
2383
|
}
|
|
2384
2384
|
}
|
|
2385
|
-
const
|
|
2385
|
+
const codexPaths =
|
|
2386
|
+
harness === 'codex'
|
|
2387
|
+
? codexLayerPaths(wantGlobal, deps.cwd, deps.env, hookPlatform)
|
|
2388
|
+
: null
|
|
2389
|
+
const settingsTarget =
|
|
2390
|
+
codexPaths?.configToml ?? settingsFile(harness, wantGlobal, deps.cwd, deps.env, hookPlatform)
|
|
2386
2391
|
|
|
2387
2392
|
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
2388
2393
|
// merged into a settings document, so it owns the whole file.
|
|
2389
2394
|
if (harness === 'opencode') {
|
|
2390
|
-
return installOpencodePlugin(deps,
|
|
2395
|
+
return installOpencodePlugin(deps, settingsTarget, {
|
|
2391
2396
|
adapterPath,
|
|
2392
2397
|
timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
2393
2398
|
platform: hookPlatform,
|
|
@@ -2397,8 +2402,8 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2397
2402
|
|
|
2398
2403
|
if (harness === 'cursor') {
|
|
2399
2404
|
try {
|
|
2400
|
-
withTargetFileLock(
|
|
2401
|
-
const document = loadCursorSettings(
|
|
2405
|
+
withTargetFileLock(settingsTarget, () => {
|
|
2406
|
+
const document = loadCursorSettings(settingsTarget)
|
|
2402
2407
|
const result = mergeCursorHooks(
|
|
2403
2408
|
document,
|
|
2404
2409
|
buildCursorHookConfig({
|
|
@@ -2409,43 +2414,40 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2409
2414
|
}),
|
|
2410
2415
|
scriptPath,
|
|
2411
2416
|
)
|
|
2412
|
-
applyPlan(
|
|
2417
|
+
applyPlan(settingsTarget, result.document)
|
|
2413
2418
|
return result
|
|
2414
2419
|
})
|
|
2415
2420
|
} catch (err) {
|
|
2416
2421
|
deps.io.err(String(err))
|
|
2417
2422
|
return EXIT.failed
|
|
2418
2423
|
}
|
|
2419
|
-
printHooksInstallClose(deps, harness,
|
|
2424
|
+
printHooksInstallClose(deps, harness, settingsTarget)
|
|
2420
2425
|
return EXIT.ok
|
|
2421
2426
|
}
|
|
2422
2427
|
|
|
2423
|
-
|
|
2424
|
-
|
|
2428
|
+
const installInto = (file: string): { file: string; foreignStopCount: number } => {
|
|
2429
|
+
const document = loadSettings(file)
|
|
2430
|
+
const foreignStopCount = foreignStopHandlers(document).length
|
|
2431
|
+
const result = mergeHooks(
|
|
2432
|
+
document,
|
|
2433
|
+
buildHookConfig({
|
|
2434
|
+
adapterPath,
|
|
2435
|
+
harness,
|
|
2436
|
+
platform: hookPlatform,
|
|
2437
|
+
nodePath,
|
|
2438
|
+
}),
|
|
2439
|
+
scriptPath,
|
|
2440
|
+
)
|
|
2441
|
+
applyPlan(file, result.document)
|
|
2442
|
+
return { file, foreignStopCount }
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
let installed: { file: string; foreignStopCount: number }
|
|
2425
2446
|
try {
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
document,
|
|
2431
|
-
buildHookConfig({
|
|
2432
|
-
adapterPath,
|
|
2433
|
-
harness,
|
|
2434
|
-
platform: hookPlatform,
|
|
2435
|
-
nodePath,
|
|
2436
|
-
}),
|
|
2437
|
-
scriptPath,
|
|
2438
|
-
)
|
|
2439
|
-
applyPlan(file, result.document)
|
|
2440
|
-
return result
|
|
2441
|
-
})
|
|
2442
|
-
if (harness === 'codex') {
|
|
2443
|
-
collapsedFrom = collapseUnusedCodexRepresentation(
|
|
2444
|
-
file,
|
|
2445
|
-
scriptPath,
|
|
2446
|
-
codexLayerPaths(flags.global ?? false, deps.cwd, deps.env),
|
|
2447
|
-
)
|
|
2448
|
-
}
|
|
2447
|
+
installed =
|
|
2448
|
+
codexPaths === null
|
|
2449
|
+
? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
|
|
2450
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => installInto(inspection.writeTarget))
|
|
2449
2451
|
} catch (err) {
|
|
2450
2452
|
deps.io.err(String(err))
|
|
2451
2453
|
return EXIT.failed
|
|
@@ -2455,17 +2457,14 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
2455
2457
|
const layer = flags.global ? null : codexLayerDir(deps.cwd)
|
|
2456
2458
|
if (layer !== null) mkdirSync(layer, { recursive: true })
|
|
2457
2459
|
}
|
|
2458
|
-
printHooksInstallClose(deps, harness, file)
|
|
2459
|
-
if (
|
|
2460
|
-
deps.io.out(`Collapsed ${collapsedFrom} onto ${file} so this layer has one representation.`)
|
|
2461
|
-
}
|
|
2462
|
-
if (foreignStopCount > 0) {
|
|
2460
|
+
printHooksInstallClose(deps, harness, installed.file)
|
|
2461
|
+
if (installed.foreignStopCount > 0) {
|
|
2463
2462
|
deps.io.out(
|
|
2464
2463
|
"This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.",
|
|
2465
2464
|
)
|
|
2466
2465
|
}
|
|
2467
2466
|
if (harness === 'codex') {
|
|
2468
|
-
for (const problem of codexRepresentationProblems(deps.cwd, deps.env)) {
|
|
2467
|
+
for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
|
|
2469
2468
|
deps.io.out(problem)
|
|
2470
2469
|
}
|
|
2471
2470
|
}
|
|
@@ -2522,13 +2521,13 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
2522
2521
|
const harness = resolveHarness(deps, flags.harness)
|
|
2523
2522
|
if (!harness) return EXIT.usage
|
|
2524
2523
|
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
2525
|
-
const
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2524
|
+
const global = flags.global ?? false
|
|
2525
|
+
const codexPaths =
|
|
2526
|
+
harness === 'codex'
|
|
2527
|
+
? codexLayerPaths(global, deps.cwd, deps.env, deps.hookPlatform)
|
|
2528
|
+
: null
|
|
2529
|
+
const file =
|
|
2530
|
+
codexPaths?.configToml ?? settingsFile(harness, global, deps.cwd, deps.env, deps.hookPlatform)
|
|
2532
2531
|
if (harness === 'opencode') {
|
|
2533
2532
|
try {
|
|
2534
2533
|
return withTargetFileLock(file, () => {
|
|
@@ -2576,32 +2575,48 @@ export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlag
|
|
|
2576
2575
|
)
|
|
2577
2576
|
return EXIT.ok
|
|
2578
2577
|
}
|
|
2579
|
-
const
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
try {
|
|
2578
|
+
const removeInstalledHooks = (): { existing: string[]; removedAny: boolean } => {
|
|
2579
|
+
const files =
|
|
2580
|
+
codexPaths === null
|
|
2581
|
+
? hookDefinitionFiles(harness, global, deps.cwd, deps.env, deps.hookPlatform)
|
|
2582
|
+
: [codexPaths.hooksJson, codexPaths.configToml]
|
|
2583
|
+
const existing = files.filter((candidate) => existsSync(candidate))
|
|
2584
|
+
let removedAny = false
|
|
2587
2585
|
for (const candidate of existing) {
|
|
2588
|
-
const
|
|
2586
|
+
const removeFromCandidate = () => {
|
|
2589
2587
|
const document = loadSettings(candidate)
|
|
2590
2588
|
const result = removeHooks(document, scriptPath)
|
|
2591
|
-
applyPlan(candidate, result.document)
|
|
2589
|
+
if (result.replaced.length > 0) applyPlan(candidate, result.document)
|
|
2592
2590
|
return result
|
|
2593
|
-
}
|
|
2591
|
+
}
|
|
2592
|
+
const stripped =
|
|
2593
|
+
codexPaths === null
|
|
2594
|
+
? withTargetFileLock(candidate, removeFromCandidate)
|
|
2595
|
+
: removeFromCandidate()
|
|
2594
2596
|
if (stripped.replaced.length > 0) {
|
|
2595
2597
|
removedAny = true
|
|
2596
2598
|
deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`)
|
|
2597
2599
|
}
|
|
2598
2600
|
}
|
|
2601
|
+
return { existing, removedAny }
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
let result: { existing: string[]; removedAny: boolean }
|
|
2605
|
+
try {
|
|
2606
|
+
result =
|
|
2607
|
+
codexPaths === null
|
|
2608
|
+
? removeInstalledHooks()
|
|
2609
|
+
: withCodexLayerTransaction(codexPaths, removeInstalledHooks)
|
|
2599
2610
|
} catch (err) {
|
|
2600
2611
|
deps.io.err(String(err))
|
|
2601
2612
|
return EXIT.failed
|
|
2602
2613
|
}
|
|
2603
|
-
if (
|
|
2604
|
-
deps.io.out(`
|
|
2614
|
+
if (result.existing.length === 0) {
|
|
2615
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
2616
|
+
return EXIT.ok
|
|
2617
|
+
}
|
|
2618
|
+
if (!result.removedAny) {
|
|
2619
|
+
deps.io.out(`No Notifai hooks found in ${result.existing.join(', ')}`)
|
|
2605
2620
|
}
|
|
2606
2621
|
return EXIT.ok
|
|
2607
2622
|
}
|
|
@@ -4896,23 +4911,16 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
|
4896
4911
|
})
|
|
4897
4912
|
}
|
|
4898
4913
|
|
|
4899
|
-
const representationProblems = codexRepresentationProblems(
|
|
4914
|
+
const representationProblems = codexRepresentationProblems(
|
|
4915
|
+
deps.cwd,
|
|
4916
|
+
deps.env,
|
|
4917
|
+
deps.hookPlatform,
|
|
4918
|
+
)
|
|
4900
4919
|
if (representationProblems.length > 0) {
|
|
4901
|
-
const collapseCommand = representationProblems.some((problem) => problem.includes('--global'))
|
|
4902
|
-
? 'notifai hooks install --harness codex --global'
|
|
4903
|
-
: 'notifai hooks install --harness codex'
|
|
4904
4920
|
checks.push({
|
|
4905
4921
|
name: 'hooks (codex representation)',
|
|
4906
4922
|
ok: false,
|
|
4907
4923
|
detail: representationProblems.join('; '),
|
|
4908
|
-
...(representationProblems.some((problem) => problem.includes('to collapse onto'))
|
|
4909
|
-
? {
|
|
4910
|
-
remedy: {
|
|
4911
|
-
summary: 'collapse this Codex layer onto a single hook file',
|
|
4912
|
-
command: collapseCommand,
|
|
4913
|
-
},
|
|
4914
|
-
}
|
|
4915
|
-
: {}),
|
|
4916
4924
|
})
|
|
4917
4925
|
}
|
|
4918
4926
|
|
|
@@ -253,15 +253,12 @@ describe('settings locations', () => {
|
|
|
253
253
|
expect(settingsFile('claude-code', true, '/repo', {})).toBe(
|
|
254
254
|
path.join(process.env['HOME']!, '.claude', 'settings.json'),
|
|
255
255
|
)
|
|
256
|
-
expect(settingsFile('codex', true, '/repo', {})).toBe(
|
|
257
|
-
path.join(process.env['HOME']!, '.codex', 'hooks.json'),
|
|
258
|
-
)
|
|
259
256
|
})
|
|
260
257
|
|
|
261
|
-
it('uses
|
|
262
|
-
expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/
|
|
258
|
+
it('uses inline Codex hooks for a new layer', () => {
|
|
259
|
+
expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/config.toml')
|
|
263
260
|
expect(settingsFile('codex', true, '/repo', {})).toBe(
|
|
264
|
-
path.join(os.homedir(), '.codex', '
|
|
261
|
+
path.join(os.homedir(), '.codex', 'config.toml'),
|
|
265
262
|
)
|
|
266
263
|
})
|
|
267
264
|
|
|
@@ -273,67 +270,24 @@ describe('settings locations', () => {
|
|
|
273
270
|
expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
|
|
274
271
|
})
|
|
275
272
|
|
|
276
|
-
it('
|
|
277
|
-
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-ours-toml-'))
|
|
278
|
-
mkdirSync(path.join(repo, '.codex'), { recursive: true })
|
|
279
|
-
applyPlan(path.join(repo, '.codex', 'config.toml'), { hooks: ours() })
|
|
280
|
-
|
|
281
|
-
expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
|
|
282
|
-
})
|
|
283
|
-
|
|
284
|
-
it('stays on hooks.json when that file already holds someone else\'s hooks', () => {
|
|
273
|
+
it('stays on hooks.json when it alone has hooks, even if config.toml already exists', () => {
|
|
285
274
|
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-json-'))
|
|
286
|
-
|
|
287
|
-
|
|
275
|
+
const layer = path.join(repo, '.codex')
|
|
276
|
+
mkdirSync(layer, { recursive: true })
|
|
277
|
+
writeFileSync(
|
|
278
|
+
path.join(layer, 'config.toml'),
|
|
279
|
+
`[hooks.state."${layer}/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
|
|
280
|
+
)
|
|
281
|
+
applyPlan(path.join(layer, 'hooks.json'), {
|
|
288
282
|
hooks: {
|
|
289
283
|
Stop: [{ hooks: [{ type: 'command', command: 'gdh-stop' }] }],
|
|
290
284
|
},
|
|
291
285
|
})
|
|
292
286
|
|
|
293
|
-
expect(settingsFile('codex', false, repo, {})).toBe(path.join(
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
it('writes Codex hooks into an existing inline [hooks] instead of inventing hooks.json', () => {
|
|
297
|
-
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-toml-layer-'))
|
|
298
|
-
mkdirSync(path.join(repo, '.codex'), { recursive: true })
|
|
299
|
-
writeFileSync(
|
|
300
|
-
path.join(repo, '.codex', 'config.toml'),
|
|
301
|
-
[
|
|
302
|
-
'model = "gpt-5.6"',
|
|
303
|
-
'',
|
|
304
|
-
'[[hooks.PostToolUse]]',
|
|
305
|
-
'matcher = "Bash"',
|
|
306
|
-
'',
|
|
307
|
-
'[[hooks.PostToolUse.hooks]]',
|
|
308
|
-
'type = "command"',
|
|
309
|
-
'command = "gdh-guard"',
|
|
310
|
-
'',
|
|
311
|
-
'[[hooks.Stop]]',
|
|
312
|
-
'',
|
|
313
|
-
'[[hooks.Stop.hooks]]',
|
|
314
|
-
'type = "command"',
|
|
315
|
-
'command = "gdh-stop"',
|
|
316
|
-
'',
|
|
317
|
-
].join('\n'),
|
|
318
|
-
)
|
|
319
|
-
|
|
320
|
-
expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
it('does not treat the Codex trust store as an inline hook representation', () => {
|
|
324
|
-
const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-only-'))
|
|
325
|
-
mkdirSync(path.join(home, '.codex'), { recursive: true })
|
|
326
|
-
writeFileSync(
|
|
327
|
-
path.join(home, '.codex', 'config.toml'),
|
|
328
|
-
`[hooks.state."${home}/.codex/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
|
|
329
|
-
)
|
|
330
|
-
|
|
331
|
-
expect(settingsFile('codex', true, '/repo', { HOME: home, CODEX_HOME: path.join(home, '.codex') })).toBe(
|
|
332
|
-
path.join(home, '.codex', 'hooks.json'),
|
|
333
|
-
)
|
|
287
|
+
expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'hooks.json'))
|
|
334
288
|
})
|
|
335
289
|
|
|
336
|
-
it('
|
|
290
|
+
it('does not migrate our hooks when both representations already exist', () => {
|
|
337
291
|
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-both-'))
|
|
338
292
|
const layer = path.join(repo, '.codex')
|
|
339
293
|
mkdirSync(layer, { recursive: true })
|
|
@@ -345,7 +299,7 @@ describe('settings locations', () => {
|
|
|
345
299
|
)
|
|
346
300
|
applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
|
|
347
301
|
|
|
348
|
-
expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, '
|
|
302
|
+
expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'hooks.json'))
|
|
349
303
|
})
|
|
350
304
|
})
|
|
351
305
|
|
|
@@ -371,7 +325,7 @@ describe('a Codex install run inside a git worktree', () => {
|
|
|
371
325
|
it('targets the main repository, which is the file Codex actually reads', () => {
|
|
372
326
|
const { main, worktree } = repoWithWorktree()
|
|
373
327
|
|
|
374
|
-
expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', '
|
|
328
|
+
expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'config.toml'))
|
|
375
329
|
expect(codexProjectRoot(worktree)).toBe(main)
|
|
376
330
|
})
|
|
377
331
|
|
|
@@ -438,7 +392,7 @@ describe('a Codex install run inside a git worktree', () => {
|
|
|
438
392
|
|
|
439
393
|
it('follows the active harness home for global hook install', () => {
|
|
440
394
|
expect(settingsFile('codex', true, '/repo', { HOME: '/user', CODEX_HOME: '/managed/codex' })).toBe(
|
|
441
|
-
'/managed/codex/
|
|
395
|
+
'/managed/codex/config.toml',
|
|
442
396
|
)
|
|
443
397
|
expect(settingsFile('claude-code', true, '/repo', { CLAUDE_CONFIG_DIR: '/managed/claude' })).toBe(
|
|
444
398
|
'/managed/claude/settings.json',
|
package/src/install-hooks.ts
CHANGED
|
@@ -3,13 +3,13 @@ import {
|
|
|
3
3
|
lstatSync,
|
|
4
4
|
readFileSync,
|
|
5
5
|
realpathSync,
|
|
6
|
-
rmSync,
|
|
7
6
|
statSync,
|
|
8
7
|
} from 'node:fs'
|
|
9
8
|
import { createHash } from 'node:crypto'
|
|
10
9
|
import path from 'node:path'
|
|
11
10
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
12
11
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
12
|
+
import { withTargetFileLock } from './file-lock.js'
|
|
13
13
|
import {
|
|
14
14
|
hookAdapterPath,
|
|
15
15
|
hookHostPlatform,
|
|
@@ -41,8 +41,9 @@ const OPENCODE_EVENTS = [
|
|
|
41
41
|
* with an optional timeout — so one generator serves both. Only the file
|
|
42
42
|
* location and the event set differ. Codex supports either a dedicated
|
|
43
43
|
* `hooks.json` or inline `[hooks]` tables in the same layer's `config.toml`.
|
|
44
|
-
* We default
|
|
45
|
-
*
|
|
44
|
+
* We default a hook-empty layer to inline config and preserve whichever single
|
|
45
|
+
* representation a populated layer already uses. One representation per layer,
|
|
46
|
+
* never both.
|
|
46
47
|
*
|
|
47
48
|
* Cursor's native format is flat and lower-camel-cased, while OpenCode's
|
|
48
49
|
* extension point is a JavaScript plugin module. Each therefore has a bounded
|
|
@@ -492,37 +493,32 @@ export interface CodexLayerInspection {
|
|
|
492
493
|
writeTarget: string
|
|
493
494
|
dual: boolean
|
|
494
495
|
overlappingEvents: string[]
|
|
495
|
-
canCollapse: boolean
|
|
496
496
|
}
|
|
497
497
|
|
|
498
498
|
/**
|
|
499
499
|
* Which representation this Codex layer already uses.
|
|
500
500
|
*
|
|
501
501
|
* Codex supports `hooks.json` and inline `[hooks]` side by side, but loads both
|
|
502
|
-
* and runs every matching handler.
|
|
502
|
+
* and runs every matching handler. Use inline config for a hook-empty layer and
|
|
503
503
|
* preserve an existing single representation so installing Notifai never
|
|
504
|
-
* invents a dual layer.
|
|
505
|
-
*
|
|
504
|
+
* invents a dual layer. When the layer is already dual, keep our handlers in
|
|
505
|
+
* the representation that already contains them instead of silently migrating
|
|
506
|
+
* them. `[hooks.state]` is the trust store, not a hook definition, so it does
|
|
507
|
+
* not count as a representation.
|
|
506
508
|
*/
|
|
507
509
|
export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection {
|
|
508
510
|
const jsonDocument = tryLoadSettings(paths.hooksJson)
|
|
509
511
|
const tomlDocument = tryLoadSettings(paths.configToml)
|
|
510
512
|
const jsonEvents = hookEventNames(jsonDocument?.hooks)
|
|
511
513
|
const tomlEvents = hookEventNames(tomlDocument?.hooks)
|
|
512
|
-
const jsonForeign = documentHasForeignHandlers(jsonDocument)
|
|
513
|
-
const tomlForeign = documentHasForeignHandlers(tomlDocument)
|
|
514
|
-
const jsonOurs = documentHasOurHandlers(jsonDocument)
|
|
515
|
-
const tomlOurs = documentHasOurHandlers(tomlDocument)
|
|
516
514
|
const dual = jsonEvents.length > 0 && tomlEvents.length > 0
|
|
517
|
-
let writeTarget = paths.
|
|
518
|
-
if (
|
|
519
|
-
writeTarget = paths.
|
|
515
|
+
let writeTarget = paths.configToml
|
|
516
|
+
if (jsonEvents.length > 0 && tomlEvents.length === 0) {
|
|
517
|
+
writeTarget = paths.hooksJson
|
|
520
518
|
} else if (dual) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
else if (tomlOurs && !jsonOurs) writeTarget = paths.configToml
|
|
525
|
-
else writeTarget = paths.hooksJson
|
|
519
|
+
const jsonOurs = documentHasOurHandlers(jsonDocument)
|
|
520
|
+
const tomlOurs = documentHasOurHandlers(tomlDocument)
|
|
521
|
+
if (jsonOurs && !tomlOurs) writeTarget = paths.hooksJson
|
|
526
522
|
}
|
|
527
523
|
return {
|
|
528
524
|
paths,
|
|
@@ -530,11 +526,23 @@ export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection
|
|
|
530
526
|
tomlEvents,
|
|
531
527
|
writeTarget,
|
|
532
528
|
dual,
|
|
533
|
-
overlappingEvents:
|
|
534
|
-
|
|
529
|
+
overlappingEvents: dual
|
|
530
|
+
? jsonEvents.filter((event) => tomlEvents.includes(event))
|
|
531
|
+
: [],
|
|
535
532
|
}
|
|
536
533
|
}
|
|
537
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Serialize one Notifai transaction across both representations in a Codex
|
|
537
|
+
* layer, then inspect that layer only after acquiring the shared anchor.
|
|
538
|
+
*/
|
|
539
|
+
export function withCodexLayerTransaction<T>(
|
|
540
|
+
paths: CodexLayerPaths,
|
|
541
|
+
action: (inspection: CodexLayerInspection) => T,
|
|
542
|
+
): T {
|
|
543
|
+
return withTargetFileLock(paths.configToml, () => action(inspectCodexLayer(paths)))
|
|
544
|
+
}
|
|
545
|
+
|
|
538
546
|
/** Every file in this layer Codex might already be reading hooks from. */
|
|
539
547
|
export function hookDefinitionFiles(
|
|
540
548
|
harness: Harness,
|
|
@@ -548,47 +556,20 @@ export function hookDefinitionFiles(
|
|
|
548
556
|
return [paths.hooksJson, paths.configToml]
|
|
549
557
|
}
|
|
550
558
|
|
|
551
|
-
/**
|
|
552
|
-
* Drop the unused representation after writing the one this layer already had.
|
|
553
|
-
*
|
|
554
|
-
* Only safe when the discarded file has no one else's handlers. `hooks.json`
|
|
555
|
-
* is deleted once it has no events left so Codex stops warning; `config.toml`
|
|
556
|
-
* is never deleted.
|
|
557
|
-
*/
|
|
558
|
-
export function collapseUnusedCodexRepresentation(
|
|
559
|
-
keepFile: string,
|
|
560
|
-
scriptPath: string,
|
|
561
|
-
paths: CodexLayerPaths,
|
|
562
|
-
): string | null {
|
|
563
|
-
const other = keepFile === paths.hooksJson ? paths.configToml : paths.hooksJson
|
|
564
|
-
if (!existsSync(other)) return null
|
|
565
|
-
const document = tryLoadSettings(other)
|
|
566
|
-
if (document === null || documentHasForeignHandlers(document)) return null
|
|
567
|
-
const stripped = removeHooks(document, scriptPath)
|
|
568
|
-
if (other === paths.hooksJson && hookEventNames(stripped.document.hooks).length === 0) {
|
|
569
|
-
rmSync(other)
|
|
570
|
-
return other
|
|
571
|
-
}
|
|
572
|
-
applyPlan(other, stripped.document)
|
|
573
|
-
return other
|
|
574
|
-
}
|
|
575
|
-
|
|
576
559
|
export function codexRepresentationProblems(
|
|
577
560
|
cwd: string,
|
|
578
561
|
env: NodeJS.ProcessEnv = process.env,
|
|
562
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
579
563
|
): string[] {
|
|
580
564
|
return [false, true].flatMap((global) => {
|
|
581
|
-
const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env))
|
|
565
|
+
const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env, platform))
|
|
582
566
|
if (!layer.dual) return []
|
|
583
567
|
const overlap =
|
|
584
568
|
layer.overlappingEvents.length > 0
|
|
585
569
|
? ` Matching handlers from both files all run — this layer's ${layer.overlappingEvents.join(', ')} handlers will both fire.`
|
|
586
570
|
: ' Matching handlers from both files all run.'
|
|
587
|
-
const collapse = layer.canCollapse
|
|
588
|
-
? ` Run \`notifai hooks install --harness codex${global ? ' --global' : ''}\` to collapse onto ${path.basename(layer.writeTarget)}.`
|
|
589
|
-
: ' Both files have other hooks, so Notifai cannot collapse them.'
|
|
590
571
|
return [
|
|
591
|
-
`loading hooks from both ${layer.paths.hooksJson} and ${layer.paths.configToml}; prefer a single representation for this layer.${overlap}
|
|
572
|
+
`loading hooks from both ${layer.paths.hooksJson} and ${layer.paths.configToml}; prefer a single representation for this layer.${overlap} Notifai preserves existing representations instead of migrating them automatically.`,
|
|
592
573
|
]
|
|
593
574
|
})
|
|
594
575
|
}
|
|
@@ -608,11 +589,6 @@ function hookEventNames(hooks: SettingsDocument['hooks']): string[] {
|
|
|
608
589
|
.map(([event]) => event)
|
|
609
590
|
}
|
|
610
591
|
|
|
611
|
-
function documentHasForeignHandlers(document: SettingsDocument | null): boolean {
|
|
612
|
-
if (document === null) return false
|
|
613
|
-
return locateAllHandlers(document).some((handler) => !isNotifaiCommand(handler.command))
|
|
614
|
-
}
|
|
615
|
-
|
|
616
592
|
function documentHasOurHandlers(document: SettingsDocument | null): boolean {
|
|
617
593
|
if (document === null) return false
|
|
618
594
|
return locateHandlers(document).length > 0
|