@raidiant/notifai 1.0.1 → 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 +17 -0
- package/README.md +17 -7
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +31 -39
- 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 +50 -21
- package/dist/install-hooks.js +141 -57
- 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 +100 -26
- package/src/commands.ts +49 -36
- 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 +174 -33
- package/src/install-hooks.ts +186 -54
- 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,18 +247,37 @@ describe('settings locations', () => {
|
|
|
226
247
|
)
|
|
227
248
|
})
|
|
228
249
|
|
|
229
|
-
it('
|
|
230
|
-
expect(
|
|
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
|
+
)
|
|
231
256
|
expect(settingsFile('codex', true, '/repo', {})).toBe(
|
|
232
|
-
path.join(
|
|
257
|
+
path.join(process.env['HOME']!, '.codex', 'hooks.json'),
|
|
233
258
|
)
|
|
234
259
|
})
|
|
235
260
|
|
|
236
|
-
it('
|
|
237
|
-
|
|
261
|
+
it('uses the dedicated Codex hooks file for a new layer', () => {
|
|
262
|
+
expect(settingsFile('codex', false, '/repo', {})).toBe('/repo/.codex/hooks.json')
|
|
263
|
+
expect(settingsFile('codex', true, '/repo', {})).toBe(
|
|
264
|
+
path.join(os.homedir(), '.codex', 'hooks.json'),
|
|
265
|
+
)
|
|
266
|
+
})
|
|
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-'))
|
|
238
270
|
mkdirSync(path.join(repo, '.codex'), { recursive: true })
|
|
239
271
|
applyPlan(path.join(repo, '.codex', 'hooks.json'), { hooks: ours() })
|
|
240
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
|
+
|
|
241
281
|
expect(settingsFile('codex', false, repo, {})).toBe(path.join(repo, '.codex', 'config.toml'))
|
|
242
282
|
})
|
|
243
283
|
|
|
@@ -282,17 +322,18 @@ describe('settings locations', () => {
|
|
|
282
322
|
|
|
283
323
|
it('does not treat the Codex trust store as an inline hook representation', () => {
|
|
284
324
|
const home = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-only-'))
|
|
325
|
+
mkdirSync(path.join(home, '.codex'), { recursive: true })
|
|
285
326
|
writeFileSync(
|
|
286
|
-
path.join(home, 'config.toml'),
|
|
287
|
-
`[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`,
|
|
288
329
|
)
|
|
289
330
|
|
|
290
|
-
expect(settingsFile('codex', true, '/repo', { CODEX_HOME: home })).toBe(
|
|
291
|
-
path.join(home, '
|
|
331
|
+
expect(settingsFile('codex', true, '/repo', { HOME: home, CODEX_HOME: path.join(home, '.codex') })).toBe(
|
|
332
|
+
path.join(home, '.codex', 'hooks.json'),
|
|
292
333
|
)
|
|
293
334
|
})
|
|
294
335
|
|
|
295
|
-
it('
|
|
336
|
+
it('preserves foreign inline hooks when both representations exist', () => {
|
|
296
337
|
const repo = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-both-'))
|
|
297
338
|
const layer = path.join(repo, '.codex')
|
|
298
339
|
mkdirSync(layer, { recursive: true })
|
|
@@ -330,7 +371,7 @@ describe('a Codex install run inside a git worktree', () => {
|
|
|
330
371
|
it('targets the main repository, which is the file Codex actually reads', () => {
|
|
331
372
|
const { main, worktree } = repoWithWorktree()
|
|
332
373
|
|
|
333
|
-
expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', '
|
|
374
|
+
expect(settingsFile('codex', false, worktree, {})).toBe(path.join(main, '.codex', 'hooks.json'))
|
|
334
375
|
expect(codexProjectRoot(worktree)).toBe(main)
|
|
335
376
|
})
|
|
336
377
|
|
|
@@ -395,19 +436,13 @@ describe('a Codex install run inside a git worktree', () => {
|
|
|
395
436
|
)
|
|
396
437
|
})
|
|
397
438
|
|
|
398
|
-
it('follows
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '/managed/codex' })).toBe(
|
|
402
|
-
'/managed/codex/config.toml',
|
|
439
|
+
it('follows the active harness home for global hook install', () => {
|
|
440
|
+
expect(settingsFile('codex', true, '/repo', { HOME: '/user', CODEX_HOME: '/managed/codex' })).toBe(
|
|
441
|
+
'/managed/codex/hooks.json',
|
|
403
442
|
)
|
|
404
443
|
expect(settingsFile('claude-code', true, '/repo', { CLAUDE_CONFIG_DIR: '/managed/claude' })).toBe(
|
|
405
444
|
'/managed/claude/settings.json',
|
|
406
445
|
)
|
|
407
|
-
// An empty value is not a relocation.
|
|
408
|
-
expect(settingsFile('codex', true, '/repo', { CODEX_HOME: '' })).toBe(
|
|
409
|
-
path.join(os.homedir(), '.codex', 'config.toml'),
|
|
410
|
-
)
|
|
411
446
|
})
|
|
412
447
|
})
|
|
413
448
|
|
|
@@ -560,7 +595,7 @@ describe('finding what is installed', () => {
|
|
|
560
595
|
].join('\n'),
|
|
561
596
|
)
|
|
562
597
|
|
|
563
|
-
const found = findInstallations(cwd, {
|
|
598
|
+
const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
|
|
564
599
|
const codex = found.find((installation) => installation.harness === 'codex' && !installation.global)
|
|
565
600
|
expect(codex?.file).toBe(file)
|
|
566
601
|
expect(codex?.handlers.map((handler) => handler.event)).toEqual(['Stop'])
|
|
@@ -580,7 +615,7 @@ describe('finding what is installed', () => {
|
|
|
580
615
|
),
|
|
581
616
|
)
|
|
582
617
|
|
|
583
|
-
const problems = codexRepresentationProblems(cwd, {
|
|
618
|
+
const problems = codexRepresentationProblems(cwd, { HOME: path.join(cwd, 'isolated-home') })
|
|
584
619
|
expect(problems).toHaveLength(1)
|
|
585
620
|
expect(problems[0]).toContain('loading hooks from both')
|
|
586
621
|
expect(problems[0]).toContain(path.join(layer, 'hooks.json'))
|
|
@@ -594,7 +629,7 @@ describe('finding what is installed', () => {
|
|
|
594
629
|
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
595
630
|
applyPlan(path.join(cwd, '.claude', 'settings.local.json'), { hooks: ours() })
|
|
596
631
|
|
|
597
|
-
const found = findInstallations(cwd, {
|
|
632
|
+
const found = findInstallations(cwd, { HOME: path.join(cwd, 'isolated-home') })
|
|
598
633
|
const claude = found.find((i) => i.harness === 'claude-code' && !i.global)
|
|
599
634
|
|
|
600
635
|
expect(claude).toBeDefined()
|
|
@@ -615,39 +650,41 @@ describe('finding what is installed', () => {
|
|
|
615
650
|
|
|
616
651
|
it('detects when Codex still trusts an older definition of an installed hook', () => {
|
|
617
652
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-trust-'))
|
|
618
|
-
const
|
|
653
|
+
const home = path.join(cwd, 'home')
|
|
654
|
+
const env = { HOME: home }
|
|
619
655
|
const hookFile = path.join(cwd, '.codex', 'hooks.json')
|
|
620
656
|
mkdirSync(path.dirname(hookFile), { recursive: true })
|
|
621
657
|
applyPlan(hookFile, { hooks: ours() })
|
|
622
|
-
const installations = findInstallations(cwd,
|
|
658
|
+
const installations = findInstallations(cwd, env)
|
|
623
659
|
const codex = installations.find((installation) => installation.harness === 'codex')
|
|
624
660
|
const stop = codex?.handlers.find((handler) => handler.event === 'Stop')
|
|
625
661
|
expect(stop).toBeDefined()
|
|
626
662
|
|
|
627
|
-
|
|
663
|
+
const trustFile = path.join(home, '.codex', 'config.toml')
|
|
664
|
+
mkdirSync(path.dirname(trustFile), { recursive: true })
|
|
628
665
|
writeFileSync(
|
|
629
|
-
|
|
666
|
+
trustFile,
|
|
630
667
|
`[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "sha256:obsolete"\n`,
|
|
631
668
|
)
|
|
632
669
|
|
|
633
|
-
const stale = codexTrustProblems(installations,
|
|
670
|
+
const stale = codexTrustProblems(installations, env)
|
|
634
671
|
expect(stale).toHaveLength(3)
|
|
635
672
|
expect(stale).toEqual(expect.arrayContaining([expect.stringMatching(/Stop.*changed.*\/hooks/i)]))
|
|
636
673
|
|
|
637
674
|
writeFileSync(
|
|
638
|
-
|
|
675
|
+
trustFile,
|
|
639
676
|
`[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\n`,
|
|
640
677
|
)
|
|
641
|
-
expect(codexTrustProblems(installations,
|
|
678
|
+
expect(codexTrustProblems(installations, env)).toEqual([
|
|
642
679
|
expect.stringMatching(/UserPromptSubmit.*not trusted.*\/hooks/i),
|
|
643
680
|
expect.stringMatching(/SessionEnd.*not trusted.*\/hooks/i),
|
|
644
681
|
])
|
|
645
682
|
|
|
646
683
|
writeFileSync(
|
|
647
|
-
|
|
684
|
+
trustFile,
|
|
648
685
|
`[hooks.state.${JSON.stringify(codexTrustKey(codex!, stop!))}]\ntrusted_hash = "${codexHookIdentityHash(stop!)}"\nenabled = false\n`,
|
|
649
686
|
)
|
|
650
|
-
expect(codexTrustProblems(installations,
|
|
687
|
+
expect(codexTrustProblems(installations, env)).toEqual(
|
|
651
688
|
expect.arrayContaining([expect.stringMatching(/Stop.*disabled.*\/hooks/i)]),
|
|
652
689
|
)
|
|
653
690
|
})
|
|
@@ -843,6 +880,14 @@ describe('detectHarness', () => {
|
|
|
843
880
|
])
|
|
844
881
|
})
|
|
845
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
|
+
|
|
846
891
|
it('prefers project evidence over anything installed on the machine', () => {
|
|
847
892
|
// The regression. Machine-global markers used to be OR-ed in per harness,
|
|
848
893
|
// so a developer with several agent tools installed got a candidate for
|
|
@@ -865,3 +910,99 @@ describe('detectHarness', () => {
|
|
|
865
910
|
expect(answer === null || HARNESSES.includes(answer)).toBe(true)
|
|
866
911
|
})
|
|
867
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
|
+
})
|