@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/install-hooks.ts
CHANGED
|
@@ -7,13 +7,18 @@ import {
|
|
|
7
7
|
statSync,
|
|
8
8
|
} from 'node:fs'
|
|
9
9
|
import { createHash } from 'node:crypto'
|
|
10
|
-
import os from 'node:os'
|
|
11
10
|
import path from 'node:path'
|
|
12
11
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
13
12
|
import { atomicWriteFileSync } from './atomic-file.js'
|
|
14
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
hookAdapterPath,
|
|
15
|
+
hookHostPlatform,
|
|
16
|
+
inspectHookAdapter,
|
|
17
|
+
type HookHostPlatform,
|
|
18
|
+
} from './hook-adapter.js'
|
|
15
19
|
import { opencodePluginPath, opencodePluginTarget } from './opencode-plugin.js'
|
|
16
20
|
import { HARNESSES, type Harness } from './harnesses.js'
|
|
21
|
+
import { accountHome } from './platform.js'
|
|
17
22
|
|
|
18
23
|
export { HARNESSES, type Harness } from './harnesses.js'
|
|
19
24
|
|
|
@@ -34,9 +39,10 @@ const OPENCODE_EVENTS = [
|
|
|
34
39
|
* Claude Code's `settings.json` and Codex's hook files use the same shape —
|
|
35
40
|
* `hooks` maps an event name to matcher groups, each holding command handlers
|
|
36
41
|
* with an optional timeout — so one generator serves both. Only the file
|
|
37
|
-
* location and the event set differ. Codex
|
|
38
|
-
* or
|
|
39
|
-
*
|
|
42
|
+
* location and the event set differ. Codex supports either a dedicated
|
|
43
|
+
* `hooks.json` or inline `[hooks]` tables in the same layer's `config.toml`.
|
|
44
|
+
* We default to the dedicated file so installing hooks never rewrites Codex's
|
|
45
|
+
* main configuration and trust store. One representation per layer, never both.
|
|
40
46
|
*
|
|
41
47
|
* Cursor's native format is flat and lower-camel-cased, while OpenCode's
|
|
42
48
|
* extension point is a JavaScript plugin module. Each therefore has a bounded
|
|
@@ -67,22 +73,44 @@ export interface InstallPlan {
|
|
|
67
73
|
config: HookConfig
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
export interface HookCommandOptions {
|
|
77
|
+
platform?: NodeJS.Platform | HookHostPlatform
|
|
78
|
+
/** Registered Node used to interpret the Windows adapter. Ignored on POSIX. */
|
|
79
|
+
nodePath?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
70
82
|
/**
|
|
71
83
|
* The command each hook runs. Harness definitions know only the stable
|
|
72
84
|
* user-level adapter. Mutable Node, package-manager, version, and checkout
|
|
73
|
-
* paths live behind that seam and never enter a trusted hook identity
|
|
85
|
+
* paths live behind that seam and never enter a trusted hook identity —
|
|
86
|
+
* except on Windows, where CreateProcess cannot run the adapter without an
|
|
87
|
+
* explicit Node executable, so the registered interpreter is named first.
|
|
74
88
|
*/
|
|
75
89
|
export function hookCommand(
|
|
76
90
|
adapterPath: string,
|
|
77
91
|
event: string,
|
|
78
92
|
harness?: Harness,
|
|
93
|
+
options: HookCommandOptions = {},
|
|
79
94
|
): string {
|
|
80
95
|
return (
|
|
81
|
-
`${
|
|
96
|
+
`${hookCommandPrefix(adapterPath, options)}hook ${event} ${OWNER_MARKER}` +
|
|
82
97
|
(harness === undefined ? '' : ` --harness ${harness}`)
|
|
83
98
|
)
|
|
84
99
|
}
|
|
85
100
|
|
|
101
|
+
/** The leading argv that every Notifai handler must start with. */
|
|
102
|
+
export function hookCommandPrefix(
|
|
103
|
+
adapterPath: string,
|
|
104
|
+
options: HookCommandOptions = {},
|
|
105
|
+
): string {
|
|
106
|
+
const host = hookHostPlatform(options.platform)
|
|
107
|
+
if (host === 'win32') {
|
|
108
|
+
const nodePath = options.nodePath ?? process.execPath
|
|
109
|
+
return `${quoteWindowsArg(nodePath)} ${quoteWindowsArg(adapterPath)} `
|
|
110
|
+
}
|
|
111
|
+
return `${quote(adapterPath)} `
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
/**
|
|
87
115
|
* Says "Notifai installed this" without saying which checkout did.
|
|
88
116
|
*
|
|
@@ -109,11 +137,39 @@ function quote(value: string): string {
|
|
|
109
137
|
return `'${value.replaceAll("'", `'\\''`)}'`
|
|
110
138
|
}
|
|
111
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Quote one Windows argv element so CommandLineToArgvW / CreateProcess keep
|
|
142
|
+
* it as a single argument. Always used for the Node and adapter paths, which
|
|
143
|
+
* routinely contain spaces (`Program Files`) and must not use POSIX quotes
|
|
144
|
+
* inside JSON or TOML harness documents.
|
|
145
|
+
*/
|
|
146
|
+
export function quoteWindowsArg(value: string): string {
|
|
147
|
+
let out = '"'
|
|
148
|
+
let slashes = 0
|
|
149
|
+
for (const ch of value) {
|
|
150
|
+
if (ch === '\\') {
|
|
151
|
+
slashes += 1
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
if (ch === '"') {
|
|
155
|
+
out += '\\'.repeat(slashes * 2 + 1) + '"'
|
|
156
|
+
slashes = 0
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
out += '\\'.repeat(slashes) + ch
|
|
160
|
+
slashes = 0
|
|
161
|
+
}
|
|
162
|
+
return `${out}${'\\'.repeat(slashes * 2)}"`
|
|
163
|
+
}
|
|
164
|
+
|
|
112
165
|
export interface BuildOptions {
|
|
113
166
|
/** Stable user-level executable installed by the hook adapter module. */
|
|
114
167
|
adapterPath: string
|
|
115
168
|
/** The installed adapter stamps its exact harness into project pointers. */
|
|
116
169
|
harness?: Harness
|
|
170
|
+
platform?: NodeJS.Platform | HookHostPlatform
|
|
171
|
+
/** Registered Node named first in Windows hook commands. */
|
|
172
|
+
nodePath?: string
|
|
117
173
|
}
|
|
118
174
|
|
|
119
175
|
/**
|
|
@@ -153,24 +209,36 @@ export const BLOCKING_STOP_TIMEOUT_SECONDS = 540
|
|
|
153
209
|
* declared ceiling above the wait — except Codex, which owns that number
|
|
154
210
|
* itself and whose definition must stay byte-stable for its trust store.
|
|
155
211
|
*/
|
|
156
|
-
function stopHandler(
|
|
157
|
-
|
|
158
|
-
|
|
212
|
+
function stopHandler(
|
|
213
|
+
adapterPath: string,
|
|
214
|
+
harness: Harness | undefined,
|
|
215
|
+
options: HookCommandOptions,
|
|
216
|
+
): HookHandler {
|
|
217
|
+
const command = hookCommand(adapterPath, 'stop', harness, options)
|
|
218
|
+
if (harness === 'claude-code' && hookHostPlatform(options.platform) === 'posix') {
|
|
159
219
|
return { type: 'command', command, timeout: CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, async: true }
|
|
160
220
|
}
|
|
161
221
|
if (harness === 'codex') return { type: 'command', command }
|
|
162
222
|
return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS }
|
|
163
223
|
}
|
|
164
224
|
|
|
225
|
+
function commandOptionsFrom(options: BuildOptions): HookCommandOptions {
|
|
226
|
+
return {
|
|
227
|
+
...(options.platform === undefined ? {} : { platform: options.platform }),
|
|
228
|
+
...(options.nodePath === undefined ? {} : { nodePath: options.nodePath }),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
165
232
|
export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
166
233
|
const { adapterPath } = options
|
|
234
|
+
const commandOptions = commandOptionsFrom(options)
|
|
167
235
|
return {
|
|
168
236
|
UserPromptSubmit: [
|
|
169
237
|
{
|
|
170
238
|
hooks: [
|
|
171
239
|
{
|
|
172
240
|
type: 'command',
|
|
173
|
-
command: hookCommand(adapterPath, 'user-prompt-submit', options.harness),
|
|
241
|
+
command: hookCommand(adapterPath, 'user-prompt-submit', options.harness, commandOptions),
|
|
174
242
|
// Claude Code caps UserPromptSubmit at 30s; stay well inside it so
|
|
175
243
|
// a slow network can never delay the user's own prompt.
|
|
176
244
|
timeout: 15,
|
|
@@ -178,13 +246,13 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
|
178
246
|
],
|
|
179
247
|
},
|
|
180
248
|
],
|
|
181
|
-
Stop: [{ hooks: [stopHandler(adapterPath, options.harness)] }],
|
|
249
|
+
Stop: [{ hooks: [stopHandler(adapterPath, options.harness, commandOptions)] }],
|
|
182
250
|
SessionEnd: [
|
|
183
251
|
{
|
|
184
252
|
hooks: [
|
|
185
253
|
{
|
|
186
254
|
type: 'command',
|
|
187
|
-
command: hookCommand(adapterPath, 'session-end', options.harness),
|
|
255
|
+
command: hookCommand(adapterPath, 'session-end', options.harness, commandOptions),
|
|
188
256
|
// Both harnesses give SessionEnd a ~1-3s budget, so this handler
|
|
189
257
|
// only touches local state.
|
|
190
258
|
timeout: 3,
|
|
@@ -211,16 +279,17 @@ interface CursorSettingsDocument {
|
|
|
211
279
|
|
|
212
280
|
/** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
|
|
213
281
|
export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
282
|
+
const commandOptions = commandOptionsFrom(options)
|
|
214
283
|
return {
|
|
215
284
|
beforeSubmitPrompt: [
|
|
216
285
|
{
|
|
217
|
-
command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor'),
|
|
286
|
+
command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor', commandOptions),
|
|
218
287
|
timeout: 15,
|
|
219
288
|
},
|
|
220
289
|
],
|
|
221
290
|
stop: [
|
|
222
291
|
{
|
|
223
|
-
command: hookCommand(options.adapterPath, 'stop', 'cursor'),
|
|
292
|
+
command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
|
|
224
293
|
timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
225
294
|
// A continuation may register a real follow-up question. Match the
|
|
226
295
|
// session-state cap so those chains are useful but never unbounded.
|
|
@@ -229,7 +298,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
|
229
298
|
],
|
|
230
299
|
sessionEnd: [
|
|
231
300
|
{
|
|
232
|
-
command: hookCommand(options.adapterPath, 'session-end', 'cursor'),
|
|
301
|
+
command: hookCommand(options.adapterPath, 'session-end', 'cursor', commandOptions),
|
|
233
302
|
timeout: 3,
|
|
234
303
|
},
|
|
235
304
|
],
|
|
@@ -246,29 +315,43 @@ export function settingsFile(
|
|
|
246
315
|
global: boolean,
|
|
247
316
|
cwd: string,
|
|
248
317
|
env: NodeJS.ProcessEnv = process.env,
|
|
318
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
249
319
|
): string {
|
|
250
320
|
switch (harness) {
|
|
251
321
|
// OpenCode has no settings document to merge into — its adapter is a
|
|
252
322
|
// generated plugin module, so it owns a whole file instead.
|
|
253
323
|
case 'opencode':
|
|
254
|
-
return opencodePluginPath(global, cwd, env)
|
|
324
|
+
return opencodePluginPath(global, cwd, env, platform)
|
|
255
325
|
case 'cursor': {
|
|
256
|
-
const home = env
|
|
326
|
+
const home = harnessAccountHome(env, platform)
|
|
257
327
|
return global
|
|
258
328
|
? path.join(home, '.cursor', 'hooks.json')
|
|
259
329
|
: path.join(cwd, '.cursor', 'hooks.json')
|
|
260
330
|
}
|
|
261
331
|
case 'claude-code':
|
|
262
332
|
return global
|
|
263
|
-
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
|
|
333
|
+
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude', platform), 'settings.json')
|
|
264
334
|
: path.join(cwd, '.claude', 'settings.local.json')
|
|
265
335
|
case 'codex':
|
|
266
|
-
return inspectCodexLayer(codexLayerPaths(global, cwd, env)).writeTarget
|
|
336
|
+
return inspectCodexLayer(codexLayerPaths(global, cwd, env, platform)).writeTarget
|
|
267
337
|
default:
|
|
268
338
|
return assertNeverHarness(harness)
|
|
269
339
|
}
|
|
270
340
|
}
|
|
271
341
|
|
|
342
|
+
/**
|
|
343
|
+
* The account home harness config roots are resolved against.
|
|
344
|
+
*
|
|
345
|
+
* Delegates to the shared process/path `accountHome` so Cursor, OpenCode,
|
|
346
|
+
* Claude, and Codex follow the same Windows USERPROFILE / MSYS HOME rules.
|
|
347
|
+
*/
|
|
348
|
+
export function harnessAccountHome(
|
|
349
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
350
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
351
|
+
): string {
|
|
352
|
+
return accountHome(env, hookHostPlatform(platform) === 'win32' ? 'win32' : 'linux')
|
|
353
|
+
}
|
|
354
|
+
|
|
272
355
|
function assertNeverHarness(harness: never): never {
|
|
273
356
|
throw new Error(`Unsupported harness: ${String(harness)}`)
|
|
274
357
|
}
|
|
@@ -277,8 +360,8 @@ function assertNeverHarness(harness: never): never {
|
|
|
277
360
|
* Where Codex looks for a project's hooks — the **main** repository, not the
|
|
278
361
|
* working directory.
|
|
279
362
|
*
|
|
280
|
-
* Run from a linked git worktree, Codex reads `<main repo>/.codex/
|
|
281
|
-
* and never looks at the worktree's own. Proven 2026-08-03 in an isolated
|
|
363
|
+
* Run from a linked git worktree, Codex reads `<main repo>/.codex/config.toml`
|
|
364
|
+
* (or a leftover `hooks.json`) and never looks at the worktree's own. Proven 2026-08-03 in an isolated
|
|
282
365
|
* `CODEX_HOME`: with cwd set to a worktree, a handler at the main repo root
|
|
283
366
|
* fired and an identical one at the worktree root did not; removing the main
|
|
284
367
|
* one left nothing firing at all. Writing to cwd therefore produces a silent
|
|
@@ -308,7 +391,7 @@ export function codexProjectRoot(cwd: string): string {
|
|
|
308
391
|
*
|
|
309
392
|
* Codex splits the two halves of "which project am I in": it discovers the
|
|
310
393
|
* project layer by walking up from cwd for a `.codex` directory, but resolves
|
|
311
|
-
*
|
|
394
|
+
* the hook file inside that layer against the main repository. In an ordinary
|
|
312
395
|
* checkout both land on the same directory and the split is invisible. In a
|
|
313
396
|
* worktree they diverge, and writing only the main repository's file leaves it
|
|
314
397
|
* unread — Codex never looks, because nothing at or above cwd told it there was
|
|
@@ -354,14 +437,29 @@ function commonGitDir(gitDir: string): string {
|
|
|
354
437
|
}
|
|
355
438
|
|
|
356
439
|
/**
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
* `CODEX_HOME
|
|
360
|
-
*
|
|
440
|
+
* Harness-specific home variables relocate the whole active harness home.
|
|
441
|
+
* This matters under session managers that give each Codex account its own
|
|
442
|
+
* `CODEX_HOME`: writing to the OS account's ~/.codex would configure a
|
|
443
|
+
* different Codex installation than the one running the command.
|
|
361
444
|
*/
|
|
362
|
-
export function configHome(
|
|
445
|
+
export function configHome(
|
|
446
|
+
env: NodeJS.ProcessEnv,
|
|
447
|
+
variable: string,
|
|
448
|
+
fallback: string,
|
|
449
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
450
|
+
): string {
|
|
363
451
|
const override = env[variable]
|
|
364
|
-
|
|
452
|
+
if (override !== undefined && override !== '') return override
|
|
453
|
+
const home = harnessAccountHome(env, platform)
|
|
454
|
+
return path.join(home, fallback)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** User-global Codex config directory for the active Codex installation. */
|
|
458
|
+
export function codexGlobalDir(
|
|
459
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
460
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
461
|
+
): string {
|
|
462
|
+
return configHome(env, 'CODEX_HOME', '.codex', platform)
|
|
365
463
|
}
|
|
366
464
|
|
|
367
465
|
export interface CodexLayerPaths {
|
|
@@ -375,9 +473,10 @@ export function codexLayerPaths(
|
|
|
375
473
|
global: boolean,
|
|
376
474
|
cwd: string,
|
|
377
475
|
env: NodeJS.ProcessEnv = process.env,
|
|
476
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
378
477
|
): CodexLayerPaths {
|
|
379
478
|
const dir = global
|
|
380
|
-
?
|
|
479
|
+
? codexGlobalDir(env, platform)
|
|
381
480
|
: path.join(codexProjectRoot(cwd), '.codex')
|
|
382
481
|
return {
|
|
383
482
|
dir,
|
|
@@ -399,9 +498,11 @@ export interface CodexLayerInspection {
|
|
|
399
498
|
/**
|
|
400
499
|
* Which representation this Codex layer already uses.
|
|
401
500
|
*
|
|
402
|
-
* Codex
|
|
403
|
-
* matching handler
|
|
404
|
-
*
|
|
501
|
+
* Codex supports `hooks.json` and inline `[hooks]` side by side, but loads both
|
|
502
|
+
* and runs every matching handler. Prefer the dedicated file for a new layer;
|
|
503
|
+
* preserve an existing single representation so installing Notifai never
|
|
504
|
+
* invents a dual layer. `[hooks.state]` is the trust store, not a hook
|
|
505
|
+
* definition, so it does not count as a representation.
|
|
405
506
|
*/
|
|
406
507
|
export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection {
|
|
407
508
|
const jsonDocument = tryLoadSettings(paths.hooksJson)
|
|
@@ -414,14 +515,13 @@ export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection
|
|
|
414
515
|
const tomlOurs = documentHasOurHandlers(tomlDocument)
|
|
415
516
|
const dual = jsonEvents.length > 0 && tomlEvents.length > 0
|
|
416
517
|
let writeTarget = paths.hooksJson
|
|
417
|
-
if (tomlEvents.length > 0 && jsonEvents.length === 0)
|
|
418
|
-
|
|
419
|
-
else if (dual) {
|
|
518
|
+
if (tomlEvents.length > 0 && jsonEvents.length === 0) {
|
|
519
|
+
writeTarget = paths.configToml
|
|
520
|
+
} else if (dual) {
|
|
420
521
|
if (tomlForeign && !jsonForeign) writeTarget = paths.configToml
|
|
421
522
|
else if (jsonForeign && !tomlForeign) writeTarget = paths.hooksJson
|
|
422
523
|
else if (!jsonForeign && !tomlForeign) writeTarget = paths.hooksJson
|
|
423
524
|
else if (tomlOurs && !jsonOurs) writeTarget = paths.configToml
|
|
424
|
-
else if (jsonOurs && !tomlOurs) writeTarget = paths.hooksJson
|
|
425
525
|
else writeTarget = paths.hooksJson
|
|
426
526
|
}
|
|
427
527
|
return {
|
|
@@ -441,9 +541,10 @@ export function hookDefinitionFiles(
|
|
|
441
541
|
global: boolean,
|
|
442
542
|
cwd: string,
|
|
443
543
|
env: NodeJS.ProcessEnv = process.env,
|
|
544
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
444
545
|
): string[] {
|
|
445
|
-
if (harness !== 'codex') return [settingsFile(harness, global, cwd, env)]
|
|
446
|
-
const paths = codexLayerPaths(global, cwd, env)
|
|
546
|
+
if (harness !== 'codex') return [settingsFile(harness, global, cwd, env, platform)]
|
|
547
|
+
const paths = codexLayerPaths(global, cwd, env, platform)
|
|
447
548
|
return [paths.hooksJson, paths.configToml]
|
|
448
549
|
}
|
|
449
550
|
|
|
@@ -543,11 +644,14 @@ function localHarnessEvidence(cwd: string): Harness[] {
|
|
|
543
644
|
}
|
|
544
645
|
|
|
545
646
|
/** Harnesses installed anywhere on this machine — a much weaker signal. */
|
|
546
|
-
function globalHarnessEvidence(
|
|
547
|
-
|
|
647
|
+
function globalHarnessEvidence(
|
|
648
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
649
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
650
|
+
): Harness[] {
|
|
651
|
+
const home = harnessAccountHome(env, platform)
|
|
548
652
|
const found: Harness[] = []
|
|
549
653
|
if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
|
|
550
|
-
if (existsSync(
|
|
654
|
+
if (existsSync(codexGlobalDir(env, platform))) found.push('codex')
|
|
551
655
|
if (existsSync(path.join(home, '.cursor'))) found.push('cursor')
|
|
552
656
|
if (existsSync(path.join(home, '.config', 'opencode'))) found.push('opencode')
|
|
553
657
|
return found
|
|
@@ -560,8 +664,15 @@ function globalHarnessEvidence(env: NodeJS.ProcessEnv = process.env): Harness[]
|
|
|
560
664
|
*
|
|
561
665
|
* `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
|
|
562
666
|
*/
|
|
563
|
-
export function detectedHarnesses(
|
|
564
|
-
|
|
667
|
+
export function detectedHarnesses(
|
|
668
|
+
cwd: string,
|
|
669
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
670
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
671
|
+
): Harness[] {
|
|
672
|
+
const seen = new Set<Harness>([
|
|
673
|
+
...localHarnessEvidence(cwd),
|
|
674
|
+
...globalHarnessEvidence(env, platform),
|
|
675
|
+
])
|
|
565
676
|
return HARNESSES.filter((harness) => seen.has(harness))
|
|
566
677
|
}
|
|
567
678
|
|
|
@@ -574,10 +685,14 @@ export function detectedHarnesses(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
574
685
|
* are several harnesses. `detectedHarnesses` is the default for install;
|
|
575
686
|
* this helper stays for uninstall and any caller that cannot take a list.
|
|
576
687
|
*/
|
|
577
|
-
export function detectHarness(
|
|
688
|
+
export function detectHarness(
|
|
689
|
+
cwd: string,
|
|
690
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
691
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
692
|
+
): Harness | null {
|
|
578
693
|
const local = localHarnessEvidence(cwd)
|
|
579
694
|
if (local.length > 0) return local.length === 1 ? local[0]! : null
|
|
580
|
-
const global = globalHarnessEvidence(env)
|
|
695
|
+
const global = globalHarnessEvidence(env, platform)
|
|
581
696
|
return global.length === 1 ? global[0]! : null
|
|
582
697
|
}
|
|
583
698
|
|
|
@@ -872,7 +987,7 @@ export function codexHookIdentityHash(handler: InstalledHandler): string {
|
|
|
872
987
|
}
|
|
873
988
|
|
|
874
989
|
function codexTrustState(env: NodeJS.ProcessEnv): Record<string, unknown> {
|
|
875
|
-
const file = path.join(
|
|
990
|
+
const file = path.join(codexGlobalDir(env), 'config.toml')
|
|
876
991
|
if (!existsSync(file)) return {}
|
|
877
992
|
try {
|
|
878
993
|
const parsed = parseToml(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
@@ -956,11 +1071,17 @@ export function findInstallations(
|
|
|
956
1071
|
cwd: string,
|
|
957
1072
|
env: NodeJS.ProcessEnv = process.env,
|
|
958
1073
|
adapterHome?: string,
|
|
1074
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
959
1075
|
): Installation[] {
|
|
1076
|
+
const nodePath = inspectHookAdapter(adapterHome, platform).target?.execPath
|
|
1077
|
+
const commandOptions: HookCommandOptions = {
|
|
1078
|
+
platform,
|
|
1079
|
+
...(nodePath === undefined ? {} : { nodePath }),
|
|
1080
|
+
}
|
|
960
1081
|
const found: Installation[] = []
|
|
961
1082
|
for (const harness of HARNESSES) {
|
|
962
1083
|
for (const global of [false, true]) {
|
|
963
|
-
const files = hookDefinitionFiles(harness, global, cwd, env)
|
|
1084
|
+
const files = hookDefinitionFiles(harness, global, cwd, env, platform)
|
|
964
1085
|
for (const file of files) {
|
|
965
1086
|
if (!existsSync(file)) continue
|
|
966
1087
|
// OpenCode's adapter is a plugin module, not a settings document, so it
|
|
@@ -997,7 +1118,10 @@ export function findInstallations(
|
|
|
997
1118
|
event,
|
|
998
1119
|
groupIndex: 0,
|
|
999
1120
|
handlerIndex: 0,
|
|
1000
|
-
command: hookCommand(target.adapter, hookEvent, 'opencode'
|
|
1121
|
+
command: hookCommand(target.adapter, hookEvent, 'opencode', {
|
|
1122
|
+
...commandOptions,
|
|
1123
|
+
...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
|
|
1124
|
+
}),
|
|
1001
1125
|
...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
|
|
1002
1126
|
})),
|
|
1003
1127
|
})
|
|
@@ -1012,7 +1136,7 @@ export function findInstallations(
|
|
|
1012
1136
|
}
|
|
1013
1137
|
const handlers = locateCursorHandlers(document)
|
|
1014
1138
|
if (handlers.length > 0) {
|
|
1015
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome)
|
|
1139
|
+
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions)
|
|
1016
1140
|
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
|
|
1017
1141
|
}
|
|
1018
1142
|
continue
|
|
@@ -1025,7 +1149,7 @@ export function findInstallations(
|
|
|
1025
1149
|
}
|
|
1026
1150
|
const handlers = locateHandlers(document)
|
|
1027
1151
|
if (handlers.length > 0) {
|
|
1028
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome)
|
|
1152
|
+
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions)
|
|
1029
1153
|
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
|
|
1030
1154
|
}
|
|
1031
1155
|
}
|
|
@@ -1038,6 +1162,7 @@ function harnessMarkerProblems(
|
|
|
1038
1162
|
harness: Harness,
|
|
1039
1163
|
handlers: InstalledHandler[],
|
|
1040
1164
|
adapterHome?: string,
|
|
1165
|
+
options: HookCommandOptions = {},
|
|
1041
1166
|
): string[] {
|
|
1042
1167
|
const problems: string[] = []
|
|
1043
1168
|
if (!handlers.every((handler) => handler.command.includes(`--harness ${harness}`))) {
|
|
@@ -1045,7 +1170,7 @@ function harnessMarkerProblems(
|
|
|
1045
1170
|
`installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``,
|
|
1046
1171
|
)
|
|
1047
1172
|
}
|
|
1048
|
-
const expected = `${
|
|
1173
|
+
const expected = `${hookCommandPrefix(hookAdapterPath(adapterHome), options)}hook `
|
|
1049
1174
|
if (!handlers.every((handler) => handler.command.startsWith(expected))) {
|
|
1050
1175
|
problems.push(
|
|
1051
1176
|
'installed commands still name a mutable CLI or runtime path; rerun `notifai hooks install` to migrate to the stable adapter',
|
package/src/native-skills.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
-
import os from 'node:os'
|
|
4
3
|
import path from 'node:path'
|
|
4
|
+
import { accountHome, npxLaunch } from './platform.js'
|
|
5
5
|
|
|
6
6
|
/** The two scopes offered by the skills installer. */
|
|
7
7
|
export type SkillScope = 'project' | 'global'
|
|
@@ -52,10 +52,9 @@ interface LockFile {
|
|
|
52
52
|
function skillLockPath(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): string {
|
|
53
53
|
if (scope === 'project') return path.join(cwd, 'skills-lock.json')
|
|
54
54
|
const stateHome = env['XDG_STATE_HOME']
|
|
55
|
-
const home = env['HOME'] ?? env['USERPROFILE'] ?? os.homedir()
|
|
56
55
|
return stateHome !== undefined && stateHome !== ''
|
|
57
56
|
? path.join(stateHome, 'skills', '.skill-lock.json')
|
|
58
|
-
: path.join(
|
|
57
|
+
: path.join(accountHome(env), '.agents', '.skill-lock.json')
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
function readLock(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): LockFile {
|
|
@@ -76,8 +75,7 @@ function conventionalSkillPath(
|
|
|
76
75
|
env: NodeJS.ProcessEnv,
|
|
77
76
|
): string {
|
|
78
77
|
if (scope === 'project') return path.join(cwd, '.agents', 'skills', name)
|
|
79
|
-
|
|
80
|
-
return path.join(home, '.agents', 'skills', name)
|
|
78
|
+
return path.join(accountHome(env), '.agents', 'skills', name)
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
function skillsFromLock(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): NativeSkill[] {
|
|
@@ -103,11 +101,8 @@ function skillsFromLock(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv):
|
|
|
103
101
|
|
|
104
102
|
function run(args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): Promise<number> {
|
|
105
103
|
return new Promise((resolve) => {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
env: options.env,
|
|
109
|
-
stdio: 'inherit',
|
|
110
|
-
})
|
|
104
|
+
const launch = npxLaunch(args, options)
|
|
105
|
+
const child = spawn(launch.file, launch.args, launch.options)
|
|
111
106
|
child.on('error', () => resolve(1))
|
|
112
107
|
child.on('exit', (code) => resolve(code ?? 1))
|
|
113
108
|
})
|