@raidiant/notifai 0.5.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +11 -4
  3. package/dist/commands.d.ts +16 -1
  4. package/dist/commands.js +371 -157
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.d.ts +1 -1
  7. package/dist/config-schema.js +2 -14
  8. package/dist/config-schema.js.map +1 -1
  9. package/dist/config.d.ts +19 -8
  10. package/dist/config.js +50 -7
  11. package/dist/config.js.map +1 -1
  12. package/dist/harnesses.d.ts +1 -0
  13. package/dist/harnesses.js +6 -0
  14. package/dist/harnesses.js.map +1 -1
  15. package/dist/hook-adapter.d.ts +10 -1
  16. package/dist/hook-adapter.js +79 -10
  17. package/dist/hook-adapter.js.map +1 -1
  18. package/dist/install-hooks.d.ts +54 -17
  19. package/dist/install-hooks.js +258 -97
  20. package/dist/install-hooks.js.map +1 -1
  21. package/dist/interactive.d.ts +32 -1
  22. package/dist/interactive.js +163 -47
  23. package/dist/interactive.js.map +1 -1
  24. package/dist/main.js +8 -8
  25. package/dist/main.js.map +1 -1
  26. package/dist/native-skills.d.ts +1 -1
  27. package/dist/native-skills.js +23 -58
  28. package/dist/native-skills.js.map +1 -1
  29. package/dist/pack-feedback-slice.d.ts +35 -0
  30. package/dist/pack-feedback-slice.js +126 -0
  31. package/dist/pack-feedback-slice.js.map +1 -0
  32. package/dist/readiness.d.ts +20 -0
  33. package/dist/readiness.js +24 -0
  34. package/dist/readiness.js.map +1 -1
  35. package/package.json +3 -2
  36. package/src/commands.test.ts +364 -31
  37. package/src/commands.ts +450 -177
  38. package/src/config-schema.ts +2 -17
  39. package/src/config.test.ts +29 -4
  40. package/src/config.ts +67 -11
  41. package/src/harnesses.ts +7 -0
  42. package/src/hook-adapter.test.ts +43 -1
  43. package/src/hook-adapter.ts +83 -11
  44. package/src/hooks.test.ts +4 -5
  45. package/src/install-hooks.test.ts +154 -2
  46. package/src/install-hooks.ts +231 -44
  47. package/src/interactive.test.ts +338 -0
  48. package/src/interactive.ts +221 -49
  49. package/src/main.ts +8 -8
  50. package/src/native-skills.test.ts +75 -0
  51. package/src/native-skills.ts +31 -75
  52. package/src/pack-feedback-slice.test.ts +212 -0
  53. package/src/pack-feedback-slice.ts +187 -0
  54. package/src/readiness.test.ts +25 -0
  55. package/src/readiness.ts +38 -0
  56. package/src/skill.test.ts +43 -0
  57. package/src/ui/config-view.test.ts +5 -4
@@ -25,7 +25,7 @@ export type ConfigKind = 'string' | 'url' | 'integer' | 'boolean' | 'enum' | 'li
25
25
  * Which surface a key belongs to. Grouping is the difference between a flat
26
26
  * list of keys and a few short ones a reader can hold in their head.
27
27
  */
28
- export type ConfigGroup = 'questions' | 'delivery' | 'project' | 'diagnostics' | 'connection'
28
+ export type ConfigGroup = 'questions' | 'delivery' | 'project' | 'diagnostics'
29
29
 
30
30
  export const CONFIG_GROUPS: { id: ConfigGroup; title: string; blurb: string }[] = [
31
31
  {
@@ -48,11 +48,6 @@ export const CONFIG_GROUPS: { id: ConfigGroup; title: string; blurb: string }[]
48
48
  title: 'Local logs',
49
49
  blurb: 'What this machine records about its own activity, so an agent can find out afterwards what happened.',
50
50
  },
51
- {
52
- id: 'connection',
53
- title: 'Connection',
54
- blurb: 'Which Notifai service this machine talks to. Most people never change this.',
55
- },
56
51
  ]
57
52
 
58
53
  export interface ConfigKeyInfo {
@@ -234,17 +229,6 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
234
229
  advanced: true,
235
230
  example: '3',
236
231
  },
