@raidiant/notifai 0.5.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +165 -0
- package/README.md +11 -4
- package/dist/claude-wake.d.ts +101 -0
- package/dist/claude-wake.js +397 -0
- package/dist/claude-wake.js.map +1 -0
- package/dist/codex-wake.d.ts +84 -0
- package/dist/codex-wake.js +238 -0
- package/dist/codex-wake.js.map +1 -0
- package/dist/commands.d.ts +31 -11
- package/dist/commands.js +679 -232
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +3 -3
- package/dist/config-schema.js +11 -49
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +23 -45
- package/dist/config.js +61 -39
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +4 -13
- package/dist/harnesses.js +12 -17
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +10 -1
- package/dist/hook-adapter.js +79 -10
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +119 -65
- package/dist/hooks.js +330 -285
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +76 -24
- package/dist/install-hooks.js +301 -125
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +32 -1
- package/dist/interactive.js +165 -51
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +14 -21
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +1 -1
- package/dist/native-skills.js +23 -58
- package/dist/native-skills.js.map +1 -1
- package/dist/pack-feedback-slice.d.ts +35 -0
- package/dist/pack-feedback-slice.js +126 -0
- package/dist/pack-feedback-slice.js.map +1 -0
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +24 -0
- package/dist/readiness.js.map +1 -1
- package/dist/release.d.ts +31 -0
- package/dist/release.js +52 -0
- package/dist/release.js.map +1 -0
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +11 -10
- package/src/commands.test.ts +386 -36
- package/src/commands.ts +473 -182
- package/src/config-schema.ts +2 -17
- package/src/config.test.ts +29 -4
- package/src/config.ts +67 -11
- package/src/harnesses.ts +7 -0
- package/src/hook-adapter.test.ts +43 -1
- package/src/hook-adapter.ts +83 -11
- package/src/hooks.test.ts +4 -5
- package/src/install-hooks.test.ts +154 -2
- package/src/install-hooks.ts +231 -44
- package/src/interactive.test.ts +338 -0
- package/src/interactive.ts +221 -49
- package/src/main.ts +14 -20
- package/src/native-skills.test.ts +75 -0
- package/src/native-skills.ts +31 -75
- package/src/pack-feedback-slice.test.ts +212 -0
- package/src/pack-feedback-slice.ts +187 -0
- package/src/readiness.test.ts +25 -0
- package/src/readiness.ts +38 -0
- package/src/release.ts +51 -0
- package/src/skill.test.ts +43 -0
- package/src/ui/config-view.test.ts +5 -4
- package/dist/idle.d.ts +0 -16
- package/dist/idle.js +0 -44
- package/dist/idle.js.map +0 -1
package/src/config-schema.ts
CHANGED
|
@@ -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'
|
|
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)
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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(
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
/**
|
|
247
|
-
|
|
248
|
-
|
|
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:
|
|
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'
|
package/src/hook-adapter.test.ts
CHANGED
|
@@ -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
|
-
|
|
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', () => {
|
package/src/hook-adapter.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
137
|
-
if (
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
|
670
|
-
//
|
|
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
|