@raidiant/notifai 0.1.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/LICENSE +202 -0
- package/README.md +76 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +118 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +225 -0
- package/dist/commands.js +1855 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +224 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +31 -0
- package/dist/credentials.js +94 -0
- package/dist/credentials.js.map +1 -0
- package/dist/hooks.d.ts +250 -0
- package/dist/hooks.js +792 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idle.d.ts +16 -0
- package/dist/idle.js +44 -0
- package/dist/idle.js.map +1 -0
- package/dist/install-hooks.d.ts +194 -0
- package/dist/install-hooks.js +579 -0
- package/dist/install-hooks.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +259 -0
- package/dist/main.js.map +1 -0
- package/dist/opencode-plugin.d.ts +65 -0
- package/dist/opencode-plugin.js +181 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/readiness.d.ts +87 -0
- package/dist/readiness.js +40 -0
- package/dist/readiness.js.map +1 -0
- package/dist/send.d.ts +86 -0
- package/dist/send.js +202 -0
- package/dist/send.js.map +1 -0
- package/package.json +56 -0
- package/src/client.test.ts +83 -0
- package/src/client.ts +183 -0
- package/src/commands.test.ts +1199 -0
- package/src/commands.ts +2201 -0
- package/src/config.test.ts +243 -0
- package/src/config.ts +347 -0
- package/src/credentials.ts +119 -0
- package/src/hooks.test.ts +1030 -0
- package/src/hooks.ts +1099 -0
- package/src/idle.test.ts +32 -0
- package/src/idle.ts +41 -0
- package/src/install-hooks.test.ts +500 -0
- package/src/install-hooks.ts +708 -0
- package/src/main.ts +315 -0
- package/src/opencode-plugin.ts +203 -0
- package/src/readiness.ts +88 -0
- package/src/send.ts +272 -0
- package/tsconfig.json +10 -0
package/src/idle.test.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { parseIdleSeconds, readIdleSeconds } from './idle.js'
|
|
3
|
+
|
|
4
|
+
describe('OS idle probe', () => {
|
|
5
|
+
it('reads HIDIdleTime as nanoseconds', () => {
|
|
6
|
+
// Verbatim shape of `ioreg -c IOHIDSystem -r -k HIDIdleTime`.
|
|
7
|
+
const output = `+-o IOHIDSystem <class IOHIDSystem, id 0x100000abc>
|
|
8
|
+
{
|
|
9
|
+
"HIDIdleTime" = 151627250
|
|
10
|
+
"HIDPointerAcceleration" = 6144
|
|
11
|
+
}
|
|
12
|
+
`
|
|
13
|
+
expect(parseIdleSeconds(output)).toBeCloseTo(0.15, 2)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('reads a long idle period', () => {
|
|
17
|
+
expect(parseIdleSeconds('"HIDIdleTime" = 900000000000')).toBe(900)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns null rather than a wrong number when the key is absent or junk', () => {
|
|
21
|
+
// null degrades to the elapsed-time signal; a bogus 0 would instead pin the
|
|
22
|
+
// user "present" forever and silently disable escalation altogether.
|
|
23
|
+
expect(parseIdleSeconds('')).toBeNull()
|
|
24
|
+
expect(parseIdleSeconds('"HIDPointerAcceleration" = 6144')).toBeNull()
|
|
25
|
+
expect(parseIdleSeconds('"HIDIdleTime" = <ptr>')).toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('reports no signal on platforms without one, instead of throwing', () => {
|
|
29
|
+
expect(readIdleSeconds('linux')).toBeNull()
|
|
30
|
+
expect(readIdleSeconds('win32')).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
})
|
package/src/idle.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Seconds since the user last touched the keyboard or mouse, or null when this
|
|
5
|
+
* machine cannot say.
|
|
6
|
+
*
|
|
7
|
+
* The hooks need to know whether the user is at their desk, and the only signal
|
|
8
|
+
* they had was silence since the last prompt — which includes the agent's own
|
|
9
|
+
* turn, so a user watching a long build was indistinguishable from a user who
|
|
10
|
+
* had left. This is the corroborating signal.
|
|
11
|
+
*
|
|
12
|
+
* null is a first-class answer, not a failure: on a machine with no idle source
|
|
13
|
+
* the caller keeps its previous behaviour rather than guessing. That is why
|
|
14
|
+
* every error path here returns null instead of throwing.
|
|
15
|
+
*/
|
|
16
|
+
export function readIdleSeconds(platform: NodeJS.Platform = process.platform): number | null {
|
|
17
|
+
if (platform !== 'darwin') return null
|
|
18
|
+
try {
|
|
19
|
+
// -r -k narrows the dump to the one node carrying the key (~5KB), so this
|
|
20
|
+
// stays a ~20ms call on the hot path of every Stop hook.
|
|
21
|
+
const out = execFileSync('ioreg', ['-c', 'IOHIDSystem', '-r', '-k', 'HIDIdleTime'], {
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
timeout: 2000,
|
|
24
|
+
maxBuffer: 1_000_000,
|
|
25
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
26
|
+
})
|
|
27
|
+
return parseIdleSeconds(out)
|
|
28
|
+
} catch {
|
|
29
|
+
// Missing binary, sandbox denial, timeout — all mean "no signal".
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Exported for tests: HIDIdleTime is nanoseconds since the last HID event. */
|
|
35
|
+
export function parseIdleSeconds(ioregOutput: string): number | null {
|
|
36
|
+
const match = /"HIDIdleTime"\s*=\s*(\d+)/.exec(ioregOutput)
|
|
37
|
+
if (match?.[1] === undefined) return null
|
|
38
|
+
const nanos = Number(match[1])
|
|
39
|
+
if (!Number.isFinite(nanos) || nanos < 0) return null
|
|
40
|
+
return nanos / 1e9
|
|
41
|
+
}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lstatSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
symlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { describe, expect, it } from 'vitest'
|
|
14
|
+
import { parseChoices } from './send.js'
|
|
15
|
+
import {
|
|
16
|
+
OPENCODE_PLUGIN_MARKER,
|
|
17
|
+
isOurOpencodePlugin,
|
|
18
|
+
opencodePluginPath,
|
|
19
|
+
opencodePluginSource,
|
|
20
|
+
opencodePluginTarget,
|
|
21
|
+
} from './opencode-plugin.js'
|
|
22
|
+
import {
|
|
23
|
+
HARNESSES,
|
|
24
|
+
applyPlan,
|
|
25
|
+
buildHookConfig,
|
|
26
|
+
codexLayerDir,
|
|
27
|
+
codexProjectRoot,
|
|
28
|
+
findInstallations,
|
|
29
|
+
handlerEvent,
|
|
30
|
+
hookCommand,
|
|
31
|
+
mergeHooks,
|
|
32
|
+
removeHooks,
|
|
33
|
+
settingsFile,
|
|
34
|
+
type HookConfig,
|
|
35
|
+
type SettingsDocument,
|
|
36
|
+
} from './install-hooks.js'
|
|
37
|
+
|
|
38
|
+
const SCRIPT = '/opt/notifai/dist/main.js'
|
|
39
|
+
const EXEC = '/usr/local/bin/node'
|
|
40
|
+
|
|
41
|
+
function ours(): HookConfig {
|
|
42
|
+
return buildHookConfig({ execPath: EXEC, scriptPath: SCRIPT, replyTimeoutSeconds: 240, graceSeconds: 0 })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('choice labels', () => {
|
|
46
|
+
it('splits a single value on commas for the one-liner case', () => {
|
|
47
|
+
expect(parseChoices('Staging,Production')).toEqual([
|
|
48
|
+
{ id: 'staging', label: 'Staging' },
|
|
49
|
+
{ id: 'production', label: 'Production' },
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('keeps commas inside a label when the flag is repeated', () => {
|
|
54
|
+
// Observed live 2026-08-02: "Yes, it worked,No, something broke" passed as
|
|
55
|
+
// one value silently became four choices, and 2-6 validation let it
|
|
56
|
+
// through because four is in range. Repeating the flag is the escape.
|
|
57
|
+
expect(parseChoices(['Yes, it worked', 'No, something broke'])).toEqual([
|
|
58
|
+
{ id: 'yes-it-worked', label: 'Yes, it worked' },
|
|
59
|
+
{ id: 'no-something-broke', label: 'No, something broke' },
|
|
60
|
+
])
|
|
61
|
+
expect(parseChoices('Yes, it worked,No, something broke')).toHaveLength(4)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('rejects a set that cannot become a valid closed question', () => {
|
|
65
|
+
// A pipe-separated string is one label, not two — the shape that reached
|
|
66
|
+
// the hook and failed there instead of at registration.
|
|
67
|
+
expect(parseChoices('Yes|No')).toBe('invalid')
|
|
68
|
+
expect(parseChoices(['Only one'])).toBe('invalid')
|
|
69
|
+
expect(parseChoices(['A', 'a'])).toBe('invalid')
|
|
70
|
+
expect(parseChoices(['1', '2', '3', '4', '5', '6', '7'])).toBe('invalid')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('treats an unused repeatable flag as no choices at all', () => {
|
|
74
|
+
expect(parseChoices([])).toBeNull()
|
|
75
|
+
expect(parseChoices(undefined)).toBeNull()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('hook config', () => {
|
|
80
|
+
it('invokes the CLI by absolute path so a hook shell without our PATH still works', () => {
|
|
81
|
+
expect(hookCommand(EXEC, SCRIPT, 'stop')).toBe(`'${EXEC}' '${SCRIPT}' hook stop --owner notifai`)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('single-quotes so a path cannot become a command', () => {
|
|
85
|
+
// Double quotes still expand $() and backticks, so a checkout path with
|
|
86
|
+
// shell syntax was execution on every hook event.
|
|
87
|
+
const cmd = hookCommand('/usr/bin/node', '/tmp/$(touch /tmp/pwned)/main.js', 'stop')
|
|
88
|
+
expect(cmd).toBe("'/usr/bin/node' '/tmp/$(touch /tmp/pwned)/main.js' hook stop --owner notifai")
|
|
89
|
+
expect(hookCommand('/usr/bin/node', "/it's/here.js", 'stop')).toContain(`'/it'\\''s/here.js'`)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('gives blocking hooks headroom over our own wait, and SessionEnd almost none', () => {
|
|
93
|
+
const config = buildHookConfig({ execPath: EXEC, scriptPath: SCRIPT, replyTimeoutSeconds: 240, graceSeconds: 0 })
|
|
94
|
+
expect(config['Stop']?.[0]?.hooks[0]?.timeout).toBe(300)
|
|
95
|
+
// Claude Code caps UserPromptSubmit at 30s; Codex gives SessionEnd 1-3s.
|
|
96
|
+
expect(config['UserPromptSubmit']?.[0]?.hooks[0]?.timeout).toBe(15)
|
|
97
|
+
expect(config['SessionEnd']?.[0]?.hooks[0]?.timeout).toBe(3)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('merging into existing settings', () => {
|
|
102
|
+
it('leaves unrelated settings and other people’s hooks untouched', () => {
|
|
103
|
+
const existing = {
|
|
104
|
+
permissions: { allow: ['Bash(git *)'] },
|
|
105
|
+
hooks: {
|
|
106
|
+
Stop: [{ hooks: [{ type: 'command' as const, command: 'make lint' }] }],
|
|
107
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command' as const, command: 'guard.sh' }] }],
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
const merged = mergeHooks(existing, ours(), SCRIPT)
|
|
111
|
+
|
|
112
|
+
expect(merged.document['permissions']).toEqual({ allow: ['Bash(git *)'] })
|
|
113
|
+
expect(merged.document.hooks?.['PreToolUse']).toEqual(existing.hooks.PreToolUse)
|
|
114
|
+
const stop = merged.document.hooks?.['Stop'] ?? []
|
|
115
|
+
expect(stop[0]?.hooks[0]?.command).toBe('make lint')
|
|
116
|
+
expect(stop[1]?.hooks[0]?.command).toContain('hook stop')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('replaces our own groups on reinstall instead of duplicating them', () => {
|
|
120
|
+
const once = mergeHooks({}, ours(), SCRIPT)
|
|
121
|
+
const twice = mergeHooks(
|
|
122
|
+
once.document,
|
|
123
|
+
buildHookConfig({ execPath: EXEC, scriptPath: SCRIPT, replyTimeoutSeconds: 60, graceSeconds: 0 }),
|
|
124
|
+
SCRIPT,
|
|
125
|
+
)
|
|
126
|
+
expect(twice.document.hooks?.['Stop']).toHaveLength(1)
|
|
127
|
+
expect(twice.document.hooks?.['Stop']?.[0]?.hooks[0]?.timeout).toBe(120)
|
|
128
|
+
expect(twice.replaced).toContain('Stop')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('keeps a user handler that shares a matcher group with ours', () => {
|
|
132
|
+
const installed = mergeHooks({}, ours(), SCRIPT)
|
|
133
|
+
const mixed = installed.document.hooks!['Stop']!
|
|
134
|
+
mixed[0]!.hooks.push({ type: 'command', command: 'make lint' })
|
|
135
|
+
|
|
136
|
+
const stripped = removeHooks(installed.document, SCRIPT)
|
|
137
|
+
expect(stripped.document.hooks?.['Stop']).toEqual([
|
|
138
|
+
{ hooks: [{ type: 'command', command: 'make lint' }] },
|
|
139
|
+
])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('uninstall removes only our handlers', () => {
|
|
143
|
+
const installed = mergeHooks(
|
|
144
|
+
{ hooks: { Stop: [{ hooks: [{ type: 'command' as const, command: 'make lint' }] }] } },
|
|
145
|
+
ours(),
|
|
146
|
+
SCRIPT,
|
|
147
|
+
)
|
|
148
|
+
const stripped = removeHooks(installed.document, SCRIPT)
|
|
149
|
+
expect(stripped.document.hooks?.['Stop']).toEqual([
|
|
150
|
+
{ hooks: [{ type: 'command', command: 'make lint' }] },
|
|
151
|
+
])
|
|
152
|
+
expect(stripped.document.hooks?.['UserPromptSubmit']).toBeUndefined()
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('settings locations', () => {
|
|
157
|
+
it('writes a project install to the gitignored file, not the shared one', () => {
|
|
158
|
+
expect(settingsFile('claude-code', false, '/repo', {})).toBe('/repo/.claude/settings.local.json')
|
|
159
|
+
expect(settingsFile('claude-code', true, '/repo', {})).toBe(
|
|
160
|
+
path.join(os.homedir(), '.claude', 'settings.json'),
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('uses hooks.json for Codex', () => {
|
|
165
|
+
expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/hooks.json')
|
|
166
|
+
expect(settingsFile('codex', true, '/repo', {})).toBe(
|
|
167
|
+
path.join(os.homedir(), '.codex', 'hooks.json'),
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Codex resolves project hooks against the main repository, so an install run
|
|
174
|
+
* inside a worktree used to write a file Codex never reads — and reported
|
|
175
|
+
* success. Proven against the real binary 2026-08-03.
|
|
176
|
+
*/
|
|
177
|
+
describe('a Codex install run inside a git worktree', () => {
|
|
178
|
+
/** The on-disk shape `git worktree add` produces, verified against real git. */
|
|
179
|
+
function repoWithWorktree(): { main: string; worktree: string } {
|
|
180
|
+
const base = mkdtempSync(path.join(os.tmpdir(), 'notifai-wt-'))
|
|
181
|
+
const main = path.join(base, 'main')
|
|
182
|
+
const worktree = path.join(base, 'wt')
|
|
183
|
+
const gitDir = path.join(main, '.git', 'worktrees', 'wt')
|
|
184
|
+
mkdirSync(gitDir, { recursive: true })
|
|
185
|
+
mkdirSync(worktree, { recursive: true })
|
|
186
|
+
writeFileSync(path.join(gitDir, 'commondir'), '../..\n')
|
|
187
|
+
writeFileSync(path.join(worktree, '.git'), `gitdir: ${gitDir}\n`)
|
|
188
|
+
return { main, worktree }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it('targets the main repository, which is the file Codex actually reads', () => {
|
|
192
|
+
const { main, worktree } = repoWithWorktree()
|
|
193
|
+
|
|
194
|
+
expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'hooks.json'))
|
|
195
|
+
expect(codexProjectRoot(worktree)).toBe(main)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('resolves the same way from a subdirectory of the worktree', () => {
|
|
199
|
+
const { main, worktree } = repoWithWorktree()
|
|
200
|
+
const nested = path.join(worktree, 'apps', 'cli')
|
|
201
|
+
mkdirSync(nested, { recursive: true })
|
|
202
|
+
|
|
203
|
+
expect(codexProjectRoot(nested)).toBe(main)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('leaves an ordinary checkout alone', () => {
|
|
207
|
+
const base = mkdtempSync(path.join(os.tmpdir(), 'notifai-repo-'))
|
|
208
|
+
mkdirSync(path.join(base, '.git'), { recursive: true })
|
|
209
|
+
const nested = path.join(base, 'packages', 'core')
|
|
210
|
+
mkdirSync(nested, { recursive: true })
|
|
211
|
+
|
|
212
|
+
expect(codexProjectRoot(base)).toBe(base)
|
|
213
|
+
expect(codexProjectRoot(nested)).toBe(base)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('falls back to the working directory outside a repository', () => {
|
|
217
|
+
// Not every project is a git checkout, and an install there should still
|
|
218
|
+
// land somewhere predictable rather than walking off to the filesystem root.
|
|
219
|
+
const base = mkdtempSync(path.join(os.tmpdir(), 'notifai-bare-'))
|
|
220
|
+
|
|
221
|
+
expect(codexProjectRoot(base)).toBe(base)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('names the directory Codex needs before it will look for project hooks', () => {
|
|
225
|
+
// Writing the main repository's file is not enough: Codex only loads the
|
|
226
|
+
// project layer when a `.codex` directory sits at or above cwd, and in a
|
|
227
|
+
// worktree there is nothing to find. An empty directory was the difference
|
|
228
|
+
// between the handler running and nothing running.
|
|
229
|
+
const { worktree } = repoWithWorktree()
|
|
230
|
+
|
|
231
|
+
expect(codexLayerDir(worktree)).toBe(path.join(worktree, '.codex'))
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('anchors that directory at the worktree root so subdirectories are covered', () => {
|
|
235
|
+
const { worktree } = repoWithWorktree()
|
|
236
|
+
const nested = path.join(worktree, 'apps', 'cli')
|
|
237
|
+
mkdirSync(nested, { recursive: true })
|
|
238
|
+
|
|
239
|
+
// Codex walks up from cwd looking for it, so one at the root serves every
|
|
240
|
+
// directory an agent might run from.
|
|
241
|
+
expect(codexLayerDir(nested)).toBe(path.join(worktree, '.codex'))
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('asks for nothing extra in an ordinary checkout, where both halves agree', () => {
|
|
245
|
+
const base = mkdtempSync(path.join(os.tmpdir(), 'notifai-plain-'))
|
|
246
|
+
mkdirSync(path.join(base, '.git'), { recursive: true })
|
|
247
|
+
|
|
248
|
+
expect(codexLayerDir(base)).toBeNull()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('does not redirect Claude Code, which reads the worktree it runs in', () => {
|
|
252
|
+
const { worktree } = repoWithWorktree()
|
|
253
|
+
|
|
254
|
+
expect(settingsFile('claude-code', false, worktree, {})).toBe(
|
|
255
|
+
path.join(worktree, '.claude', 'settings.local.json'),
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('follows a relocated config home rather than installing where nothing reads', () => {
|
|
260
|
+
// Orca points CODEX_HOME at a per-account home; writing to ~/.codex there
|
|
261
|
+
// produced hooks Codex never loaded, and nothing reported a problem.
|
|
262
|
+
expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '/managed/codex' })).toBe(
|
|
263
|
+
'/managed/codex/hooks.json',
|
|
264
|
+
)
|
|
265
|
+
expect(settingsFile('claude-code', true, '/repo', { CLAUDE_CONFIG_DIR: '/managed/claude' })).toBe(
|
|
266
|
+
'/managed/claude/settings.json',
|
|
267
|
+
)
|
|
268
|
+
// An empty value is not a relocation.
|
|
269
|
+
expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '' })).toBe(
|
|
270
|
+
path.join(os.homedir(), '.codex', 'hooks.json'),
|
|
271
|
+
)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe('upgrading past a dropped event', () => {
|
|
276
|
+
it('removes a handler for an event this build no longer serves', () => {
|
|
277
|
+
// Found live in this repo: settings.local.json still ran
|
|
278
|
+
// `main.js hook permission-request`, written by a build that had such an
|
|
279
|
+
// event. The current binary exits 2 with "Unknown hook event" every time
|
|
280
|
+
// the harness fires it, and reinstalling never cleaned it up because the
|
|
281
|
+
// merge only touched events present in the incoming config.
|
|
282
|
+
const stale = {
|
|
283
|
+
hooks: {
|
|
284
|
+
PermissionRequest: [
|
|
285
|
+
{ hooks: [{ type: 'command' as const, command: hookCommand(EXEC, SCRIPT, 'permission-request') }] },
|
|
286
|
+
],
|
|
287
|
+
Stop: [{ hooks: [{ type: 'command' as const, command: hookCommand(EXEC, SCRIPT, 'stop') }] }],
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const merged = mergeHooks(stale, ours(), SCRIPT)
|
|
292
|
+
|
|
293
|
+
expect(merged.document.hooks?.['PermissionRequest']).toBeUndefined()
|
|
294
|
+
expect(merged.removed).toEqual(['PermissionRequest'])
|
|
295
|
+
expect(merged.replaced).toContain('Stop')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('keeps someone else’s handler for that same dropped event', () => {
|
|
299
|
+
const mixed = {
|
|
300
|
+
hooks: {
|
|
301
|
+
PermissionRequest: [
|
|
302
|
+
{
|
|
303
|
+
hooks: [
|
|
304
|
+
{ type: 'command' as const, command: hookCommand(EXEC, SCRIPT, 'permission-request') },
|
|
305
|
+
{ type: 'command' as const, command: 'their-own-tool --check' },
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const merged = mergeHooks(mixed, ours(), SCRIPT)
|
|
313
|
+
|
|
314
|
+
expect(merged.document.hooks?.['PermissionRequest']?.[0]?.hooks).toEqual([
|
|
315
|
+
{ type: 'command', command: 'their-own-tool --check' },
|
|
316
|
+
])
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('does not accumulate duplicates when run twice', () => {
|
|
320
|
+
const once = mergeHooks({}, ours(), SCRIPT)
|
|
321
|
+
const twice = mergeHooks(once.document, ours(), SCRIPT)
|
|
322
|
+
expect(twice.document.hooks?.['Stop']?.[0]?.hooks).toHaveLength(1)
|
|
323
|
+
expect(twice.removed).toEqual([])
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe('writing the settings file', () => {
|
|
328
|
+
function scratch(): string {
|
|
329
|
+
return mkdtempSync(path.join(os.tmpdir(), 'notifai-apply-'))
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
it('refuses to write through a symlink', () => {
|
|
333
|
+
const dir = scratch()
|
|
334
|
+
const real = path.join(dir, 'somewhere-else.json')
|
|
335
|
+
const link = path.join(dir, 'settings.json')
|
|
336
|
+
writeFileSync(real, '{"untouched":true}\n')
|
|
337
|
+
symlinkSync(real, link)
|
|
338
|
+
|
|
339
|
+
expect(() => applyPlan(link, { hooks: {} })).toThrow(/symlink/)
|
|
340
|
+
// The point of refusing: the target must be exactly as it was.
|
|
341
|
+
expect(readFileSync(real, 'utf8')).toBe('{"untouched":true}\n')
|
|
342
|
+
expect(lstatSync(link).isSymbolicLink()).toBe(true)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('preserves an existing file’s mode and leaves no temp file behind', () => {
|
|
346
|
+
const dir = scratch()
|
|
347
|
+
const file = path.join(dir, 'settings.json')
|
|
348
|
+
writeFileSync(file, '{}\n', { mode: 0o644 })
|
|
349
|
+
|
|
350
|
+
applyPlan(file, { hooks: ours() })
|
|
351
|
+
|
|
352
|
+
expect(statSync(file).mode & 0o777).toBe(0o644)
|
|
353
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toHaveProperty('hooks.Stop')
|
|
354
|
+
expect(readdirSync(dir)).toEqual(['settings.json'])
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('keeps a file it creates private, since settings can carry tokens', () => {
|
|
358
|
+
const dir = scratch()
|
|
359
|
+
const file = path.join(dir, 'nested', 'settings.json')
|
|
360
|
+
applyPlan(file, { hooks: {} })
|
|
361
|
+
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
describe('finding what is installed', () => {
|
|
366
|
+
it('reports handlers from either harness with their positions', () => {
|
|
367
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-find-'))
|
|
368
|
+
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
369
|
+
applyPlan(path.join(cwd, '.claude', 'settings.local.json'), { hooks: ours() })
|
|
370
|
+
|
|
371
|
+
const found = findInstallations(cwd, { CODEX_HOME: path.join(cwd, 'no-codex-here') })
|
|
372
|
+
const claude = found.find((i) => i.harness === 'claude-code' && !i.global)
|
|
373
|
+
|
|
374
|
+
expect(claude).toBeDefined()
|
|
375
|
+
expect(claude?.handlers.map((h) => h.event).sort()).toEqual([
|
|
376
|
+
'SessionEnd',
|
|
377
|
+
'Stop',
|
|
378
|
+
'UserPromptSubmit',
|
|
379
|
+
])
|
|
380
|
+
expect(claude?.handlers.every((h) => h.groupIndex === 0 && h.handlerIndex === 0)).toBe(true)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('reads the event a handler actually invokes, not the key it sits under', () => {
|
|
384
|
+
// These diverge exactly when an upgrade drops an event, which is the case
|
|
385
|
+
// worth detecting.
|
|
386
|
+
expect(handlerEvent(hookCommand(EXEC, SCRIPT, 'permission-request'))).toBe('permission-request')
|
|
387
|
+
expect(handlerEvent('unrelated --tool')).toBeNull()
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Ownership was matched on the absolute script path, so a second
|
|
394
|
+
* checkout did not recognise the first one's handlers as ours: both stayed,
|
|
395
|
+
* the harness ran both, and one question produced two notifications.
|
|
396
|
+
*/
|
|
397
|
+
describe('two checkouts', () => {
|
|
398
|
+
const OTHER = '/Users/rafael/other-checkout/apps/cli/dist/main.js'
|
|
399
|
+
|
|
400
|
+
function documentWith(command: string): SettingsDocument {
|
|
401
|
+
return { hooks: { Stop: [{ matcher: '*', hooks: [{ type: 'command', command, timeout: 300 }] }] } }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function stopCommands(document: SettingsDocument): string[] {
|
|
405
|
+
return (document.hooks?.['Stop'] ?? []).flatMap((group) => group.hooks.map((h) => h.command))
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
it('replaces a handler another checkout installed instead of running beside it', () => {
|
|
409
|
+
const existing = documentWith(hookCommand(EXEC, OTHER, 'stop'))
|
|
410
|
+
const merged = mergeHooks(
|
|
411
|
+
existing,
|
|
412
|
+
buildHookConfig({ execPath: EXEC, scriptPath: SCRIPT, replyTimeoutSeconds: 240, graceSeconds: 0 }),
|
|
413
|
+
SCRIPT,
|
|
414
|
+
)
|
|
415
|
+
const commands = stopCommands(merged.document)
|
|
416
|
+
expect(commands.filter((c) => c.includes('hook stop'))).toHaveLength(1)
|
|
417
|
+
expect(commands[0]).toContain(SCRIPT)
|
|
418
|
+
expect(commands.join(' ')).not.toContain(OTHER)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('uninstalls a handler another checkout installed', () => {
|
|
422
|
+
const removed = removeHooks(documentWith(hookCommand(EXEC, OTHER, 'stop')), SCRIPT)
|
|
423
|
+
expect(stopCommands(removed.document)).toHaveLength(0)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('still recognises an install that predates the marker', () => {
|
|
427
|
+
// Same path, no marker: the form written by an older build.
|
|
428
|
+
const legacy = documentWith(`'${EXEC}' '${SCRIPT}' hook stop`)
|
|
429
|
+
expect(stopCommands(removeHooks(legacy, SCRIPT).document)).toHaveLength(0)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it("leaves someone else's hook alone", () => {
|
|
433
|
+
const foreign = documentWith('/usr/local/bin/my-own-thing --stop')
|
|
434
|
+
expect(stopCommands(removeHooks(foreign, SCRIPT).document)).toHaveLength(1)
|
|
435
|
+
})
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* OpenCode's extension point is a plugin module, not a command
|
|
440
|
+
* hook, so it needs a different adapter — but deliberately not different logic.
|
|
441
|
+
*/
|
|
442
|
+
describe('the OpenCode adapter', () => {
|
|
443
|
+
const source = opencodePluginSource({
|
|
444
|
+
execPath: EXEC,
|
|
445
|
+
scriptPath: SCRIPT,
|
|
446
|
+
timeoutSeconds: 240,
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('shells out to the same hook commands the other harnesses run', () => {
|
|
450
|
+
// The whole point: presence, escalation and retirement stay in the CLI.
|
|
451
|
+
expect(source).toContain('"hook", event')
|
|
452
|
+
expect(source).toContain(JSON.stringify(SCRIPT))
|
|
453
|
+
expect(source).toContain(JSON.stringify(EXEC))
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('wires the three question lifecycle joints and leaves permissions alone', () => {
|
|
457
|
+
expect(source).toContain('"chat.message"')
|
|
458
|
+
expect(source).toContain('event: async ({ event })')
|
|
459
|
+
expect(source).toContain('event?.type === "session.idle"')
|
|
460
|
+
expect(source).toContain('event?.properties?.sessionID')
|
|
461
|
+
expect(source).toContain('event?.type === "session.deleted"')
|
|
462
|
+
expect(source).toContain('event?.properties?.info?.id')
|
|
463
|
+
expect(source).not.toContain('"permission.ask"')
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('carries the ownership marker so a second checkout replaces it', () => {
|
|
467
|
+
expect(source).toContain(OPENCODE_PLUGIN_MARKER)
|
|
468
|
+
expect(isOurOpencodePlugin(source)).toBe(true)
|
|
469
|
+
expect(isOurOpencodePlugin('export const SomeoneElse = () => ({})')).toBe(false)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('installs beside the config rather than into it', () => {
|
|
473
|
+
const local = opencodePluginPath(false, '/repo', {})
|
|
474
|
+
expect(local).toBe(path.join('/repo', '.opencode', 'plugins', 'notifai.js'))
|
|
475
|
+
const global = opencodePluginPath(true, '/repo', { OPENCODE_CONFIG_DIR: '/cfg/opencode' })
|
|
476
|
+
expect(global).toBe(path.join('/cfg/opencode', 'plugins', 'notifai.js'))
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('is a harness `hooks install` knows about', () => {
|
|
480
|
+
expect(HARNESSES).toContain('opencode')
|
|
481
|
+
expect(settingsFile('opencode', false, '/repo', {})).toContain('notifai.js')
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('reports the build it will run, not its own path', () => {
|
|
485
|
+
// `doctor` asks every handler which build it invokes. A plugin is a module
|
|
486
|
+
// rather than a command line, so answering with the plugin's own path made
|
|
487
|
+
// an ordinary OpenCode install look like a second checkout and turned the
|
|
488
|
+
// duplicate check red on a healthy machine.
|
|
489
|
+
expect(opencodePluginTarget(source)).toEqual({ exec: EXEC, script: SCRIPT, current: true })
|
|
490
|
+
expect(opencodePluginTarget(source.replace(/^const ADAPTER_VERSION = .*\n/m, ''))).toEqual({
|
|
491
|
+
exec: EXEC,
|
|
492
|
+
script: SCRIPT,
|
|
493
|
+
current: false,
|
|
494
|
+
})
|
|
495
|
+
expect(opencodePluginTarget('export const SomeoneElse = () => ({})')).toBeNull()
|
|
496
|
+
// Ours, but with the constants edited beyond recognition: no answer beats
|
|
497
|
+
// a wrong one, since the caller compares these for equality.
|
|
498
|
+
expect(opencodePluginTarget(`${OPENCODE_PLUGIN_MARKER}\nconst EXEC = whatever\n`)).toBeNull()
|
|
499
|
+
})
|
|
500
|
+
})
|