237
-
238
- base_url: {
239
- label: 'Service URL',
240
- group: 'connection',
241
- kind: 'url',
242
- summary: 'Which Notifai service this machine talks to',
243
- detail:
244
- 'Points the CLI at a Notifai service. The default is the hosted service, and signing in stores the URL the credential belongs to, so this rarely needs setting by hand.\n\nChanging it does not move your credential: a URL that does not match the one you paired against will not authenticate.',
245
- advanced: true,
246
- example: 'https://notifai.fly.dev',
247
- },
248
232
  }
249
233
 
250
234
  export function configInfo(key: ConfigKey): ConfigKeyInfo {
@@ -309,6 +293,7 @@ export function formatValue(key: ConfigKey, value: unknown): string {
309
293
  export function describeSource(source: string): { label: string; path: string | null } {
310
294
  if (source === 'default') return { label: 'default', path: null }
311
295
  if (source === 'flag') return { label: 'this command', path: null }
296
+ if (source === 'env') return { label: 'environment', path: null }
312
297
  const separator = source.indexOf(':')
313
298
  if (separator === -1) return { label: source, path: null }
314
299
  const layer = source.slice(0, separator)
@@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { afterAll, describe, expect, it } from 'vitest'
5
- import { loadConfig, sessionConfigPath } from './config.js'
5
+ import { loadConfig, personalProjectConfigPath, sessionConfigPath } from './config.js'
6
6
  import { buildDraft } from './send.js'
7
7
 
8
8
  const tmp = mkdtempSync(path.join(os.tmpdir(), 'notifai-cli-'))
@@ -29,8 +29,9 @@ function setup(options: {
29
29
  writeFileSync(path.join(projectDir, 'config.toml'), options.projectToml)
30
30
  }
31
31
  if (options.projectLocalToml !== undefined) {
32
- mkdirSync(projectDir, { recursive: true })
33
- writeFileSync(path.join(projectDir, 'config.local.toml'), options.projectLocalToml)
32
+ const localFile = personalProjectConfigPath(project, { XDG_CONFIG_HOME: home })
33
+ mkdirSync(path.dirname(localFile), { recursive: true })
34
+ writeFileSync(localFile, options.projectLocalToml)
34
35
  }
35
36
  const state = path.join(home, 'state')
36
37
  const env = { XDG_CONFIG_HOME: home, XDG_STATE_HOME: state } as NodeJS.ProcessEnv
@@ -70,7 +71,31 @@ describe('harness config layers', () => {
70
71
  })
71
72
  const config = loadConfig({ cwd, env })
72
73
  expect(config.ask_notifications.value).toBe(false)
73
- expect(config.ask_notifications.source).toMatch(/config\.local\.toml$/)
74
+ expect(config.ask_notifications.source).toMatch(/\/projects\/[0-9a-f]{32}\.toml$/)
75
+ })
76
+
77
+ it('ignores an in-tree config.local.toml leftover', () => {
78
+ const { env, cwd } = setup({ projectToml: 'ask_notifications = true\n' })
79
+ mkdirSync(path.join(cwd, '..', '.notifai'), { recursive: true })
80
+ writeFileSync(path.join(cwd, '..', '.notifai', 'config.local.toml'), 'ask_notifications = false\n')
81
+ expect(loadConfig({ cwd, env }).ask_notifications).toEqual({ value: true, source: expect.stringMatching(/^project:/) })
82
+ })
83
+
84
+ it('takes the service origin from NOTIFAI_BASE_URL, not from a config file', () => {
85
+ const { env, cwd } = setup({
86
+ projectToml: 'base_url = "https://attacker.example"\n',
87
+ })
88
+ expect(loadConfig({ cwd, env }).base_url).toEqual({
89
+ value: 'https://notifai.fly.dev',
90
+ source: 'default',
91
+ })
92
+ expect(
93
+ loadConfig({ cwd, env: { ...env, NOTIFAI_BASE_URL: 'https://selfhost.example' } }).base_url,
94
+ ).toEqual({ value: 'https://selfhost.example', source: 'env' })
95
+ expect(loadConfig({ cwd, env, flags: { base_url: 'https://flag.example' } }).base_url).toEqual({
96
+ value: 'https://flag.example',
97
+ source: 'flag',
98
+ })
74
99
  })
75
100
 
76
101
  it('keeps hostile or colliding session ids inside their own file', () => {
package/src/config.ts CHANGED
@@ -15,16 +15,16 @@ import type { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol
15
15
  * than in the agent's context, because context is lost to compaction and a
16
16
  * preference that evaporates mid-session is worse than none.
17
17
  *
18
- * `project-local` is `.notifai/config.local.toml`, intended to be gitignored.
19
- * `.notifai/config.toml` is intended for committed, shared configuration, so it
20
- * must not be where a personal "route my questions to my devices" preference
21
- * lands. The CLI does not edit a repository's ignore rules or create commits.
18
+ * `project-local` is a personal file under the user's config directory, keyed
19
+ * by the project root so a checkout does not have to gitignore anything. Shared
20
+ * `.notifai/config.toml` stays in the tree for committed project identity.
22
21
  *
23
22
  * `config show --explain` surfaces the winning layer per key.
24
23
  */
25
24
 
26
25
  export type ConfigSource =
27
26
  | 'flag'
27
+ | 'env'
28
28
  | `session:${string}`
29
29
  | `project-local:${string}`
30
30
  | `project:${string}`
@@ -92,7 +92,6 @@ export interface CliConfig {
92
92
  }
93
93
 
94
94
  export const CONFIG_KEYS = [
95
- 'base_url',
96
95
  'wait_seconds',
97
96
  'ttl_seconds',
98
97
  'collapse_key',
@@ -132,8 +131,10 @@ export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> =
132
131
  log_level: LOG_LEVELS,
133
132
  }
134
133
 
134
+ /** Compiled service origin. Not a user-facing setting; override with `--base-url` or `NOTIFAI_BASE_URL`. */
135
+ export const DEFAULT_BASE_URL = 'https://notifai.fly.dev'
136
+
135
137
  const DEFAULTS = {
136
- base_url: 'https://notifai.fly.dev',
137
138
  wait_seconds: 10,
138
139
  ttl_seconds: 86400,
139
140
  collapse_key: null,
@@ -243,9 +244,46 @@ export function findProjectConfigPath(startDir: string): string | null {
243
244
  return findProjectFile(startDir, 'config.toml')
244
245
  }
245
246
 
246
- /** Personal sibling of the shared project config; callers should keep it gitignored. */
247
- export function findProjectLocalConfigPath(startDir: string): string | null {
248
- return findProjectFile(startDir, 'config.local.toml')
247
+ /** Walk up from cwd looking for a `.git` file or directory (worktree or checkout). */
248
+ function findGitRoot(startDir: string): string | null {
249
+ let dir = path.resolve(startDir)
250
+ for (;;) {
251
+ if (existsSync(path.join(dir, '.git'))) return canonicalDir(dir)
252
+ const parent = path.dirname(dir)
253
+ if (parent === dir) return null
254
+ dir = parent
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Stable identity for personal project preferences.
260
+ *
261
+ * Prefer the directory that owns the shared `.notifai/config.toml`, then the
262
+ * git checkout root, then cwd. Subdirectories of one project therefore share
263
+ * one personal file, and nothing is written inside the repository.
264
+ */
265
+ export function personalProjectIdentity(cwd: string): string {
266
+ const projectConfig = findProjectConfigPath(cwd)
267
+ const root =
268
+ projectConfig !== null ? path.dirname(path.dirname(projectConfig)) : (findGitRoot(cwd) ?? cwd)
269
+ return createHash('sha256').update(canonicalDir(root)).digest('hex').slice(0, 32)
270
+ }
271
+
272
+ /** Personal project layer: `$XDG_CONFIG_HOME/notifai/projects/<identity>.toml`. */
273
+ export function personalProjectConfigPath(
274
+ cwd: string,
275
+ env: NodeJS.ProcessEnv = process.env,
276
+ ): string {
277
+ return path.join(globalConfigDir(env), 'projects', `${personalProjectIdentity(cwd)}.toml`)
278
+ }
279
+
280
+ /** Existing personal project file, or null when this checkout has none. */
281
+ export function findProjectLocalConfigPath(
282
+ startDir: string,
283
+ env: NodeJS.ProcessEnv = process.env,
284
+ ): string | null {
285
+ const file = personalProjectConfigPath(startDir, env)
286
+ return existsSync(file) ? file : null
249
287
  }
250
288
 
251
289
  /**
@@ -306,6 +344,24 @@ export interface FlagOverrides {
306
344
  interruption_level?: CliInterruptionLevel
307
345
  }
308
346
 
347
+ /**
348
+ * Service origin is not a user setting. Flag and env are developer/self-host
349
+ * overrides; signed-in traffic still pins to the credential origin elsewhere.
350
+ */
351
+ function resolveServiceUrl(
352
+ flags: FlagOverrides,
353
+ env: NodeJS.ProcessEnv,
354
+ ): ResolvedValue<string> {
355
+ if (typeof flags.base_url === 'string' && flags.base_url !== '') {
356
+ return { value: flags.base_url, source: 'flag' }
357
+ }
358
+ const fromEnv = env['NOTIFAI_BASE_URL']
359
+ if (typeof fromEnv === 'string' && fromEnv !== '') {
360
+ return { value: fromEnv, source: 'env' }
361
+ }
362
+ return { value: DEFAULT_BASE_URL, source: 'default' }
363
+ }
364
+
309
365
  export function loadConfig(options: {
310
366
  cwd?: string
311
367
  env?: NodeJS.ProcessEnv
@@ -320,7 +376,7 @@ export function loadConfig(options: {
320
376
  const cwd = options.cwd ?? process.cwd()
321
377
  const projectPath = findProjectConfigPath(cwd)
322
378
  const projectRaw = projectPath ? readTomlFile(projectPath) : {}
323
- const projectLocalPath = findProjectLocalConfigPath(cwd)
379
+ const projectLocalPath = findProjectLocalConfigPath(cwd, env)
324
380
  const projectLocalRaw = projectLocalPath ? readTomlFile(projectLocalPath) : {}
325
381
  const sessionId = options.sessionId ?? env['NOTIFAI_SESSION']
326
382
  const sessionPath = sessionId ? sessionConfigPath(sessionId, env) : null
@@ -344,7 +400,7 @@ export function loadConfig(options: {
344
400
  }
345
401
 
346
402
  return {
347
- base_url: resolve('base_url'),
403
+ base_url: resolveServiceUrl(flags, env),
348
404
  wait_seconds: resolve('wait_seconds'),
349
405
  ttl_seconds: resolve('ttl_seconds'),
350
406
  collapse_key: resolve('collapse_key'),
package/src/harnesses.ts CHANGED
@@ -3,6 +3,13 @@ export const HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
3
3
 
4
4
  export type Harness = (typeof HARNESSES)[number]
5
5
 
6
+ export const HARNESS_LABELS: Record<Harness, string> = {
7
+ 'claude-code': 'Claude Code',
8
+ codex: 'Codex',
9
+ cursor: 'Cursor',
10
+ opencode: 'OpenCode',
11
+ }
12
+
6
13
  export type DeliveryRoute =
7
14
  | 'hook-continuation'
8
15
  | 'inbox-socket'
@@ -16,6 +16,7 @@ import {
16
16
  hookAdapterPath,
17
17
  inspectHookAdapter,
18
18
  installHookAdapter,
19
+ isNpxAdapterTarget,
19
20
  } from './hook-adapter.js'
20
21
  import { buildHookConfig, codexHookIdentityHash, type InstalledHandler } from './install-hooks.js'
21
22
 
@@ -76,7 +77,48 @@ describe('stable hook adapter', () => {
76
77
  expect(after).toEqual(before)
77
78
  expect(afterSource).not.toBe(beforeSource)
78
79
  expect(codexHookIdentityHash(stop(after))).toBe(codexHookIdentityHash(stop(before)))
79
- expect(inspectHookAdapter(homeDir).target?.scriptPath).toBe(second)
80
+ const inspected = inspectHookAdapter(homeDir).target
81
+ expect(inspected && !isNpxAdapterTarget(inspected) ? inspected.scriptPath : null).toBe(second)
82
+ })
83
+
84
+ it('executes a pinned npx spec through npm-cli.js without evaluating it', () => {
85
+ const { root, homeDir } = isolated()
86
+ const npmCli = path.join(root, 'npm-cli.js')
87
+ writeFileSync(
88
+ npmCli,
89
+ 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n',
90
+ )
91
+ const installed = installHookAdapter(
92
+ {
93
+ kind: 'npx',
94
+ execPath: process.execPath,
95
+ npmCli,
96
+ spec: '@raidiant/notifai@0.5.1',
97
+ },
98
+ homeDir,
99
+ )
100
+ const run = spawnSync(installed.path, ['hook', 'stop', '--owner', 'notifai'], {
101
+ encoding: 'utf8',
102
+ })
103
+
104
+ expect(run.status).toBe(0)
105
+ expect(JSON.parse(run.stdout)).toEqual([
106
+ 'exec',
107
+ '--yes',
108
+ '--package',
109
+ '@raidiant/notifai@0.5.1',
110
+ '--',
111
+ 'notifai',
112
+ 'hook',
113
+ 'stop',
114
+ '--owner',
115
+ 'notifai',
116
+ ])
117
+ const inspected = inspectHookAdapter(homeDir)
118
+ expect(inspected.problems).toEqual([])
119
+ expect(inspected.target && isNpxAdapterTarget(inspected.target) ? inspected.target.spec : null).toBe(
120
+ '@raidiant/notifai@0.5.1',
121
+ )
80
122
  })
81
123
 
82
124
  it('uses a fixed home-relative pathname with no XDG input surface', () => {
@@ -14,11 +14,25 @@ import { withTargetFileLock } from './file-lock.js'
14
14
  const ADAPTER_MARKER = '# notifai managed hook adapter'
15
15
  const ADAPTER_VERSION = 1
16
16
 
17
- export interface HookAdapterTarget {
17
+ export interface HookAdapterFileTarget {
18
+ kind?: 'file'
18
19
  execPath: string
19
20
  scriptPath: string
20
21
  }
21
22
 
23
+ export interface HookAdapterNpxTarget {
24
+ kind: 'npx'
25
+ execPath: string
26
+ npmCli: string
27
+ spec: string
28
+ }
29
+
30
+ export type HookAdapterTarget = HookAdapterFileTarget | HookAdapterNpxTarget
31
+
32
+ export function isNpxAdapterTarget(target: HookAdapterTarget): target is HookAdapterNpxTarget {
33
+ return target.kind === 'npx'
34
+ }
35
+
22
36
  export interface HookAdapterInspection {
23
37
  path: string
24
38
  target: HookAdapterTarget | null
@@ -73,6 +87,20 @@ function assertUsableTarget(target: HookAdapterTarget): void {
73
87
  if (!exec.isFile() || (exec.mode & 0o111) === 0) {
74
88
  throw new Error(`Hook adapter runtime ${target.execPath} is not executable.`)
75
89
  }
90
+ if (isNpxAdapterTarget(target)) {
91
+ if (target.spec.trim() === '') {
92
+ throw new Error('Hook adapter npx spec is empty.')
93
+ }
94
+ try {
95
+ if (!statSync(target.npmCli).isFile()) {
96
+ throw new Error(`Hook adapter npm CLI ${target.npmCli} is not a regular file.`)
97
+ }
98
+ } catch (err) {
99
+ if (err instanceof Error && err.message.startsWith('Hook adapter npm CLI ')) throw err
100
+ throw new Error(`Hook adapter npm CLI ${target.npmCli} does not exist.`)
101
+ }
102
+ return
103
+ }
76
104
  try {
77
105
  if (!statSync(target.scriptPath).isFile()) {
78
106
  throw new Error(`Hook adapter CLI ${target.scriptPath} is not a regular file.`)
@@ -133,18 +161,51 @@ export function inspectHookAdapter(
133
161
  } catch {
134
162
  problems.push(`registered runtime ${target.execPath} is missing`)
135
163
  }
136
- try {
137
- if (!statSync(target.scriptPath).isFile()) {
138
- problems.push(`registered CLI ${target.scriptPath} is not a regular file`)
164
+ if (isNpxAdapterTarget(target)) {
165
+ if (target.spec.trim() === '') problems.push('registered npx spec is empty')
166
+ try {
167
+ if (!statSync(target.npmCli).isFile()) {
168
+ problems.push(`registered npm CLI ${target.npmCli} is not a regular file`)
169
+ }
170
+ } catch {
171
+ problems.push(`registered npm CLI ${target.npmCli} is missing`)
172
+ }
173
+ } else {
174
+ try {
175
+ if (!statSync(target.scriptPath).isFile()) {
176
+ problems.push(`registered CLI ${target.scriptPath} is not a regular file`)
177
+ }
178
+ } catch {
179
+ problems.push(`registered CLI ${target.scriptPath} is missing`)
139
180
  }
140
- } catch {
141
- problems.push(`registered CLI ${target.scriptPath} is missing`)
142
181
  }
143
182
  }
144
183
  return { path: file, target, problems }
145
184
  }
146
185
 
147
186
  function hookAdapterSource(target: HookAdapterTarget): string {
187
+ if (isNpxAdapterTarget(target)) {
188
+ return `#!/bin/sh
189
+ ${ADAPTER_MARKER}
190
+ # adapter-version: ${ADAPTER_VERSION}
191
+ # target-kind: npx
192
+ # target-exec-json: ${JSON.stringify(target.execPath)}
193
+ # target-npm-cli-json: ${JSON.stringify(target.npmCli)}
194
+ # target-spec-json: ${JSON.stringify(target.spec)}
195
+ set -eu
196
+
197
+ registered_exec=${quote(target.execPath)}
198
+ registered_npm_cli=${quote(target.npmCli)}
199
+ registered_spec=${quote(target.spec)}
200
+
201
+ if [ -x "$registered_exec" ] && [ -f "$registered_npm_cli" ]; then
202
+ exec "$registered_exec" "$registered_npm_cli" exec --yes --package "$registered_spec" -- notifai "$@"
203
+ fi
204
+
205
+ printf '%s\n' 'Notifai hook adapter target is stale; run notifai hooks install.' >&2
206
+ exit 127
207
+ `
208
+ }
148
209
  return `#!/bin/sh
149
210
  ${ADAPTER_MARKER}
150
211
  # adapter-version: ${ADAPTER_VERSION}
@@ -165,15 +226,26 @@ exit 127
165
226
  }
166
227
 
167
228
  function parseTarget(contents: string): HookAdapterTarget | null {
229
+ const kind = /^# target-kind: (.+)$/m.exec(contents)?.[1]
168
230
  const exec = /^# target-exec-json: (.+)$/m.exec(contents)?.[1]
169
- const script = /^# target-script-json: (.+)$/m.exec(contents)?.[1]
170
- if (exec === undefined || script === undefined) return null
231
+ if (exec === undefined) return null
171
232
  try {
172
233
  const execPath: unknown = JSON.parse(exec)
234
+ if (typeof execPath !== 'string') return null
235
+ if (kind === 'npx') {
236
+ const npmCliRaw = /^# target-npm-cli-json: (.+)$/m.exec(contents)?.[1]
237
+ const specRaw = /^# target-spec-json: (.+)$/m.exec(contents)?.[1]
238
+ if (npmCliRaw === undefined || specRaw === undefined) return null
239
+ const npmCli: unknown = JSON.parse(npmCliRaw)
240
+ const spec: unknown = JSON.parse(specRaw)
241
+ return typeof npmCli === 'string' && typeof spec === 'string'
242
+ ? { kind: 'npx', execPath, npmCli, spec }
243
+ : null
244
+ }
245
+ const script = /^# target-script-json: (.+)$/m.exec(contents)?.[1]
246
+ if (script === undefined) return null
173
247
  const scriptPath: unknown = JSON.parse(script)
174
- return typeof execPath === 'string' && typeof scriptPath === 'string'
175
- ? { execPath, scriptPath }
176
- : null
248
+ return typeof scriptPath === 'string' ? { execPath, scriptPath } : null
177
249
  } catch {
178
250
  return null
179
251
  }
package/src/hooks.test.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  import { EXIT, askCommand, buildQuestions, hookRunCommand, type CommandDeps, type CommandIo } from './commands.js'
36
36
  import {
37
37
  loadConfig,
38
+ personalProjectConfigPath,
38
39
  projectSessionPointerPath,
39
40
  sanitizeSessionId,
40
41
  sessionConfigPath,
@@ -770,11 +771,9 @@ describe('config resolution inside a hook', () => {
770
771
  const h = harness([reply({ choice_id: 'allow' })])
771
772
  // A project that has turned the feature off.
772
773
  const project = mkdtempSync(path.join(os.tmpdir(), 'notifai-proj-'))
773
- mkdirSync(path.join(project, '.notifai'), { recursive: true })
774
- writeFileSync(
775
- path.join(project, '.notifai', 'config.local.toml'),
776
- 'ask_notifications = false\n',
777
- )
774
+ const localFile = personalProjectConfigPath(project, h.env)
775
+ mkdirSync(path.dirname(localFile), { recursive: true })
776
+ writeFileSync(localFile, 'ask_notifications = false\n')
778
777
  writeSessionState('c1', h.env, { last_prompt_at: AWAY })
779
778
  registerQuestion('c1', h.env, { question: 'Ship it?' })
780
779
 
@@ -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,
@@ -229,6 +232,60 @@ describe('settings locations', () => {
229
232
  path.join(os.homedir(), '.codex', 'hooks.json'),
230
233
  )
231
234
  })
235
+
236
+ it('writes Codex hooks into an existing inline [hooks] instead of inventing hooks.json', () => {
237
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-toml-layer-'))
238
+ mkdirSync(path.join(repo, '.codex'), { recursive: true })
239
+ writeFileSync(
240
+ path.join(repo, '.codex', 'config.toml'),
241
+ [
242
+ 'model = "gpt-5.6"',
243
+ '',
244
+ '[[hooks.PostToolUse]]',
245
+ 'matcher = "Bash"',
246
+ '',
247
+ '[[hooks.PostToolUse.hooks]]',
248
+ 'type = "command"',
249
+ 'command = "gdh-guard"',
250
+ '',
251
+ '[[hooks.Stop]]',
252
+ '',
253
+ '[[hooks.Stop.hooks]]',
254
+ 'type = "command"',
255
+ 'command = "gdh-stop"',
256
+ '',
257
+ ].join('\n'),
258
+ )
259
+
260
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
261
+ })
262
+
263
+ it('does not treat the Codex trust store as an inline hook representation', () => {
264
+ const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-only-'))
265
+ writeFileSync(
266
+ path.join(home, 'config.toml'),
267
+ `[hooks.state."${home}/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
268
+ )
269
+
270
+ expect(settingsFile('codex', true, '/repo', { CODEX_HOME: home })).toBe(
271
+ path.join(home, 'hooks.json'),
272
+ )
273
+ })
274
+
275
+ it('keeps writing to inline [hooks] when a leftover hooks.json only holds Notifai', () => {
276
+ const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-both-'))
277
+ const layer = path.join(repo, '.codex')
278
+ mkdirSync(layer, { recursive: true })
279
+ writeFileSync(
280
+ path.join(layer, 'config.toml'),
281
+ ['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', 'command = "gdh-stop"', ''].join(
282
+ '\n',
283
+ ),
284
+ )
285
+ applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
286
+
287
+ expect(settingsFile('codex', false, repo, {})).toBe(path.join(layer, 'config.toml'))
288
+ })
232
289
  })
233
290
 
234
291
  /**
@@ -422,9 +479,96 @@ describe('writing the settings file', () => {
422
479
  applyPlan(file, { hooks: {} })
423
480
  expect(statSync(file).mode & 0o777).toBe(0o600)
424
481
  })
482
+
483
+ it('merges into Codex config.toml without dropping trust state or foreign hooks', () => {
484
+ const dir = scratch()
485
+ const file = path.join(dir, 'config.toml')
486
+ writeFileSync(
487
+ file,
488
+ [
489
+ 'model = "gpt-5.6"',
490
+ '',
491
+ '[features]',
492
+ 'hooks = true',
493
+ '',
494
+ `[hooks.state."${file}:stop:0:0"]`,
495
+ 'trusted_hash = "sha256:abc"',
496
+ '',
497
+ '[[hooks.Stop]]',
498
+ '',
499
+ '[[hooks.Stop.hooks]]',
500
+ 'type = "command"',
501
+ 'command = "gdh-stop"',
502
+ '',
503
+ ].join('\n'),
504
+ )
505
+
506
+ const merged = mergeHooks(loadSettings(file), ours(), SCRIPT)
507
+ applyPlan(file, merged.document)
508
+
509
+ const text = readFileSync(file, 'utf8')
510
+ expect(text).toContain('model = "gpt-5.6"')
511
+ expect(text).toContain('trusted_hash = "sha256:abc"')
512
+ expect(text).toContain('gdh-stop')
513
+ expect(text).toContain('hook stop')
514
+ expect(text).toContain('[[hooks.UserPromptSubmit]]')
515
+ expect(text).toContain('[[hooks.SessionEnd]]')
516
+ expect(merged.document.hooks?.['Stop']?.flatMap((group) => group.hooks)).toHaveLength(2)
517
+ })
425
518
  })
426
519
 
427
520
  describe('finding what is installed', () => {
521
+ it('reports Notifai handlers written as inline Codex [hooks]', () => {
522
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-find-toml-'))
523
+ const file = path.join(cwd, '.codex', 'config.toml')
524
+ mkdirSync(path.dirname(file), { recursive: true })
525
+ writeFileSync(
526
+ file,
527
+ [
528
+ '[[hooks.Stop]]',
529
+ '',
530
+ '[[hooks.Stop.hooks]]',
531
+ 'type = "command"',
532
+ 'command = "gdh-stop"',
533
+ '',
534
+ '[[hooks.Stop]]',
535
+ '',
536
+ '[[hooks.Stop.hooks]]',
537
+ 'type = "command"',
538
+ `command = "${hookCommand(ADAPTER, 'stop', 'codex')}"`,
539
+ '',
540
+ ].join('\n'),
541
+ )
542
+
543
+ const found = findInstallations(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
544
+ const codex = found.find((installation) => installation.harness === 'codex' && !installation.global)
545
+ expect(codex?.file).toBe(file)
546
+ expect(codex?.handlers.map((handler) => handler.event)).toEqual(['Stop'])
547
+ expect(codex?.handlers[0]?.handlerIndex).toBe(0)
548
+ expect(codex?.handlers[0]?.groupIndex).toBe(1)
549
+ })
550
+
551
+ it('names a Codex layer that loads both representations', () => {
552
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-dual-rep-'))
553
+ const layer = path.join(cwd, '.codex')
554
+ mkdirSync(layer, { recursive: true })
555
+ applyPlan(path.join(layer, 'hooks.json'), { hooks: ours() })
556
+ writeFileSync(
557
+ path.join(layer, 'config.toml'),
558
+ ['[[hooks.Stop]]', '', '[[hooks.Stop.hooks]]', 'type = "command"', 'command = "gdh-stop"', ''].join(
559
+ '\n',
560
+ ),
561
+ )
562
+
563
+ const problems = codexRepresentationProblems(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
564
+ expect(problems).toHaveLength(1)
565
+ expect(problems[0]).toContain('loading hooks from both')
566
+ expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
567
+ expect(problems[0]).toContain(path.join(layer, 'config.toml'))
568
+ expect(problems[0]).toMatch(/prefer a single representation for this layer/)
569
+ expect(problems[0]).toMatch(/Stop/)
570
+ })
571
+
428
572
  it('reports handlers from either harness with their positions', () => {
429
573
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-find-'))
430
574
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
@@ -666,11 +810,19 @@ describe('detectHarness', () => {
666
810
  })
667
811
 
668
812
  it('returns null when the project itself names two harnesses', () => {
669
- // Genuine ambiguity, and the caller must ask rather than pick. Not the
670
- // same as having no evidence.
813
+ // Genuine ambiguity for a single-harness caller. detectedHarnesses lists
814
+ // both so install can wire them together.
671
815
  expect(detectHarness(project('.claude', '.codex'))).toBeNull()
672
816
  })
673
817
 
818
+ it('lists every project-named harness in declared order', () => {
819
+ const isolated = { HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-home-')) }
820
+ expect(detectedHarnesses(project('.codex', '.claude'), isolated)).toEqual([
821
+ 'claude-code',
822
+ 'codex',
823
+ ])
824
+ })
825
+
674
826
  it('prefers project evidence over anything installed on the machine', () => {
675
827
  // The regression. Machine-global markers used to be OR-ed in per harness,
676
828
  // so a developer with several agent tools installed got a candidate for