@raidiant/notifai 1.0.0 → 1.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/CHANGELOG.md +26 -1
- package/README.md +17 -7
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +43 -38
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +5 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +54 -3
- package/dist/credentials.js +237 -18
- package/dist/credentials.js.map +1 -1
- package/dist/file-lock.js +15 -6
- package/dist/file-lock.js.map +1 -1
- package/dist/hook-adapter.d.ts +6 -2
- package/dist/hook-adapter.js +110 -29
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.js +2 -1
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +54 -22
- package/dist/install-hooks.js +125 -56
- package/dist/install-hooks.js.map +1 -1
- package/dist/native-skills.js +5 -10
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -2
- package/dist/opencode-plugin.js +142 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/platform.d.ts +73 -0
- package/dist/platform.js +143 -0
- package/dist/platform.js.map +1 -0
- package/dist/ui/config-view.js +5 -1
- package/dist/ui/config-view.js.map +1 -1
- package/package.json +3 -3
- package/src/commands.test.ts +144 -34
- package/src/commands.ts +65 -35
- package/src/config.ts +11 -9
- package/src/credentials.test.ts +291 -0
- package/src/credentials.ts +274 -17
- package/src/file-lock.ts +34 -9
- package/src/hook-adapter.test.ts +136 -0
- package/src/hook-adapter.ts +120 -28
- package/src/hooks.test.ts +27 -1
- package/src/hooks.ts +2 -1
- package/src/install-hooks.test.ts +188 -27
- package/src/install-hooks.ts +177 -52
- package/src/native-skills.ts +5 -10
- package/src/opencode-plugin.ts +148 -3
- package/src/platform.test.ts +152 -0
- package/src/platform.ts +173 -0
- package/src/ui/config-view.ts +5 -5
package/src/hook-adapter.ts
CHANGED
|
@@ -12,8 +12,11 @@ import { atomicWriteFileSync } from './atomic-file.js'
|
|
|
12
12
|
import { withTargetFileLock } from './file-lock.js'
|
|
13
13
|
|
|
14
14
|
const ADAPTER_MARKER = '# notifai managed hook adapter'
|
|
15
|
+
const WIN32_ADAPTER_MARKER = '// notifai managed hook adapter'
|
|
15
16
|
const ADAPTER_VERSION = 1
|
|
16
17
|
|
|
18
|
+
export type HookHostPlatform = 'posix' | 'win32'
|
|
19
|
+
|
|
17
20
|
export interface HookAdapterFileTarget {
|
|
18
21
|
kind?: 'file'
|
|
19
22
|
execPath: string
|
|
@@ -39,6 +42,12 @@ export interface HookAdapterInspection {
|
|
|
39
42
|
problems: string[]
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
export function hookHostPlatform(
|
|
46
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
47
|
+
): HookHostPlatform {
|
|
48
|
+
return platform === 'win32' ? 'win32' : 'posix'
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
/**
|
|
43
52
|
* One user-level identity for every harness and every project/global install.
|
|
44
53
|
*
|
|
@@ -57,18 +66,21 @@ export function hookAdapterPath(homeDir: string = os.userInfo().homedir): string
|
|
|
57
66
|
export function installHookAdapter(
|
|
58
67
|
target: HookAdapterTarget,
|
|
59
68
|
homeDir?: string,
|
|
69
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
60
70
|
): { path: string; changed: boolean } {
|
|
61
|
-
|
|
71
|
+
const host = hookHostPlatform(platform)
|
|
72
|
+
assertUsableTarget(target, host)
|
|
62
73
|
const file = hookAdapterPath(homeDir)
|
|
63
|
-
ensureManagedDirectories(homeDir ?? os.userInfo().homedir)
|
|
64
|
-
const source = hookAdapterSource(target)
|
|
74
|
+
ensureManagedDirectories(homeDir ?? os.userInfo().homedir, host)
|
|
75
|
+
const source = hookAdapterSource(target, host)
|
|
65
76
|
return withTargetFileLock(file, () => {
|
|
66
77
|
const existing = existsSync(file) ? readSafeManagedFile(file) : null
|
|
67
|
-
const
|
|
68
|
-
|
|
78
|
+
const modeDrift =
|
|
79
|
+
host === 'posix' && existing !== null && (existing.mode & 0o777) !== 0o700
|
|
80
|
+
const changed = existing === null || existing.contents !== source || modeDrift
|
|
69
81
|
if (changed) {
|
|
70
82
|
atomicWriteFileSync(file, source, {
|
|
71
|
-
mode: 0o700,
|
|
83
|
+
mode: host === 'posix' ? 0o700 : 0o600,
|
|
72
84
|
preserveMode: false,
|
|
73
85
|
requireCurrentUserOwner: true,
|
|
74
86
|
})
|
|
@@ -77,14 +89,14 @@ export function installHookAdapter(
|
|
|
77
89
|
})
|
|
78
90
|
}
|
|
79
91
|
|
|
80
|
-
function assertUsableTarget(target: HookAdapterTarget): void {
|
|
92
|
+
function assertUsableTarget(target: HookAdapterTarget, host: HookHostPlatform): void {
|
|
81
93
|
let exec
|
|
82
94
|
try {
|
|
83
95
|
exec = statSync(target.execPath)
|
|
84
96
|
} catch {
|
|
85
97
|
throw new Error(`Hook adapter runtime ${target.execPath} does not exist.`)
|
|
86
98
|
}
|
|
87
|
-
if (!exec.isFile() || (exec.mode & 0o111) === 0) {
|
|
99
|
+
if (!exec.isFile() || (host === 'posix' && (exec.mode & 0o111) === 0)) {
|
|
88
100
|
throw new Error(`Hook adapter runtime ${target.execPath} is not executable.`)
|
|
89
101
|
}
|
|
90
102
|
if (isNpxAdapterTarget(target)) {
|
|
@@ -114,7 +126,9 @@ function assertUsableTarget(target: HookAdapterTarget): void {
|
|
|
114
126
|
/** Inspect the shared adapter without executing either it or its registered CLI. */
|
|
115
127
|
export function inspectHookAdapter(
|
|
116
128
|
homeDir?: string,
|
|
129
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
117
130
|
): HookAdapterInspection {
|
|
131
|
+
const host = hookHostPlatform(platform)
|
|
118
132
|
const file = hookAdapterPath(homeDir)
|
|
119
133
|
if (!existsSync(file)) {
|
|
120
134
|
return { path: file, target: null, problems: [`${file} is missing; rerun \`notifai hooks install\``] }
|
|
@@ -135,19 +149,24 @@ export function inspectHookAdapter(
|
|
|
135
149
|
}
|
|
136
150
|
|
|
137
151
|
const problems: string[] = []
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
152
|
+
if (host === 'posix') {
|
|
153
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
154
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
155
|
+
problems.push(`${file} is owned by uid ${stat.uid}, not the current user`)
|
|
156
|
+
}
|
|
157
|
+
if ((stat.mode & 0o077) !== 0 || (stat.mode & 0o700) !== 0o700) {
|
|
158
|
+
problems.push(`${file} permissions are ${(stat.mode & 0o777).toString(8)}, expected 700`)
|
|
159
|
+
}
|
|
144
160
|
}
|
|
145
161
|
|
|
146
162
|
const contents = readFileSync(file, 'utf8')
|
|
147
|
-
|
|
163
|
+
const format = adapterFormat(contents)
|
|
164
|
+
if (format === null) {
|
|
148
165
|
problems.push(`${file} has no managed adapter marker`)
|
|
166
|
+
} else if (format !== host) {
|
|
167
|
+
problems.push(`${file} is not a ${host} managed adapter; rerun \`notifai hooks install\``)
|
|
149
168
|
}
|
|
150
|
-
const version = Number(
|
|
169
|
+
const version = Number(meta(contents, 'adapter-version') ?? Number.NaN)
|
|
151
170
|
if (version !== ADAPTER_VERSION) problems.push(`${file} uses obsolete adapter version ${version}`)
|
|
152
171
|
const target = parseTarget(contents)
|
|
153
172
|
if (target === null) {
|
|
@@ -155,7 +174,7 @@ export function inspectHookAdapter(
|
|
|
155
174
|
} else {
|
|
156
175
|
try {
|
|
157
176
|
const exec = statSync(target.execPath)
|
|
158
|
-
if (!exec.isFile() || (exec.mode & 0o111) === 0) {
|
|
177
|
+
if (!exec.isFile() || (host === 'posix' && (exec.mode & 0o111) === 0)) {
|
|
159
178
|
problems.push(`registered runtime ${target.execPath} is not executable`)
|
|
160
179
|
}
|
|
161
180
|
} catch {
|
|
@@ -183,7 +202,17 @@ export function inspectHookAdapter(
|
|
|
183
202
|
return { path: file, target, problems }
|
|
184
203
|
}
|
|
185
204
|
|
|
186
|
-
|
|
205
|
+
/** Generate the managed adapter source for one host. Exported so tests can pin bytes. */
|
|
206
|
+
export function hookAdapterSource(
|
|
207
|
+
target: HookAdapterTarget,
|
|
208
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
209
|
+
): string {
|
|
210
|
+
return hookHostPlatform(platform) === 'win32'
|
|
211
|
+
? win32HookAdapterSource(target)
|
|
212
|
+
: posixHookAdapterSource(target)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function posixHookAdapterSource(target: HookAdapterTarget): string {
|
|
187
216
|
if (isNpxAdapterTarget(target)) {
|
|
188
217
|
return `#!/bin/sh
|
|
189
218
|
${ADAPTER_MARKER}
|
|
@@ -225,16 +254,77 @@ exit 127
|
|
|
225
254
|
`
|
|
226
255
|
}
|
|
227
256
|
|
|
257
|
+
function win32HookAdapterSource(target: HookAdapterTarget): string {
|
|
258
|
+
const header = isNpxAdapterTarget(target)
|
|
259
|
+
? `${WIN32_ADAPTER_MARKER}
|
|
260
|
+
// adapter-version: ${ADAPTER_VERSION}
|
|
261
|
+
// target-kind: npx
|
|
262
|
+
// target-exec-json: ${JSON.stringify(target.execPath)}
|
|
263
|
+
// target-npm-cli-json: ${JSON.stringify(target.npmCli)}
|
|
264
|
+
// target-spec-json: ${JSON.stringify(target.spec)}
|
|
265
|
+
`
|
|
266
|
+
: `${WIN32_ADAPTER_MARKER}
|
|
267
|
+
// adapter-version: ${ADAPTER_VERSION}
|
|
268
|
+
// target-exec-json: ${JSON.stringify(target.execPath)}
|
|
269
|
+
// target-script-json: ${JSON.stringify(target.scriptPath)}
|
|
270
|
+
`
|
|
271
|
+
const argv = isNpxAdapterTarget(target)
|
|
272
|
+
? `[${JSON.stringify(target.npmCli)}, "exec", "--yes", "--package", ${JSON.stringify(target.spec)}, "--", "notifai", ...process.argv.slice(2)]`
|
|
273
|
+
: `[${JSON.stringify(target.scriptPath)}, ...process.argv.slice(2)]`
|
|
274
|
+
const companion = isNpxAdapterTarget(target)
|
|
275
|
+
? JSON.stringify(target.npmCli)
|
|
276
|
+
: JSON.stringify(target.scriptPath)
|
|
277
|
+
return `${header}"use strict";
|
|
278
|
+
const { spawnSync } = require("node:child_process");
|
|
279
|
+
const { statSync } = require("node:fs");
|
|
280
|
+
const registeredExec = ${JSON.stringify(target.execPath)};
|
|
281
|
+
const registeredCompanion = ${companion};
|
|
282
|
+
|
|
283
|
+
function isFile(file) {
|
|
284
|
+
try {
|
|
285
|
+
return statSync(file).isFile();
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (isFile(registeredExec) && isFile(registeredCompanion)) {
|
|
292
|
+
const result = spawnSync(registeredExec, ${argv}, {
|
|
293
|
+
stdio: "inherit",
|
|
294
|
+
windowsHide: true,
|
|
295
|
+
});
|
|
296
|
+
if (result.error) {
|
|
297
|
+
process.stderr.write("Notifai hook adapter target is stale; run notifai hooks install.\\n");
|
|
298
|
+
process.exit(127);
|
|
299
|
+
}
|
|
300
|
+
process.exit(result.status === null ? 1 : result.status);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
process.stderr.write("Notifai hook adapter target is stale; run notifai hooks install.\\n");
|
|
304
|
+
process.exit(127);
|
|
305
|
+
`
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function adapterFormat(contents: string): HookHostPlatform | null {
|
|
309
|
+
if (contents.startsWith(`#!/bin/sh\n${ADAPTER_MARKER}\n`)) return 'posix'
|
|
310
|
+
if (contents.startsWith(`${WIN32_ADAPTER_MARKER}\n`)) return 'win32'
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function meta(contents: string, key: string): string | undefined {
|
|
315
|
+
return new RegExp(`^(?:#|//) ${key}: (.+)$`, 'm').exec(contents)?.[1]
|
|
316
|
+
}
|
|
317
|
+
|
|
228
318
|
function parseTarget(contents: string): HookAdapterTarget | null {
|
|
229
|
-
const kind =
|
|
230
|
-
const exec =
|
|
319
|
+
const kind = meta(contents, 'target-kind')
|
|
320
|
+
const exec = meta(contents, 'target-exec-json')
|
|
231
321
|
if (exec === undefined) return null
|
|
232
322
|
try {
|
|
233
323
|
const execPath: unknown = JSON.parse(exec)
|
|
234
324
|
if (typeof execPath !== 'string') return null
|
|
235
325
|
if (kind === 'npx') {
|
|
236
|
-
const npmCliRaw =
|
|
237
|
-
const specRaw =
|
|
326
|
+
const npmCliRaw = meta(contents, 'target-npm-cli-json')
|
|
327
|
+
const specRaw = meta(contents, 'target-spec-json')
|
|
238
328
|
if (npmCliRaw === undefined || specRaw === undefined) return null
|
|
239
329
|
const npmCli: unknown = JSON.parse(npmCliRaw)
|
|
240
330
|
const spec: unknown = JSON.parse(specRaw)
|
|
@@ -242,7 +332,7 @@ function parseTarget(contents: string): HookAdapterTarget | null {
|
|
|
242
332
|
? { kind: 'npx', execPath, npmCli, spec }
|
|
243
333
|
: null
|
|
244
334
|
}
|
|
245
|
-
const script =
|
|
335
|
+
const script = meta(contents, 'target-script-json')
|
|
246
336
|
if (script === undefined) return null
|
|
247
337
|
const scriptPath: unknown = JSON.parse(script)
|
|
248
338
|
return typeof scriptPath === 'string' ? { execPath, scriptPath } : null
|
|
@@ -255,7 +345,7 @@ function quote(value: string): string {
|
|
|
255
345
|
return `'${value.replaceAll("'", `'\\''`)}'`
|
|
256
346
|
}
|
|
257
347
|
|
|
258
|
-
function ensureManagedDirectories(homeDir: string): void {
|
|
348
|
+
function ensureManagedDirectories(homeDir: string, host: HookHostPlatform): void {
|
|
259
349
|
if (!existsSync(homeDir)) mkdirSync(homeDir, { recursive: true, mode: 0o700 })
|
|
260
350
|
for (const dir of [path.join(homeDir, '.notifai'), path.join(homeDir, '.notifai', 'bin')]) {
|
|
261
351
|
if (!existsSync(dir)) mkdirSync(dir, { mode: 0o700 })
|
|
@@ -263,11 +353,13 @@ function ensureManagedDirectories(homeDir: string): void {
|
|
|
263
353
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
264
354
|
throw new Error(`${dir} is not a regular directory; refusing to install the hook adapter.`)
|
|
265
355
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
356
|
+
if (host === 'posix') {
|
|
357
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
358
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
359
|
+
throw new Error(`${dir} is owned by uid ${stat.uid}, not the current user.`)
|
|
360
|
+
}
|
|
361
|
+
chmodSync(dir, 0o700)
|
|
269
362
|
}
|
|
270
|
-
chmodSync(dir, 0o700)
|
|
271
363
|
}
|
|
272
364
|
}
|
|
273
365
|
|
package/src/hooks.test.ts
CHANGED
|
@@ -115,6 +115,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
115
115
|
platform: 'ios' as const,
|
|
116
116
|
permission_status: 'authorized',
|
|
117
117
|
registration_healthy: true,
|
|
118
|
+
reply_protocol_version: 1,
|
|
118
119
|
last_seen_at: null,
|
|
119
120
|
},
|
|
120
121
|
{
|
|
@@ -123,6 +124,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
123
124
|
platform: 'macos' as const,
|
|
124
125
|
permission_status: 'authorized',
|
|
125
126
|
registration_healthy: true,
|
|
127
|
+
reply_protocol_version: 1,
|
|
126
128
|
last_seen_at: null,
|
|
127
129
|
},
|
|
128
130
|
],
|
|
@@ -716,6 +718,30 @@ describe('terminal-first grace window', () => {
|
|
|
716
718
|
).toBeGreaterThanOrEqual(60)
|
|
717
719
|
})
|
|
718
720
|
|
|
721
|
+
it('never pushes a question to a Companion without the current reply protocol', async () => {
|
|
722
|
+
const h = harness([])
|
|
723
|
+
const factory = h.deps.clientFactory
|
|
724
|
+
h.deps.clientFactory = () => {
|
|
725
|
+
const client = factory!()
|
|
726
|
+
return {
|
|
727
|
+
...client,
|
|
728
|
+
listDevices: async () => ({
|
|
729
|
+
devices: (await client.listDevices()).devices.map((device) => ({
|
|
730
|
+
...device,
|
|
731
|
+
reply_protocol_version: null,
|
|
732
|
+
})),
|
|
733
|
+
}),
|
|
734
|
+
} as ApiClient
|
|
735
|
+
}
|
|
736
|
+
writeSessionState('old-companion', h.env, { last_prompt_at: AWAY })
|
|
737
|
+
registerQuestion('old-companion', h.env, { question: 'Ship it?' }, NOW)
|
|
738
|
+
|
|
739
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'old-companion' }))
|
|
740
|
+
|
|
741
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
742
|
+
expect(h.io.errLines.join('\n')).toContain('no device can answer a question yet')
|
|
743
|
+
})
|
|
744
|
+
|
|
719
745
|
it('charges transport overrun and close time to the one waiter ceiling', async () => {
|
|
720
746
|
const h = harness([])
|
|
721
747
|
const factory = h.deps.clientFactory
|
|
@@ -2247,7 +2273,7 @@ describe('session-end hook', () => {
|
|
|
2247
2273
|
await waitUntil(
|
|
2248
2274
|
() =>
|
|
2249
2275
|
readdirSync(logLock).filter((name) => name.startsWith('ticket-')).length >= 2,
|
|
2250
|
-
|
|
2276
|
+
5_000,
|
|
2251
2277
|
'the real Codex hook did not reach contended diagnostics before its deadline',
|
|
2252
2278
|
)
|
|
2253
2279
|
|
package/src/hooks.ts
CHANGED
|
@@ -976,7 +976,8 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
|
|
|
976
976
|
.filter(
|
|
977
977
|
(device) =>
|
|
978
978
|
(device.platform === 'ios' || device.platform === 'macos') &&
|
|
979
|
-
device.registration_healthy
|
|
979
|
+
device.registration_healthy &&
|
|
980
|
+
device.reply_protocol_version === 1,
|
|
980
981
|
)
|
|
981
982
|
.filter((device) => configured === null || configured.includes(device.device_id))
|
|
982
983
|
.map((device) => device.device_id)
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from 'node:fs'
|
|
11
11
|
import os from 'node:os'
|
|
12
12
|
import path from 'node:path'
|
|
13
|
+
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
13
14
|
import { describe, expect, it } from 'vitest'
|
|
14
15
|
import { parseChoices } from './send.js'
|
|
15
16
|
import {
|
|
@@ -19,7 +20,9 @@ import {
|
|
|
19
20
|
opencodePluginSource,
|
|
20
21
|
opencodePluginTarget,
|
|
21
22
|
} from './opencode-plugin.js'
|
|
23
|
+
import { hookAdapterPath, installHookAdapter } from './hook-adapter.js'
|
|
22
24
|
import {
|
|
25
|
+
BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
23
26
|
HARNESSES,
|
|
24
27
|
applyPlan,
|
|
25
28
|
detectHarness,
|
|
@@ -33,7 +36,9 @@ import {
|
|
|
33
36
|
codexTrustKey,
|
|
34
37
|
codexTrustProblems,
|
|
35
38
|
handlerEvent,
|
|
39
|
+
harnessAccountHome,
|
|
36
40
|
hookCommand,
|
|
41
|
+
quoteWindowsArg,
|
|
37
42
|
loadSettings,
|
|
38
43
|
mergeHooks,
|
|
39
44
|
removeHooks,
|
|
@@ -126,7 +131,7 @@ describe('hook config', () => {
|
|
|
126
131
|
})
|
|
127
132
|
})
|
|
128
133
|
|
|
129
|
-
it('gives Claude Code an asynchronous Stop with an explicit waiter budget', () => {
|
|
134
|
+
it('gives Claude Code an asynchronous Stop with an explicit waiter budget on POSIX', () => {
|
|
130
135
|
const claude = buildHookConfig({ adapterPath: ADAPTER, harness: 'claude-code' })
|
|
131
136
|
|
|
132
137
|
// `async: true` is what frees the turn: the handler returns at once and the
|
|
@@ -143,6 +148,22 @@ describe('hook config', () => {
|
|
|
143
148
|
expect(claude['SessionEnd']?.[0]?.hooks[0]?.async).toBeUndefined()
|
|
144
149
|
})
|
|
145
150
|
|
|
151
|
+
it('gives Claude Code a blocking Stop continuation on Windows', () => {
|
|
152
|
+
const options = {
|
|
153
|
+
adapterPath: 'C:\\Users\\Ada\\.notifai\\bin\\hook-adapter',
|
|
154
|
+
harness: 'claude-code' as const,
|
|
155
|
+
platform: 'win32' as const,
|
|
156
|
+
nodePath: 'C:\\Program Files\\nodejs\\node.exe',
|
|
157
|
+
}
|
|
158
|
+
const claude = buildHookConfig(options)
|
|
159
|
+
|
|
160
|
+
expect(claude['Stop']?.[0]?.hooks[0]).toEqual({
|
|
161
|
+
type: 'command',
|
|
162
|
+
command: hookCommand(options.adapterPath, 'stop', 'claude-code', options),
|
|
163
|
+
timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
146
167
|
it('leaves exactly one handler per event when an old blocking shape is reinstalled over', () => {
|
|
147
168
|
const old = mergeHooks({}, buildHookConfig({ adapterPath: ADAPTER }), SCRIPT)
|
|
148
169
|
const migrated = mergeHooks(
|
|
@@ -226,13 +247,52 @@ describe('settings locations', () => {
|
|
|
226
247
|
)
|
|
227
248
|
})
|
|
228
249
|
|
|
229
|
-
it('
|
|
250
|
+
it('keeps ambient global settings inside the test account', () => {
|
|
251
|
+
expect(os.homedir()).toBe(process.env['HOME'])
|
|
252
|
+
expect(os.homedir()).toBe(process.env['NOTIFAI_TEST_HOME'])
|
|
253
|
+
expect(settingsFile('claude-code', true, '/repo', {})).toBe(
|
|
254
|
+
path.join(process.env['HOME']!, '.claude', 'settings.json'),
|
|
255
|
+
)
|
|
256
|
+
expect(settingsFile('codex', true, '/repo', {})).toBe(
|
|
257
|
+
path.join(process.env['HOME']!, '.codex', 'hooks.json'),
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('uses the dedicated Codex hooks file for a new layer', () => {
|
|
230
262
|
expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/hooks.json')
|
|
231
263
|
expect(settingsFile('codex', true, '/repo', {})).toBe(
|
|
232
264
|
path.join(os.homedir(), '.codex', 'hooks.json'),
|
|
233
265
|
)
|
|
234
266
|
})
|
|
235
267
|
|
|
268
|
+
it('keeps a Notifai-only hooks.json instead of rewriting config.toml', () => {
|
|
269
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-'))
|
|
270
|
+
mkdirSync(path.join(repo, '.codex'), { recursive: true })
|
|
271
|
+
applyPlan(path.join(repo, '.codex', 'hooks.json'), { hooks: ours() })
|
|
272
|
+
|
|
273
|
+
expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('keeps a Notifai-only inline representation so existing trust remains valid', () => {
|
|
277
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-ours-toml-'))
|
|
278
|
+
mkdirSync(path.join(repo, '.codex'), { recursive: true })
|
|
279
|
+
applyPlan(path.join(repo, '.codex', 'config.toml'), { hooks: ours() })
|
|
280
|
+
|
|
281
|
+
expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('stays on hooks.json when that file already holds someone else\'s hooks', () => {
|
|
285
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-json-'))
|
|
286
|
+
mkdirSync(path.join(repo, '.codex'), { recursive: true })
|
|
287
|
+
applyPlan(path.join(repo, '.codex', 'hooks.json'), {
|
|
288
|
+
hooks: {
|
|
289
|
+
Stop: [{ hooks: [{ type: 'command', command: 'gdh-stop' }] }],
|
|
290
|
+
},
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'hooks.json'))
|
|
294
|
+
})
|
|
295
|
+
|
|
236
296
|
it('writes Codex hooks into an existing inline [hooks] instead of inventing hooks.json', () => {
|
|
237
297
|
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-toml-layer-'))
|
|
238
298
|
mkdirSync(path.join(repo, '.codex'), { recursive: true })
|
|
@@ -262,17 +322,18 @@ describe('settings locations', () => {
|
|
|
262
322
|
|
|
263
323
|
it('does not treat the Codex trust store as an inline hook representation', () => {
|
|
264
324
|
const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-only-'))
|
|
325
|
+
mkdirSync(path.join(home, '.codex'), { recursive: true })
|
|
265
326
|
writeFileSync(
|
|
266
|
-
path.join(home, 'config.toml'),
|
|
267
|
-
`[hooks.state."${home}/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
|
|
327
|
+
path.join(home, '.codex', 'config.toml'),
|
|
328
|
+
`[hooks.state."${home}/.codex/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:abc"\n`,
|
|
268
329
|
)
|
|
269
330
|
|
|
270
|
-
expect(settingsFile('codex', true, '/repo', { CODEX_HOME: home })).toBe(
|
|
271
|
-
path.join(home, 'hooks.json'),
|
|
331
|
+
expect(settingsFile('codex', true, '/repo', { HOME: home, CODEX_HOME: path.join(home, '.codex') })).toBe(
|
|
332
|
+
path.join(home, '.codex', 'hooks.json'),
|
|
272
333
|
)
|
|
273
334
|
})
|
|
274
335
|
|
|
275
|
-
it('
|
|
336
|
+
it('preserves foreign inline hooks when both representations exist', () => {
|
|
276
337
|
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-both-'))
|
|
277
338
|
const layer = path.join(repo, '.codex')
|
|
278
339
|
mkdirSync(layer, { recursive: true })
|
|
@@ -375,19 +436,13 @@ describe('a Codex install run inside a git worktree', () => {
|
|
|
375
436
|
)
|
|
376
437
|
})
|
|
377
438
|
|
|
378
|
-
it('follows
|
|
379
|
-
|
|
380
|
-
// produced hooks Codex never loaded, and nothing reported a problem.
|
|
381
|
-
expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '/managed/codex' })).toBe(
|
|
439
|
+
it('follows the active harness home for global hook install', () => {
|
|
440
|
+
expect(settingsFile('codex', true, '/repo', { HOME: '/user', CODEX_HOME: '/managed/codex' })).toBe(
|
|
382
441
|
'/managed/codex/hooks.json',
|
|
383
442
|
)
|
|
384
443
|
expect(settingsFile('claude-code', true, '/repo', { CLAUDE_CONFIG_DIR: '/managed/claude' })).toBe(
|
|
385
444
|
'/managed/claude/settings.json',
|
|
386
445
|
)
|
|
387
|
-
// An empty value is not a relocation.
|
|
388
|
-
expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '' })).toBe(
|
|
389
|
-
path.join(os.homedir(), '.codex', 'hooks.json'),
|
|
390
|
-
)
|
|
391
446
|
})
|
|
392
447
|
})
|
|
393
448
|
|
|
@@ -540,7 +595,7 @@ describe('finding what is installed', () => {
|
|
|
540
595
|
].join('\n'),
|
|
541
596
|
)
|
|
542
597
|
|
|
543
|
-
const found = findInstallations(cwd, {
|
|
598
|
+
const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
|
|
544
599
|
const codex = found.find((installation) => installation.harness === 'codex' && !installation.global)
|
|
545
600
|
expect(codex?.file).toBe(file)
|
|
546
601
|
expect(codex?.handlers.map((handler) => handler.event)).toEqual(['Stop'])
|
|
@@ -560,7 +615,7 @@ describe('finding what is installed', () => {
|
|
|
560
615
|
),
|
|
561
616
|
)
|
|
562
617
|
|
|
563
|
-
const problems = codexRepresentationProblems(cwd, {
|
|
618
|
+
const problems = codexRepresentationProblems(cwd, { HOME: path.join(cwd, 'isolated-home') })
|
|
564
619
|
expect(problems).toHaveLength(1)
|
|
565
620
|
expect(problems[0]).toContain('loading hooks from both')
|
|
566
621
|
expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
|
|
@@ -574,7 +629,7 @@ describe('finding what is installed', () => {
|
|
|
574
629
|
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
575
630
|
applyPlan(path.join(cwd, '.claude', 'settings.local.json'), { hooks: ours() })
|
|
576
631
|
|
|
577
|
-
const found = findInstallations(cwd, {
|
|
632
|
+
const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
|
|
578
633
|
const claude = found.find((i) => i.harness === 'claude-code' && !i.global)
|
|
579
634
|
|
|
580
635
|
expect(claude).toBeDefined()
|
|
@@ -595,39 +650,41 @@ describe('finding what is installed', () => {
|
|
|
595
650
|
|
|
596
651
|
it('detects when Codex still trusts an older definition of an installed hook', () => {
|
|
597
652
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-'))
|
|
598
|
-
const
|
|
653
|
+
const home = path.join(cwd, 'home')
|
|
654
|
+
const env = { HOME: home }
|
|
599
655
|
const hookFile = path.join(cwd, '.codex', 'hooks.json')
|
|
600
656
|
mkdirSync(path.dirname(hookFile), { recursive: true })
|
|
601
657
|
applyPlan(hookFile, { hooks: ours() })
|
|
602
|
-
const installations = findInstallations(cwd,
|
|
658
|
+
const installations = findInstallations(cwd, env)
|
|
603
659
|
const codex = installations.find((installation) => installation.harness === 'codex')
|
|
604
660
|
const stop = codex?.handlers.find((handler) => handler.event === 'Stop')
|
|
605
661
|
expect(stop).toBeDefined()
|
|
606
662
|
|
|
607
|
-
|
|
663
|
+
const trustFile = path.join(home, '.codex', 'config.toml')
|
|
664
|
+
mkdirSync(path.dirname(trustFile), { recursive: true })
|
|
608
665
|
writeFileSync(
|
|
609
|
-
|
|
666
|
+
trustFile,
|
|
610
667
|
`[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "sha256:obsolete"\n`,
|
|
611
668
|
)
|
|
612
669
|
|
|
613
|
-
const stale = codexTrustProblems(installations,
|
|
670
|
+
const stale = codexTrustProblems(installations, env)
|
|
614
671
|
expect(stale).toHaveLength(3)
|
|
615
672
|
expect(stale).toEqual(expect.arrayContaining([expect.stringMatching(/Stop.*changed.*\/hooks/i)]))
|
|
616
673
|
|
|
617
674
|
writeFileSync(
|
|
618
|
-
|
|
675
|
+
trustFile,
|
|
619
676
|
`[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\n`,
|
|
620
677
|
)
|
|
621
|
-
expect(codexTrustProblems(installations,
|
|
678
|
+
expect(codexTrustProblems(installations, env)).toEqual([
|
|
622
679
|
expect.stringMatching(/UserPromptSubmit.*not trusted.*\/hooks/i),
|
|
623
680
|
expect.stringMatching(/SessionEnd.*not trusted.*\/hooks/i),
|
|
624
681
|
])
|
|
625
682
|
|
|
626
683
|
writeFileSync(
|
|
627
|
-
|
|
684
|
+
trustFile,
|
|
628
685
|
`[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\nenabled = false\n`,
|
|
629
686
|
)
|
|
630
|
-
expect(codexTrustProblems(installations,
|
|
687
|
+
expect(codexTrustProblems(installations, env)).toEqual(
|
|
631
688
|
expect.arrayContaining([expect.stringMatching(/Stop.*disabled.*\/hooks/i)]),
|
|
632
689
|
)
|
|
633
690
|
})
|
|
@@ -823,6 +880,14 @@ describe('detectHarness', () => {
|
|
|
823
880
|
])
|
|
824
881
|
})
|
|
825
882
|
|
|
883
|
+
it('detects Codex from the active custom CODEX_HOME', () => {
|
|
884
|
+
const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-home-'))
|
|
885
|
+
const codexHome = path.join(home, 'managed', 'codex')
|
|
886
|
+
mkdirSync(codexHome, { recursive: true })
|
|
887
|
+
|
|
888
|
+
expect(detectedHarnesses(project(), { HOME: home, CODEX_HOME: codexHome })).toEqual(['codex'])
|
|
889
|
+
})
|
|
890
|
+
|
|
826
891
|
it('prefers project evidence over anything installed on the machine', () => {
|
|
827
892
|
// The regression. Machine-global markers used to be OR-ed in per harness,
|
|
828
893
|
// so a developer with several agent tools installed got a candidate for
|
|
@@ -845,3 +910,99 @@ describe('detectHarness', () => {
|
|
|
845
910
|
expect(answer === null || HARNESSES.includes(answer)).toBe(true)
|
|
846
911
|
})
|
|
847
912
|
})
|
|
913
|
+
|
|
914
|
+
describe('Windows hook commands and discovery', () => {
|
|
915
|
+
const winNode = 'C:\\Program Files\\nodejs\\node.exe'
|
|
916
|
+
const winAdapter = 'C:\\Users\\Ada Lovelace\\.notifai\\bin\\hook-adapter'
|
|
917
|
+
const winOpts = { platform: 'win32' as const, nodePath: winNode }
|
|
918
|
+
|
|
919
|
+
it('keeps POSIX command bytes unchanged', () => {
|
|
920
|
+
expect(hookCommand(ADAPTER, 'stop', undefined, { platform: 'posix' })).toBe(
|
|
921
|
+
`'${ADAPTER}' hook stop --owner notifai`,
|
|
922
|
+
)
|
|
923
|
+
expect(hookCommand(ADAPTER, 'stop', 'codex', { platform: 'posix' })).toBe(
|
|
924
|
+
`'${ADAPTER}' hook stop --owner notifai --harness codex`,
|
|
925
|
+
)
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
it('emits a Windows-safe command with no POSIX single quotes', () => {
|
|
929
|
+
const command = hookCommand(winAdapter, 'stop', 'codex', winOpts)
|
|
930
|
+
expect(command).toBe(
|
|
931
|
+
`"${winNode}" "${winAdapter}" hook stop --owner notifai --harness codex`,
|
|
932
|
+
)
|
|
933
|
+
expect(command).not.toContain("'")
|
|
934
|
+
expect(quoteWindowsArg('C:\\ends\\with\\')).toBe('"C:\\ends\\with\\\\"')
|
|
935
|
+
expect(quoteWindowsArg('say "hi"')).toBe('"say \\"hi\\""')
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
it('round-trips the Windows command through JSON and TOML documents', () => {
|
|
939
|
+
const command = hookCommand(winAdapter, 'stop', 'cursor', winOpts)
|
|
940
|
+
expect(JSON.parse(JSON.stringify({ command }))).toEqual({ command })
|
|
941
|
+
expect(parseToml(stringifyToml({ command }))).toEqual({ command })
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
it('discovers a Windows-quoted installation as the stable adapter', () => {
|
|
945
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-win-discover-'))
|
|
946
|
+
const homeDir = path.join(cwd, 'home')
|
|
947
|
+
const script = path.join(cwd, 'cli.js')
|
|
948
|
+
writeFileSync(script, '')
|
|
949
|
+
installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir, 'win32')
|
|
950
|
+
const adapter = hookAdapterPath(homeDir)
|
|
951
|
+
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
952
|
+
applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
|
|
953
|
+
hooks: buildHookConfig({
|
|
954
|
+
adapterPath: adapter,
|
|
955
|
+
harness: 'claude-code',
|
|
956
|
+
platform: 'win32',
|
|
957
|
+
nodePath: process.execPath,
|
|
958
|
+
}),
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
const found = findInstallations(
|
|
962
|
+
cwd,
|
|
963
|
+
{ HOME: homeDir, USERPROFILE: 'C:\\Users\\Ada' },
|
|
964
|
+
homeDir,
|
|
965
|
+
'win32',
|
|
966
|
+
)
|
|
967
|
+
expect(found).toHaveLength(1)
|
|
968
|
+
expect(found[0]?.harness).toBe('claude-code')
|
|
969
|
+
expect(found[0]?.problems ?? []).toEqual([])
|
|
970
|
+
expect(found[0]?.handlers[0]?.command.startsWith(`${quoteWindowsArg(process.execPath)} `)).toBe(
|
|
971
|
+
true,
|
|
972
|
+
)
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
it('prefers a Windows-absolute USERPROFILE over an MSYS HOME', () => {
|
|
976
|
+
const env = { HOME: '/c/Users/msys', USERPROFILE: 'C:\\Users\\Ada' }
|
|
977
|
+
expect(harnessAccountHome(env, 'win32')).toBe('C:\\Users\\Ada')
|
|
978
|
+
expect(settingsFile('cursor', true, '/repo', env, 'win32')).toBe(
|
|
979
|
+
path.join('C:\\Users\\Ada', '.cursor', 'hooks.json'),
|
|
980
|
+
)
|
|
981
|
+
expect(settingsFile('claude-code', true, '/repo', env, 'win32')).toBe(
|
|
982
|
+
path.join('C:\\Users\\Ada', '.claude', 'settings.json'),
|
|
983
|
+
)
|
|
984
|
+
expect(opencodePluginPath(true, '/repo', env, 'win32')).toBe(
|
|
985
|
+
path.join('C:\\Users\\Ada', '.config', 'opencode', 'plugins', 'notifai.js'),
|
|
986
|
+
)
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
it('makes OpenCode spawn the adapter through Node on Windows with shell:false', () => {
|
|
990
|
+
const source = opencodePluginSource({
|
|
991
|
+
adapterPath: winAdapter,
|
|
992
|
+
timeoutSeconds: 240,
|
|
993
|
+
platform: 'win32',
|
|
994
|
+
nodePath: winNode,
|
|
995
|
+
})
|
|
996
|
+
expect(source).toContain(`const NODE = ${JSON.stringify(winNode)}`)
|
|
997
|
+
expect(source).toContain('spawn(NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "opencode"]')
|
|
998
|
+
expect(source).toContain('shell: false')
|
|
999
|
+
expect(source).toContain('windowsHide: true')
|
|
1000
|
+
expect(source).not.toContain('spawn(ADAPTER,')
|
|
1001
|
+
expect(opencodePluginTarget(source)).toEqual({
|
|
1002
|
+
adapter: winAdapter,
|
|
1003
|
+
current: true,
|
|
1004
|
+
timeoutSeconds: 240,
|
|
1005
|
+
nodePath: winNode,
|
|
1006
|
+
})
|
|
1007
|
+
})
|
|
1008
|
+
})
|