@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
|
@@ -23,15 +23,18 @@ import {
|
|
|
23
23
|
HARNESSES,
|
|
24
24
|
applyPlan,
|
|
25
25
|
detectHarness,
|
|
26
|
+
detectedHarnesses,
|
|
26
27
|
buildHookConfig,
|
|
27
28
|
codexLayerDir,
|
|
28
29
|
codexProjectRoot,
|
|
29
30
|
findInstallations,
|
|
30
31
|
codexHookIdentityHash,
|
|
32
|
+
codexRepresentationProblems,
|
|
31
33
|
codexTrustKey,
|
|
32
34
|
codexTrustProblems,
|
|
33
35
|
handlerEvent,
|
|
34
36
|
hookCommand,
|
|
37
|
+
loadSettings,
|
|
35
38
|
mergeHooks,
|
|
36
39
|
removeHooks,
|
|
37
40
|
settingsFile,
|
|
@@ -223,12 +226,86 @@ describe('settings locations', () => {
|
|
|
223
226
|
)
|
|
224
227
|
})
|
|
225
228
|
|
|
226
|
-
it('uses
|
|
227
|
-
expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/
|
|
229
|
+
it('uses config.toml for Codex, not the legacy hooks.json', () => {
|
|
230
|
+
expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/config.toml')
|
|
228
231
|
expect(settingsFile('codex', true, '/repo', {})).toBe(
|
|
229
|
-
path.join(os.homedir(), '.codex', '
|
|
232
|
+
path.join(os.homedir(), '.codex', 'config.toml'),
|
|
230
233
|
)
|
|
231
234
|
})
|
|
235
|
+
|
|
236
|
+
it('migrates a leftover Notifai-only hooks.json onto config.toml', () => {
|
|
237
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-migrate-json-'))
|
|
238
|
+
mkdirSync(path.join(repo, '.codex'), { recursive: true })
|
|
239
|
+
applyPlan(path.join(repo, '.codex', 'hooks.json'), { hooks: ours() })
|
|
240
|
+
|
|
241
|
+
expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('stays on hooks.json when that file already holds someone else\'s hooks', () => {
|
|
245
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-json-'))
|
|
246
|
+
mkdirSync(path.join(repo, '.codex'), { recursive: true })
|
|
247
|
+
applyPlan(path.join(repo, '.codex', 'hooks.json'), {
|
|
248
|
+
hooks: {
|
|
249
|
+
Stop: [{ hooks: [{ type: 'command', command: 'gdh-stop' }] }],
|
|
250
|
+
},
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('writes Codex hooks into an existing inline [hooks] instead of inventing hooks.json', () => {
|
|
257
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-toml-layer-'))
|
|
258
|
+
mkdirSync(path.join(repo, '.codex'), { recursive: true })
|
|
259
|
+
writeFileSync(
|
|
260
|
+
path.join(repo, '.codex', 'config.toml'),
|
|
261
|
+
[
|
|
262
|
+
'model = "gpt-5.6"',
|
|
263
|
+
'',
|
|
264
|
+
'[[hooks.PostToolUse]]',
|
|
265
|
+
'matcher = "Bash"',
|
|
266
|
+
'',
|
|
267
|
+
'[[hooks.PostToolUse.hooks]]',
|
|
268
|
+
'type = "command"',
|
|
269
|
+
'command = "gdh-guard"',
|
|
270
|
+
'',
|
|
271
|
+
'[[hooks.Stop]]',
|
|
272
|
+
'',
|
|
273
|
+
'[[hooks.Stop.hooks]]',
|
|
274
|
+
'type = "command"',
|
|
275
|
+
'command = "gdh-stop"',
|
|
276
|
+
'',
|
|
277
|
+
].join('\n'),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('does not treat the Codex trust store as an inline hook representation', () => {
|
|
284
|
+
const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-only-'))
|
|
285
|
+
writeFileSync(
|
|
286
|
+
path.join(home, 'config.toml'),
|
|
287
|
+
`[hooks.state."${home}/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
expect(settingsFile('codex', true, '/repo', { CODEX_HOME: home })).toBe(
|
|
291
|
+
path.join(home, 'config.toml'),
|
|
292
|
+
)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('keeps writing to inline [hooks] when a leftover hooks.json only holds Notifai', () => {
|
|
296
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-both-'))
|
|
297
|
+
const layer = path.join(repo, '.codex')
|
|
298
|
+
mkdirSync(layer, { recursive: true })
|
|
299
|
+
writeFileSync(
|
|
300
|
+
path.join(layer, 'config.toml'),
|
|
301
|
+
['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', 'command = "gdh-stop"', ''].join(
|
|
302
|
+
'\n',
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
|
|
306
|
+
|
|
307
|
+
expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'config.toml'))
|
|
308
|
+
})
|
|
232
309
|
})
|
|
233
310
|
|
|
234
311
|
/**
|
|
@@ -253,7 +330,7 @@ describe('a Codex install run inside a git worktree', () => {
|
|
|
253
330
|
it('targets the main repository, which is the file Codex actually reads', () => {
|
|
254
331
|
const { main, worktree } = repoWithWorktree()
|
|
255
332
|
|
|
256
|
-
expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', '
|
|
333
|
+
expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'config.toml'))
|
|
257
334
|
expect(codexProjectRoot(worktree)).toBe(main)
|
|
258
335
|
})
|
|
259
336
|
|
|
@@ -322,14 +399,14 @@ describe('a Codex install run inside a git worktree', () => {
|
|
|
322
399
|
// Orca points CODEX_HOME at a per-account home; writing to ~/.codex there
|
|
323
400
|
// produced hooks Codex never loaded, and nothing reported a problem.
|
|
324
401
|
expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '/managed/codex' })).toBe(
|
|
325
|
-
'/managed/codex/
|
|
402
|
+
'/managed/codex/config.toml',
|
|
326
403
|
)
|
|
327
404
|
expect(settingsFile('claude-code', true, '/repo', { CLAUDE_CONFIG_DIR: '/managed/claude' })).toBe(
|
|
328
405
|
'/managed/claude/settings.json',
|
|
329
406
|
)
|
|
330
407
|
// An empty value is not a relocation.
|
|
331
408
|
expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '' })).toBe(
|
|
332
|
-
path.join(os.homedir(), '.codex', '
|
|
409
|
+
path.join(os.homedir(), '.codex', 'config.toml'),
|
|
333
410
|
)
|
|
334
411
|
})
|
|
335
412
|
})
|
|
@@ -422,9 +499,96 @@ describe('writing the settings file', () => {
|
|
|
422
499
|
applyPlan(file, { hooks: {} })
|
|
423
500
|
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
424
501
|
})
|
|
502
|
+
|
|
503
|
+
it('merges into Codex config.toml without dropping trust state or foreign hooks', () => {
|
|
504
|
+
const dir = scratch()
|
|
505
|
+
const file = path.join(dir, 'config.toml')
|
|
506
|
+
writeFileSync(
|
|
507
|
+
file,
|
|
508
|
+
[
|
|
509
|
+
'model = "gpt-5.6"',
|
|
510
|
+
'',
|
|
511
|
+
'[features]',
|
|
512
|
+
'hooks = true',
|
|
513
|
+
'',
|
|
514
|
+
`[hooks.state."${file}:stop:0:0"]`,
|
|
515
|
+
'trusted_hash = "sha256:abc"',
|
|
516
|
+
'',
|
|
517
|
+
'[[hooks.Stop]]',
|
|
518
|
+
'',
|
|
519
|
+
'[[hooks.Stop.hooks]]',
|
|
520
|
+
'type = "command"',
|
|
521
|
+
'command = "gdh-stop"',
|
|
522
|
+
'',
|
|
523
|
+
].join('\n'),
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
const merged = mergeHooks(loadSettings(file), ours(), SCRIPT)
|
|
527
|
+
applyPlan(file, merged.document)
|
|
528
|
+
|
|
529
|
+
const text = readFileSync(file, 'utf8')
|
|
530
|
+
expect(text).toContain('model = "gpt-5.6"')
|
|
531
|
+
expect(text).toContain('trusted_hash = "sha256:abc"')
|
|
532
|
+
expect(text).toContain('gdh-stop')
|
|
533
|
+
expect(text).toContain('hook stop')
|
|
534
|
+
expect(text).toContain('[[hooks.UserPromptSubmit]]')
|
|
535
|
+
expect(text).toContain('[[hooks.SessionEnd]]')
|
|
536
|
+
expect(merged.document.hooks?.['Stop']?.flatMap((group) => group.hooks)).toHaveLength(2)
|
|
537
|
+
})
|
|
425
538
|
})
|
|
426
539
|
|
|
427
540
|
describe('finding what is installed', () => {
|
|
541
|
+
it('reports Notifai handlers written as inline Codex [hooks]', () => {
|
|
542
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-find-toml-'))
|
|
543
|
+
const file = path.join(cwd, '.codex', 'config.toml')
|
|
544
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
545
|
+
writeFileSync(
|
|
546
|
+
file,
|
|
547
|
+
[
|
|
548
|
+
'[[hooks.Stop]]',
|
|
549
|
+
'',
|
|
550
|
+
'[[hooks.Stop.hooks]]',
|
|
551
|
+
'type = "command"',
|
|
552
|
+
'command = "gdh-stop"',
|
|
553
|
+
'',
|
|
554
|
+
'[[hooks.Stop]]',
|
|
555
|
+
'',
|
|
556
|
+
'[[hooks.Stop.hooks]]',
|
|
557
|
+
'type = "command"',
|
|
558
|
+
`command = "${hookCommand(ADAPTER, 'stop', 'codex')}"`,
|
|
559
|
+
'',
|
|
560
|
+
].join('\n'),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
const found = findInstallations(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
|
|
564
|
+
const codex = found.find((installation) => installation.harness === 'codex' && !installation.global)
|
|
565
|
+
expect(codex?.file).toBe(file)
|
|
566
|
+
expect(codex?.handlers.map((handler) => handler.event)).toEqual(['Stop'])
|
|
567
|
+
expect(codex?.handlers[0]?.handlerIndex).toBe(0)
|
|
568
|
+
expect(codex?.handlers[0]?.groupIndex).toBe(1)
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('names a Codex layer that loads both representations', () => {
|
|
572
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-dual-rep-'))
|
|
573
|
+
const layer = path.join(cwd, '.codex')
|
|
574
|
+
mkdirSync(layer, { recursive: true })
|
|
575
|
+
applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
|
|
576
|
+
writeFileSync(
|
|
577
|
+
path.join(layer, 'config.toml'),
|
|
578
|
+
['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', 'command = "gdh-stop"', ''].join(
|
|
579
|
+
'\n',
|
|
580
|
+
),
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
const problems = codexRepresentationProblems(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
|
|
584
|
+
expect(problems).toHaveLength(1)
|
|
585
|
+
expect(problems[0]).toContain('loading hooks from both')
|
|
586
|
+
expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
|
|
587
|
+
expect(problems[0]).toContain(path.join(layer, 'config.toml'))
|
|
588
|
+
expect(problems[0]).toMatch(/prefer a single representation for this layer/)
|
|
589
|
+
expect(problems[0]).toMatch(/Stop/)
|
|
590
|
+
})
|
|
591
|
+
|
|
428
592
|
it('reports handlers from either harness with their positions', () => {
|
|
429
593
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-find-'))
|
|
430
594
|
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
@@ -666,11 +830,19 @@ describe('detectHarness', () => {
|
|
|
666
830
|
})
|
|
667
831
|
|
|
668
832
|
it('returns null when the project itself names two harnesses', () => {
|
|
669
|
-
// Genuine ambiguity
|
|
670
|
-
//
|
|
833
|
+
// Genuine ambiguity for a single-harness caller. detectedHarnesses lists
|
|
834
|
+
// both so install can wire them together.
|
|
671
835
|
expect(detectHarness(project('.claude', '.codex'))).toBeNull()
|
|
672
836
|
})
|
|
673
837
|
|
|
838
|
+
it('lists every project-named harness in declared order', () => {
|
|
839
|
+
const isolated = { HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-home-')) }
|
|
840
|
+
expect(detectedHarnesses(project('.codex', '.claude'), isolated)).toEqual([
|
|
841
|
+
'claude-code',
|
|
842
|
+
'codex',
|
|
843
|
+
])
|
|
844
|
+
})
|
|
845
|
+
|
|
674
846
|
it('prefers project evidence over anything installed on the machine', () => {
|
|
675
847
|
// The regression. Machine-global markers used to be OR-ed in per harness,
|
|
676
848
|
// so a developer with several agent tools installed got a candidate for
|
package/src/install-hooks.ts
CHANGED
|
@@ -3,12 +3,13 @@ import {
|
|
|
3
3
|
lstatSync,
|
|
4
4
|
readFileSync,
|
|
5
5
|
realpathSync,
|
|
6
|
+
rmSync,
|
|
6
7
|
statSync,
|
|
7
8
|
} from 'node:fs'
|
|
8
9
|
import { createHash } from 'node:crypto'
|
|
9
10
|
import os from 'node:os'
|
|
10
11
|
import path from 'node:path'
|
|
11
|
-
import { parse as parseToml } from 'smol-toml'
|
|
12
|
+
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
12
13
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
13
14
|
import { hookAdapterPath } from './hook-adapter.js'
|
|
14
15
|
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
|
|
@@ -30,10 +31,13 @@ const OPENCODE_EVENTS = [
|
|
|
30
31
|
/**
|
|
31
32
|
* Harness hook installation.
|
|
32
33
|
*
|
|
33
|
-
* Claude Code's `settings.json` and Codex's
|
|
34
|
+
* Claude Code's `settings.json` and Codex's hook files use the same shape —
|
|
34
35
|
* `hooks` maps an event name to matcher groups, each holding command handlers
|
|
35
36
|
* with an optional timeout — so one generator serves both. Only the file
|
|
36
|
-
* location and the event set differ.
|
|
37
|
+
* location and the event set differ. Codex still loads a leftover
|
|
38
|
+
* `hooks.json`, but that file is the legacy representation. We write inline
|
|
39
|
+
* `[hooks]` tables in the same layer's `config.toml`. One representation per
|
|
40
|
+
* layer, never both.
|
|
37
41
|
*
|
|
38
42
|
* Cursor's native format is flat and lower-camel-cased, while OpenCode's
|
|
39
43
|
* extension point is a JavaScript plugin module. Each therefore has a bounded
|
|
@@ -260,9 +264,7 @@ export function settingsFile(
|
|
|
260
264
|
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
|
|
261
265
|
: path.join(cwd, '.claude', 'settings.local.json')
|
|
262
266
|
case 'codex':
|
|
263
|
-
return global
|
|
264
|
-
? path.join(configHome(env, 'CODEX_HOME', '.codex'), 'hooks.json')
|
|
265
|
-
: path.join(codexProjectRoot(cwd), '.codex', 'hooks.json')
|
|
267
|
+
return inspectCodexLayer(codexLayerPaths(global, cwd, env)).writeTarget
|
|
266
268
|
default:
|
|
267
269
|
return assertNeverHarness(harness)
|
|
268
270
|
}
|
|
@@ -276,8 +278,8 @@ function assertNeverHarness(harness: never): never {
|
|
|
276
278
|
* Where Codex looks for a project's hooks — the **main** repository, not the
|
|
277
279
|
* working directory.
|
|
278
280
|
*
|
|
279
|
-
* Run from a linked git worktree, Codex reads `<main repo>/.codex/
|
|
280
|
-
* and never looks at the worktree's own. Proven 2026-08-03 in an isolated
|
|
281
|
+
* Run from a linked git worktree, Codex reads `<main repo>/.codex/config.toml`
|
|
282
|
+
* (or a leftover `hooks.json`) and never looks at the worktree's own. Proven 2026-08-03 in an isolated
|
|
281
283
|
* `CODEX_HOME`: with cwd set to a worktree, a handler at the main repo root
|
|
282
284
|
* fired and an identical one at the worktree root did not; removing the main
|
|
283
285
|
* one left nothing firing at all. Writing to cwd therefore produces a silent
|
|
@@ -307,7 +309,7 @@ export function codexProjectRoot(cwd: string): string {
|
|
|
307
309
|
*
|
|
308
310
|
* Codex splits the two halves of "which project am I in": it discovers the
|
|
309
311
|
* project layer by walking up from cwd for a `.codex` directory, but resolves
|
|
310
|
-
*
|
|
312
|
+
* the hook file inside that layer against the main repository. In an ordinary
|
|
311
313
|
* checkout both land on the same directory and the split is invisible. In a
|
|
312
314
|
* worktree they diverge, and writing only the main repository's file leaves it
|
|
313
315
|
* unread — Codex never looks, because nothing at or above cwd told it there was
|
|
@@ -363,6 +365,151 @@ export function configHome(env: NodeJS.ProcessEnv, variable: string, fallback: s
|
|
|
363
365
|
return override !== undefined && override !== '' ? override : path.join(os.homedir(), fallback)
|
|
364
366
|
}
|
|
365
367
|
|
|
368
|
+
export interface CodexLayerPaths {
|
|
369
|
+
dir: string
|
|
370
|
+
hooksJson: string
|
|
371
|
+
configToml: string
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The two files Codex will look at for one config layer. */
|
|
375
|
+
export function codexLayerPaths(
|
|
376
|
+
global: boolean,
|
|
377
|
+
cwd: string,
|
|
378
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
379
|
+
): CodexLayerPaths {
|
|
380
|
+
const dir = global
|
|
381
|
+
? configHome(env, 'CODEX_HOME', '.codex')
|
|
382
|
+
: path.join(codexProjectRoot(cwd), '.codex')
|
|
383
|
+
return {
|
|
384
|
+
dir,
|
|
385
|
+
hooksJson: path.join(dir, 'hooks.json'),
|
|
386
|
+
configToml: path.join(dir, 'config.toml'),
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface CodexLayerInspection {
|
|
391
|
+
paths: CodexLayerPaths
|
|
392
|
+
jsonEvents: string[]
|
|
393
|
+
tomlEvents: string[]
|
|
394
|
+
writeTarget: string
|
|
395
|
+
dual: boolean
|
|
396
|
+
overlappingEvents: string[]
|
|
397
|
+
canCollapse: boolean
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Which representation this Codex layer already uses.
|
|
402
|
+
*
|
|
403
|
+
* `hooks.json` is the legacy file. We write inline `[hooks]` in
|
|
404
|
+
* `config.toml` unless this layer already has someone else's hooks only in
|
|
405
|
+
* `hooks.json` — writing a second file would make Codex load both and run
|
|
406
|
+
* every matching handler. `[hooks.state]` is the trust store, not a hook
|
|
407
|
+
* definition, so it does not count as a representation.
|
|
408
|
+
*/
|
|
409
|
+
export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection {
|
|
410
|
+
const jsonDocument = tryLoadSettings(paths.hooksJson)
|
|
411
|
+
const tomlDocument = tryLoadSettings(paths.configToml)
|
|
412
|
+
const jsonEvents = hookEventNames(jsonDocument?.hooks)
|
|
413
|
+
const tomlEvents = hookEventNames(tomlDocument?.hooks)
|
|
414
|
+
const jsonForeign = documentHasForeignHandlers(jsonDocument)
|
|
415
|
+
const tomlForeign = documentHasForeignHandlers(tomlDocument)
|
|
416
|
+
const dual = jsonEvents.length > 0 && tomlEvents.length > 0
|
|
417
|
+
// Current representation. Stay on the legacy file only when that is
|
|
418
|
+
// already where someone else's hooks live and toml has none.
|
|
419
|
+
let writeTarget = paths.configToml
|
|
420
|
+
if (jsonEvents.length > 0 && tomlEvents.length === 0 && jsonForeign) {
|
|
421
|
+
writeTarget = paths.hooksJson
|
|
422
|
+
} else if (dual && jsonForeign && !tomlForeign) {
|
|
423
|
+
writeTarget = paths.hooksJson
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
paths,
|
|
427
|
+
jsonEvents,
|
|
428
|
+
tomlEvents,
|
|
429
|
+
writeTarget,
|
|
430
|
+
dual,
|
|
431
|
+
overlappingEvents: jsonEvents.filter((event) => tomlEvents.includes(event)),
|
|
432
|
+
canCollapse: dual && !(jsonForeign && tomlForeign),
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Every file in this layer Codex might already be reading hooks from. */
|
|
437
|
+
export function hookDefinitionFiles(
|
|
438
|
+
harness: Harness,
|
|
439
|
+
global: boolean,
|
|
440
|
+
cwd: string,
|
|
441
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
442
|
+
): string[] {
|
|
443
|
+
if (harness !== 'codex') return [settingsFile(harness, global, cwd, env)]
|
|
444
|
+
const paths = codexLayerPaths(global, cwd, env)
|
|
445
|
+
return [paths.hooksJson, paths.configToml]
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Drop the unused representation after writing the one this layer already had.
|
|
450
|
+
*
|
|
451
|
+
* Only safe when the discarded file has no one else's handlers. `hooks.json`
|
|
452
|
+
* is deleted once it has no events left so Codex stops warning; `config.toml`
|
|
453
|
+
* is never deleted.
|
|
454
|
+
*/
|
|
455
|
+
export function collapseUnusedCodexRepresentation(
|
|
456
|
+
keepFile: string,
|
|
457
|
+
scriptPath: string,
|
|
458
|
+
paths: CodexLayerPaths,
|
|
459
|
+
): string | null {
|
|
460
|
+
const other = keepFile === paths.hooksJson ? paths.configToml : paths.hooksJson
|
|
461
|
+
if (!existsSync(other)) return null
|
|
462
|
+
const document = tryLoadSettings(other)
|
|
463
|
+
if (document === null || documentHasForeignHandlers(document)) return null
|
|
464
|
+
const stripped = removeHooks(document, scriptPath)
|
|
465
|
+
if (other === paths.hooksJson && hookEventNames(stripped.document.hooks).length === 0) {
|
|
466
|
+
rmSync(other)
|
|
467
|
+
return other
|
|
468
|
+
}
|
|
469
|
+
applyPlan(other, stripped.document)
|
|
470
|
+
return other
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function codexRepresentationProblems(
|
|
474
|
+
cwd: string,
|
|
475
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
476
|
+
): string[] {
|
|
477
|
+
return [false, true].flatMap((global) => {
|
|
478
|
+
const layer = inspectCodexLayer(codexLayerPaths(global, cwd, env))
|
|
479
|
+
if (!layer.dual) return []
|
|
480
|
+
const overlap =
|
|
481
|
+
layer.overlappingEvents.length > 0
|
|
482
|
+
? ` Matching handlers from both files all run — this layer's ${layer.overlappingEvents.join(', ')} handlers will both fire.`
|
|
483
|
+
: ' Matching handlers from both files all run.'
|
|
484
|
+
const collapse = layer.canCollapse
|
|
485
|
+
? ` Run \`notifai hooks install --harness codex${global ? ' --global' : ''}\` to collapse onto ${path.basename(layer.writeTarget)}.`
|
|
486
|
+
: ' Both files have other hooks, so Notifai cannot collapse them.'
|
|
487
|
+
return [
|
|
488
|
+
`loading hooks from both ${layer.paths.hooksJson} and ${layer.paths.configToml}; prefer a single representation for this layer.${overlap}${collapse}`,
|
|
489
|
+
]
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function tryLoadSettings(file: string): SettingsDocument | null {
|
|
494
|
+
if (!existsSync(file)) return null
|
|
495
|
+
try {
|
|
496
|
+
return loadSettings(file)
|
|
497
|
+
} catch {
|
|
498
|
+
return null
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function hookEventNames(hooks: SettingsDocument['hooks']): string[] {
|
|
503
|
+
return Object.entries(hooks ?? {})
|
|
504
|
+
.filter(([, value]) => Array.isArray(value))
|
|
505
|
+
.map(([event]) => event)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function documentHasForeignHandlers(document: SettingsDocument | null): boolean {
|
|
509
|
+
if (document === null) return false
|
|
510
|
+
return locateAllHandlers(document).some((handler) => !isNotifaiCommand(handler.command))
|
|
511
|
+
}
|
|
512
|
+
|
|
366
513
|
/** Best-effort detection so `hooks install` usually needs no flags. */
|
|
367
514
|
/**
|
|
368
515
|
* Harnesses this *project* shows evidence of, in the working directory only.
|
|
@@ -389,8 +536,8 @@ function localHarnessEvidence(cwd: string): Harness[] {
|
|
|
389
536
|
}
|
|
390
537
|
|
|
391
538
|
/** Harnesses installed anywhere on this machine — a much weaker signal. */
|
|
392
|
-
function globalHarnessEvidence(): Harness[] {
|
|
393
|
-
const home = os.homedir()
|
|
539
|
+
function globalHarnessEvidence(env: NodeJS.ProcessEnv = process.env): Harness[] {
|
|
540
|
+
const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
|
|
394
541
|
const found: Harness[] = []
|
|
395
542
|
if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
|
|
396
543
|
if (existsSync(path.join(home, '.codex'))) found.push('codex')
|
|
@@ -400,30 +547,34 @@ function globalHarnessEvidence(): Harness[] {
|
|
|
400
547
|
}
|
|
401
548
|
|
|
402
549
|
/**
|
|
403
|
-
*
|
|
404
|
-
*
|
|
550
|
+
* Every supported harness this project or machine shows evidence of, in
|
|
551
|
+
* declared order. Project markers come first, then machine installs that the
|
|
552
|
+
* project did not already name.
|
|
405
553
|
*
|
|
406
|
-
*
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
554
|
+
* `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
|
|
555
|
+
*/
|
|
556
|
+
export function detectedHarnesses(cwd: string, env: NodeJS.ProcessEnv = process.env): Harness[] {
|
|
557
|
+
const seen = new Set<Harness>([...localHarnessEvidence(cwd), ...globalHarnessEvidence(env)])
|
|
558
|
+
return HARNESSES.filter((harness) => seen.has(harness))
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Which *single* harness to wire when a caller still needs exactly one, or
|
|
563
|
+
* null when it is genuinely unclear.
|
|
414
564
|
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
565
|
+
* Project evidence decides, and machine evidence is consulted only when the
|
|
566
|
+
* project offers none. Several markers are not a failure of detection — they
|
|
567
|
+
* are several harnesses. `detectedHarnesses` is the default for install;
|
|
568
|
+
* this helper stays for uninstall and any caller that cannot take a list.
|
|
418
569
|
*/
|
|
419
|
-
export function detectHarness(cwd: string): Harness | null {
|
|
570
|
+
export function detectHarness(cwd: string, env: NodeJS.ProcessEnv = process.env): Harness | null {
|
|
420
571
|
const local = localHarnessEvidence(cwd)
|
|
421
572
|
if (local.length > 0) return local.length === 1 ? local[0]! : null
|
|
422
|
-
const global = globalHarnessEvidence()
|
|
573
|
+
const global = globalHarnessEvidence(env)
|
|
423
574
|
return global.length === 1 ? global[0]! : null
|
|
424
575
|
}
|
|
425
576
|
|
|
426
|
-
interface SettingsDocument {
|
|
577
|
+
export interface SettingsDocument {
|
|
427
578
|
hooks?: HookConfig
|
|
428
579
|
[key: string]: unknown
|
|
429
580
|
}
|
|
@@ -571,6 +722,11 @@ export function mergeHooks(
|
|
|
571
722
|
const removed: string[] = []
|
|
572
723
|
|
|
573
724
|
for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
|
|
725
|
+
if (!Array.isArray(groups)) {
|
|
726
|
+
// Codex keeps `[hooks.state]` beside event tables. It is not a matcher list.
|
|
727
|
+
;(hooks as Record<string, unknown>)[event] = groups
|
|
728
|
+
continue
|
|
729
|
+
}
|
|
574
730
|
const { groups: foreign, removed: hadOurs } = withoutOurs(groups, scriptPath)
|
|
575
731
|
if (hadOurs) {
|
|
576
732
|
if (event in incoming) replaced.push(event)
|
|
@@ -591,6 +747,10 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
|
|
|
591
747
|
const hooks: HookConfig = {}
|
|
592
748
|
const replaced: string[] = []
|
|
593
749
|
for (const [event, groups] of Object.entries(existing.hooks ?? {})) {
|
|
750
|
+
if (!Array.isArray(groups)) {
|
|
751
|
+
;(hooks as Record<string, unknown>)[event] = groups
|
|
752
|
+
continue
|
|
753
|
+
}
|
|
594
754
|
const { groups: foreign, removed } = withoutOurs(groups, scriptPath)
|
|
595
755
|
if (removed) replaced.push(event)
|
|
596
756
|
if (foreign.length > 0) hooks[event] = foreign
|
|
@@ -609,13 +769,31 @@ export function removeHooks(existing: SettingsDocument, scriptPath: string): Mer
|
|
|
609
769
|
* support.
|
|
610
770
|
*/
|
|
611
771
|
export function applyPlan(file: string, document: SettingsDocument | CursorSettingsDocument): void {
|
|
612
|
-
|
|
772
|
+
const body = isTomlSettingsPath(file)
|
|
773
|
+
? `${stringifyToml(document)}\n`
|
|
774
|
+
: `${JSON.stringify(document, null, 2)}\n`
|
|
775
|
+
atomicWriteFileSync(file, body, {
|
|
613
776
|
requireCurrentUserOwner: true,
|
|
614
777
|
})
|
|
615
778
|
}
|
|
616
779
|
|
|
617
780
|
export function loadSettings(file: string): SettingsDocument {
|
|
618
|
-
return readSettings(file)
|
|
781
|
+
return isTomlSettingsPath(file) ? readTomlSettings(file) : readSettings(file)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function isTomlSettingsPath(file: string): boolean {
|
|
785
|
+
return file.endsWith('.toml')
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function readTomlSettings(file: string): SettingsDocument {
|
|
789
|
+
if (!existsSync(file)) return {}
|
|
790
|
+
const source = readOwnedRegularFile(file)
|
|
791
|
+
try {
|
|
792
|
+
const parsed: unknown = parseToml(source)
|
|
793
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as SettingsDocument) : {}
|
|
794
|
+
} catch {
|
|
795
|
+
throw new Error(`Could not parse ${file}; fix or move it before installing hooks.`)
|
|
796
|
+
}
|
|
619
797
|
}
|
|
620
798
|
|
|
621
799
|
export function loadCursorSettings(file: string): CursorSettingsDocument {
|
|
@@ -766,10 +944,6 @@ function isNotifaiCommand(command: string): boolean {
|
|
|
766
944
|
return / hook (user-prompt-submit|stop|session-end)\b/.test(command)
|
|
767
945
|
}
|
|
768
946
|
|
|
769
|
-
function isNotifaiHandler(handler: HookHandler): boolean {
|
|
770
|
-
return isNotifaiCommand(handler.command)
|
|
771
|
-
}
|
|
772
|
-
|
|
773
947
|
/** Every place either harness would read a Notifai handler from. */
|
|
774
948
|
export function findInstallations(
|
|
775
949
|
cwd: string,
|
|
@@ -779,7 +953,8 @@ export function findInstallations(
|
|
|
779
953
|
const found: Installation[] = []
|
|
780
954
|
for (const harness of HARNESSES) {
|
|
781
955
|
for (const global of [false, true]) {
|
|
782
|
-
const
|
|
956
|
+
const files = hookDefinitionFiles(harness, global, cwd, env)
|
|
957
|
+
for (const file of files) {
|
|
783
958
|
if (!existsSync(file)) continue
|
|
784
959
|
// OpenCode's adapter is a plugin module, not a settings document, so it
|
|
785
960
|
// is reported as one installation covering all three events rather than
|
|
@@ -837,7 +1012,7 @@ export function findInstallations(
|
|
|
837
1012
|
}
|
|
838
1013
|
let document: SettingsDocument
|
|
839
1014
|
try {
|
|
840
|
-
document =
|
|
1015
|
+
document = loadSettings(file)
|
|
841
1016
|
} catch {
|
|
842
1017
|
continue
|
|
843
1018
|
}
|
|
@@ -846,6 +1021,7 @@ export function findInstallations(
|
|
|
846
1021
|
const problems = harnessMarkerProblems(harness, handlers, adapterHome)
|
|
847
1022
|
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
|
|
848
1023
|
}
|
|
1024
|
+
}
|
|
849
1025
|
}
|
|
850
1026
|
}
|
|
851
1027
|
return found
|
|
@@ -889,28 +1065,32 @@ function locateCursorHandlers(document: CursorSettingsDocument): InstalledHandle
|
|
|
889
1065
|
return handlers
|
|
890
1066
|
}
|
|
891
1067
|
|
|
892
|
-
function
|
|
1068
|
+
function locateAllHandlers(document: SettingsDocument): InstalledHandler[] {
|
|
893
1069
|
const handlers: InstalledHandler[] = []
|
|
894
1070
|
for (const [event, groups] of Object.entries(document.hooks ?? {})) {
|
|
1071
|
+
if (!Array.isArray(groups)) continue
|
|
895
1072
|
groups.forEach((group, groupIndex) => {
|
|
896
1073
|
group.hooks?.forEach((handler, handlerIndex) => {
|
|
897
|
-
if (
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
}
|
|
1074
|
+
if (typeof handler?.command !== 'string') return
|
|
1075
|
+
handlers.push({
|
|
1076
|
+
event,
|
|
1077
|
+
groupIndex,
|
|
1078
|
+
handlerIndex,
|
|
1079
|
+
command: handler.command,
|
|
1080
|
+
...(handler.timeout === undefined ? {} : { timeout: handler.timeout }),
|
|
1081
|
+
...(handler.async === undefined ? {} : { async: handler.async }),
|
|
1082
|
+
...(handler.statusMessage === undefined ? {} : { statusMessage: handler.statusMessage }),
|
|
1083
|
+
})
|
|
908
1084
|
})
|
|
909
1085
|
})
|
|
910
1086
|
}
|
|
911
1087
|
return handlers
|
|
912
1088
|
}
|
|
913
1089
|
|
|
1090
|
+
function locateHandlers(document: SettingsDocument): InstalledHandler[] {
|
|
1091
|
+
return locateAllHandlers(document).filter((handler) => isNotifaiCommand(handler.command))
|
|
1092
|
+
}
|
|
1093
|
+
|
|
914
1094
|
/** The hook event a handler's command actually invokes, e.g. `stop`. */
|
|
915
1095
|
export function handlerEvent(command: string): string | null {
|
|
916
1096
|
return / hook ([a-z-]+)/.exec(command)?.[1] ?? null
|