@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/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,10 +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
|
-
* `hooks.json
|
|
39
|
-
*
|
|
40
|
-
* layer, never both.
|
|
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.
|
|
41
46
|
*
|
|
42
47
|
* Cursor's native format is flat and lower-camel-cased, while OpenCode's
|
|
43
48
|
* extension point is a JavaScript plugin module. Each therefore has a bounded
|
|
@@ -68,22 +73,44 @@ export interface InstallPlan {
|
|
|
68
73
|
config: HookConfig
|
|
69
74
|
}
|
|
70
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
|
+
|
|
71
82
|
/**
|
|
72
83
|
* The command each hook runs. Harness definitions know only the stable
|
|
73
84
|
* user-level adapter. Mutable Node, package-manager, version, and checkout
|
|
74
|
-
* 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.
|
|
75
88
|
*/
|
|
76
89
|
export function hookCommand(
|
|
77
90
|
adapterPath: string,
|
|
78
91
|
event: string,
|
|
79
92
|
harness?: Harness,
|
|
93
|
+
options: HookCommandOptions = {},
|
|
80
94
|
): string {
|
|
81
95
|
return (
|
|
82
|
-
`${
|
|
96
|
+
`${hookCommandPrefix(adapterPath, options)}hook ${event} ${OWNER_MARKER}` +
|
|
83
97
|
(harness === undefined ? '' : ` --harness ${harness}`)
|
|
84
98
|
)
|
|
85
99
|
}
|
|
86
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
|
+
|
|
87
114
|
/**
|
|
88
115
|
* Says "Notifai installed this" without saying which checkout did.
|
|
89
116
|
*
|
|
@@ -110,11 +137,39 @@ function quote(value: string): string {
|
|
|
110
137
|
return `'${value.replaceAll("'", `'\\''`)}'`
|
|
111
138
|
}
|
|
112
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
|
+
|
|
113
165
|
export interface BuildOptions {
|
|
114
166
|
/** Stable user-level executable installed by the hook adapter module. */
|
|
115
167
|
adapterPath: string
|
|
116
168
|
/** The installed adapter stamps its exact harness into project pointers. */
|
|
117
169
|
harness?: Harness
|
|
170
|
+
platform?: NodeJS.Platform | HookHostPlatform
|
|
171
|
+
/** Registered Node named first in Windows hook commands. */
|
|
172
|
+
nodePath?: string
|
|
118
173
|
}
|
|
119
174
|
|
|
120
175
|
/**
|
|
@@ -154,24 +209,36 @@ export const BLOCKING_STOP_TIMEOUT_SECONDS = 540
|
|
|
154
209
|
* declared ceiling above the wait — except Codex, which owns that number
|
|
155
210
|
* itself and whose definition must stay byte-stable for its trust store.
|
|
156
211
|
*/
|
|
157
|
-
function stopHandler(
|
|
158
|
-
|
|
159
|
-
|
|
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') {
|
|
160
219
|
return { type: 'command', command, timeout: CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS, async: true }
|
|
161
220
|
}
|
|
162
221
|
if (harness === 'codex') return { type: 'command', command }
|
|
163
222
|
return { type: 'command', command, timeout: BLOCKING_STOP_TIMEOUT_SECONDS }
|
|
164
223
|
}
|
|
165
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
|
+
|
|
166
232
|
export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
167
233
|
const { adapterPath } = options
|
|
234
|
+
const commandOptions = commandOptionsFrom(options)
|
|
168
235
|
return {
|
|
169
236
|
UserPromptSubmit: [
|
|
170
237
|
{
|
|
171
238
|
hooks: [
|
|
172
239
|
{
|
|
173
240
|
type: 'command',
|
|
174
|
-
command: hookCommand(adapterPath, 'user-prompt-submit', options.harness),
|
|
241
|
+
command: hookCommand(adapterPath, 'user-prompt-submit', options.harness, commandOptions),
|
|
175
242
|
// Claude Code caps UserPromptSubmit at 30s; stay well inside it so
|
|
176
243
|
// a slow network can never delay the user's own prompt.
|
|
177
244
|
timeout: 15,
|
|
@@ -179,13 +246,13 @@ export function buildHookConfig(options: BuildOptions): HookConfig {
|
|
|
179
246
|
],
|
|
180
247
|
},
|
|
181
248
|
],
|
|
182
|
-
Stop: [{ hooks: [stopHandler(adapterPath, options.harness)] }],
|
|
249
|
+
Stop: [{ hooks: [stopHandler(adapterPath, options.harness, commandOptions)] }],
|
|
183
250
|
SessionEnd: [
|
|
184
251
|
{
|
|
185
252
|
hooks: [
|
|
186
253
|
{
|
|
187
254
|
type: 'command',
|
|
188
|
-
command: hookCommand(adapterPath, 'session-end', options.harness),
|
|
255
|
+
command: hookCommand(adapterPath, 'session-end', options.harness, commandOptions),
|
|
189
256
|
// Both harnesses give SessionEnd a ~1-3s budget, so this handler
|
|
190
257
|
// only touches local state.
|
|
191
258
|
timeout: 3,
|
|
@@ -212,16 +279,17 @@ interface CursorSettingsDocument {
|
|
|
212
279
|
|
|
213
280
|
/** Cursor's native schema is flat and uses lower-camel lifecycle event names. */
|
|
214
281
|
export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
282
|
+
const commandOptions = commandOptionsFrom(options)
|
|
215
283
|
return {
|
|
216
284
|
beforeSubmitPrompt: [
|
|
217
285
|
{
|
|
218
|
-
command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor'),
|
|
286
|
+
command: hookCommand(options.adapterPath, 'user-prompt-submit', 'cursor', commandOptions),
|
|
219
287
|
timeout: 15,
|
|
220
288
|
},
|
|
221
289
|
],
|
|
222
290
|
stop: [
|
|
223
291
|
{
|
|
224
|
-
command: hookCommand(options.adapterPath, 'stop', 'cursor'),
|
|
292
|
+
command: hookCommand(options.adapterPath, 'stop', 'cursor', commandOptions),
|
|
225
293
|
timeout: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
226
294
|
// A continuation may register a real follow-up question. Match the
|
|
227
295
|
// session-state cap so those chains are useful but never unbounded.
|
|
@@ -230,7 +298,7 @@ export function buildCursorHookConfig(options: BuildOptions): CursorHookConfig {
|
|
|
230
298
|
],
|
|
231
299
|
sessionEnd: [
|
|
232
300
|
{
|
|
233
|
-
command: hookCommand(options.adapterPath, 'session-end', 'cursor'),
|
|
301
|
+
command: hookCommand(options.adapterPath, 'session-end', 'cursor', commandOptions),
|
|
234
302
|
timeout: 3,
|
|
235
303
|
},
|
|
236
304
|
],
|
|
@@ -247,29 +315,43 @@ export function settingsFile(
|
|
|
247
315
|
global: boolean,
|
|
248
316
|
cwd: string,
|
|
249
317
|
env: NodeJS.ProcessEnv = process.env,
|
|
318
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
250
319
|
): string {
|
|
251
320
|
switch (harness) {
|
|
252
321
|
// OpenCode has no settings document to merge into — its adapter is a
|
|
253
322
|
// generated plugin module, so it owns a whole file instead.
|
|
254
323
|
case 'opencode':
|
|
255
|
-
return opencodePluginPath(global, cwd, env)
|
|
324
|
+
return opencodePluginPath(global, cwd, env, platform)
|
|
256
325
|
case 'cursor': {
|
|
257
|
-
const home = env
|
|
326
|
+
const home = harnessAccountHome(env, platform)
|
|
258
327
|
return global
|
|
259
328
|
? path.join(home, '.cursor', 'hooks.json')
|
|
260
329
|
: path.join(cwd, '.cursor', 'hooks.json')
|
|
261
330
|
}
|
|
262
331
|
case 'claude-code':
|
|
263
332
|
return global
|
|
264
|
-
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude'), 'settings.json')
|
|
333
|
+
? path.join(configHome(env, 'CLAUDE_CONFIG_DIR', '.claude', platform), 'settings.json')
|
|
265
334
|
: path.join(cwd, '.claude', 'settings.local.json')
|
|
266
335
|
case 'codex':
|
|
267
|
-
return inspectCodexLayer(codexLayerPaths(global, cwd, env)).writeTarget
|
|
336
|
+
return inspectCodexLayer(codexLayerPaths(global, cwd, env, platform)).writeTarget
|
|
268
337
|
default:
|
|
269
338
|
return assertNeverHarness(harness)
|
|
270
339
|
}
|
|
271
340
|
}
|
|
272
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
|
+
|
|
273
355
|
function assertNeverHarness(harness: never): never {
|
|
274
356
|
throw new Error(`Unsupported harness: ${String(harness)}`)
|
|
275
357
|
}
|
|
@@ -355,14 +437,29 @@ function commonGitDir(gitDir: string): string {
|
|
|
355
437
|
}
|
|
356
438
|
|
|
357
439
|
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
* `CODEX_HOME
|
|
361
|
-
*
|
|
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.
|
|
362
444
|
*/
|
|
363
|
-
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 {
|
|
364
451
|
const override = env[variable]
|
|
365
|
-
|
|
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)
|
|
366
463
|
}
|
|
367
464
|
|
|
368
465
|
export interface CodexLayerPaths {
|
|
@@ -376,9 +473,10 @@ export function codexLayerPaths(
|
|
|
376
473
|
global: boolean,
|
|
377
474
|
cwd: string,
|
|
378
475
|
env: NodeJS.ProcessEnv = process.env,
|
|
476
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
379
477
|
): CodexLayerPaths {
|
|
380
478
|
const dir = global
|
|
381
|
-
?
|
|
479
|
+
? codexGlobalDir(env, platform)
|
|
382
480
|
: path.join(codexProjectRoot(cwd), '.codex')
|
|
383
481
|
return {
|
|
384
482
|
dir,
|
|
@@ -400,10 +498,10 @@ export interface CodexLayerInspection {
|
|
|
400
498
|
/**
|
|
401
499
|
* Which representation this Codex layer already uses.
|
|
402
500
|
*
|
|
403
|
-
* `hooks.json`
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
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
|
|
407
505
|
* definition, so it does not count as a representation.
|
|
408
506
|
*/
|
|
409
507
|
export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection {
|
|
@@ -413,14 +511,18 @@ export function inspectCodexLayer(paths: CodexLayerPaths): CodexLayerInspection
|
|
|
413
511
|
const tomlEvents = hookEventNames(tomlDocument?.hooks)
|
|
414
512
|
const jsonForeign = documentHasForeignHandlers(jsonDocument)
|
|
415
513
|
const tomlForeign = documentHasForeignHandlers(tomlDocument)
|
|
514
|
+
const jsonOurs = documentHasOurHandlers(jsonDocument)
|
|
515
|
+
const tomlOurs = documentHasOurHandlers(tomlDocument)
|
|
416
516
|
const dual = jsonEvents.length > 0 && tomlEvents.length > 0
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (
|
|
421
|
-
writeTarget = paths.
|
|
422
|
-
|
|
423
|
-
writeTarget = paths.hooksJson
|
|
517
|
+
let writeTarget = paths.hooksJson
|
|
518
|
+
if (tomlEvents.length > 0 && jsonEvents.length === 0) {
|
|
519
|
+
writeTarget = paths.configToml
|
|
520
|
+
} else if (dual) {
|
|
521
|
+
if (tomlForeign && !jsonForeign) writeTarget = paths.configToml
|
|
522
|
+
else if (jsonForeign && !tomlForeign) writeTarget = paths.hooksJson
|
|
523
|
+
else if (!jsonForeign && !tomlForeign) writeTarget = paths.hooksJson
|
|
524
|
+
else if (tomlOurs && !jsonOurs) writeTarget = paths.configToml
|
|
525
|
+
else writeTarget = paths.hooksJson
|
|
424
526
|
}
|
|
425
527
|
return {
|
|
426
528
|
paths,
|
|
@@ -439,9 +541,10 @@ export function hookDefinitionFiles(
|
|
|
439
541
|
global: boolean,
|
|
440
542
|
cwd: string,
|
|
441
543
|
env: NodeJS.ProcessEnv = process.env,
|
|
544
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
442
545
|
): string[] {
|
|
443
|
-
if (harness !== 'codex') return [settingsFile(harness, global, cwd, env)]
|
|
444
|
-
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)
|
|
445
548
|
return [paths.hooksJson, paths.configToml]
|
|
446
549
|
}
|
|
447
550
|
|
|
@@ -510,6 +613,11 @@ function documentHasForeignHandlers(document: SettingsDocument | null): boolean
|
|
|
510
613
|
return locateAllHandlers(document).some((handler) => !isNotifaiCommand(handler.command))
|
|
511
614
|
}
|
|
512
615
|
|
|
616
|
+
function documentHasOurHandlers(document: SettingsDocument | null): boolean {
|
|
617
|
+
if (document === null) return false
|
|
618
|
+
return locateHandlers(document).length > 0
|
|
619
|
+
}
|
|
620
|
+
|
|
513
621
|
/** Best-effort detection so `hooks install` usually needs no flags. */
|
|
514
622
|
/**
|
|
515
623
|
* Harnesses this *project* shows evidence of, in the working directory only.
|
|
@@ -536,11 +644,14 @@ function localHarnessEvidence(cwd: string): Harness[] {
|
|
|
536
644
|
}
|
|
537
645
|
|
|
538
646
|
/** Harnesses installed anywhere on this machine — a much weaker signal. */
|
|
539
|
-
function globalHarnessEvidence(
|
|
540
|
-
|
|
647
|
+
function globalHarnessEvidence(
|
|
648
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
649
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
650
|
+
): Harness[] {
|
|
651
|
+
const home = harnessAccountHome(env, platform)
|
|
541
652
|
const found: Harness[] = []
|
|
542
653
|
if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
|
|
543
|
-
if (existsSync(
|
|
654
|
+
if (existsSync(codexGlobalDir(env, platform))) found.push('codex')
|
|
544
655
|
if (existsSync(path.join(home, '.cursor'))) found.push('cursor')
|
|
545
656
|
if (existsSync(path.join(home, '.config', 'opencode'))) found.push('opencode')
|
|
546
657
|
return found
|
|
@@ -553,8 +664,15 @@ function globalHarnessEvidence(env: NodeJS.ProcessEnv = process.env): Harness[]
|
|
|
553
664
|
*
|
|
554
665
|
* `AGENTS.md` still counts for nothing — see `localHarnessEvidence`.
|
|
555
666
|
*/
|
|
556
|
-
export function detectedHarnesses(
|
|
557
|
-
|
|
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
|
+
])
|
|
558
676
|
return HARNESSES.filter((harness) => seen.has(harness))
|
|
559
677
|
}
|
|
560
678
|
|
|
@@ -567,10 +685,14 @@ export function detectedHarnesses(cwd: string, env: NodeJS.ProcessEnv = process.
|
|
|
567
685
|
* are several harnesses. `detectedHarnesses` is the default for install;
|
|
568
686
|
* this helper stays for uninstall and any caller that cannot take a list.
|
|
569
687
|
*/
|
|
570
|
-
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 {
|
|
571
693
|
const local = localHarnessEvidence(cwd)
|
|
572
694
|
if (local.length > 0) return local.length === 1 ? local[0]! : null
|
|
573
|
-
const global = globalHarnessEvidence(env)
|
|
695
|
+
const global = globalHarnessEvidence(env, platform)
|
|
574
696
|
return global.length === 1 ? global[0]! : null
|
|
575
697
|
}
|
|
576
698
|
|
|
@@ -865,7 +987,7 @@ export function codexHookIdentityHash(handler: InstalledHandler): string {
|
|
|
865
987
|
}
|
|
866
988
|
|
|
867
989
|
function codexTrustState(env: NodeJS.ProcessEnv): Record<string, unknown> {
|
|
868
|
-
const file = path.join(
|
|
990
|
+
const file = path.join(codexGlobalDir(env), 'config.toml')
|
|
869
991
|
if (!existsSync(file)) return {}
|
|
870
992
|
try {
|
|
871
993
|
const parsed = parseToml(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
@@ -949,11 +1071,17 @@ export function findInstallations(
|
|
|
949
1071
|
cwd: string,
|
|
950
1072
|
env: NodeJS.ProcessEnv = process.env,
|
|
951
1073
|
adapterHome?: string,
|
|
1074
|
+
platform: NodeJS.Platform | HookHostPlatform = process.platform,
|
|
952
1075
|
): Installation[] {
|
|
1076
|
+
const nodePath = inspectHookAdapter(adapterHome, platform).target?.execPath
|
|
1077
|
+
const commandOptions: HookCommandOptions = {
|
|
1078
|
+
platform,
|
|
1079
|
+
...(nodePath === undefined ? {} : { nodePath }),
|
|
1080
|
+
}
|
|
953
1081
|
const found: Installation[] = []
|
|
954
1082
|
for (const harness of HARNESSES) {
|
|
955
1083
|
for (const global of [false, true]) {
|
|
956
|
-
const files = hookDefinitionFiles(harness, global, cwd, env)
|
|
1084
|
+
const files = hookDefinitionFiles(harness, global, cwd, env, platform)
|
|
957
1085
|
for (const file of files) {
|
|
958
1086
|
if (!existsSync(file)) continue
|
|
959
1087
|
// OpenCode's adapter is a plugin module, not a settings document, so it
|
|
@@ -990,7 +1118,10 @@ export function findInstallations(
|
|
|
990
1118
|
event,
|
|
991
1119
|
groupIndex: 0,
|
|
992
1120
|
handlerIndex: 0,
|
|
993
|
-
command: hookCommand(target.adapter, hookEvent, 'opencode'
|
|
1121
|
+
command: hookCommand(target.adapter, hookEvent, 'opencode', {
|
|
1122
|
+
...commandOptions,
|
|
1123
|
+
...(target.nodePath === undefined ? {} : { nodePath: target.nodePath }),
|
|
1124
|
+
}),
|
|
994
1125
|
...(target.timeoutSeconds === undefined ? {} : { timeout: target.timeoutSeconds }),
|
|
995
1126
|
})),
|
|
996
1127
|
})
|
|
@@ -1005,7 +1136,7 @@ export function findInstallations(
|
|
|
1005
1136
|
}
|
|
1006
1137
|
const handlers = locateCursorHandlers(document)
|
|
1007
1138
|
if (handlers.length > 0) {
|
|
1008
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome)
|
|
1139
|
+
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions)
|
|
1009
1140
|
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
|
|
1010
1141
|
}
|
|
1011
1142
|
continue
|
|
@@ -1018,7 +1149,7 @@ export function findInstallations(
|
|
|
1018
1149
|
}
|
|
1019
1150
|
const handlers = locateHandlers(document)
|
|
1020
1151
|
if (handlers.length > 0) {
|
|
1021
|
-
const problems = harnessMarkerProblems(harness, handlers, adapterHome)
|
|
1152
|
+
const problems = harnessMarkerProblems(harness, handlers, adapterHome, commandOptions)
|
|
1022
1153
|
found.push({ harness, file, global, handlers, ...(problems.length > 0 ? { problems } : {}) })
|
|
1023
1154
|
}
|
|
1024
1155
|
}
|
|
@@ -1031,6 +1162,7 @@ function harnessMarkerProblems(
|
|
|
1031
1162
|
harness: Harness,
|
|
1032
1163
|
handlers: InstalledHandler[],
|
|
1033
1164
|
adapterHome?: string,
|
|
1165
|
+
options: HookCommandOptions = {},
|
|
1034
1166
|
): string[] {
|
|
1035
1167
|
const problems: string[] = []
|
|
1036
1168
|
if (!handlers.every((handler) => handler.command.includes(`--harness ${harness}`))) {
|
|
@@ -1038,7 +1170,7 @@ function harnessMarkerProblems(
|
|
|
1038
1170
|
`installed commands do not stamp the ${harness} routing identity; rerun \`notifai hooks install --harness ${harness}\``,
|
|
1039
1171
|
)
|
|
1040
1172
|
}
|
|
1041
|
-
const expected = `${
|
|
1173
|
+
const expected = `${hookCommandPrefix(hookAdapterPath(adapterHome), options)}hook `
|
|
1042
1174
|
if (!handlers.every((handler) => handler.command.startsWith(expected))) {
|
|
1043
1175
|
problems.push(
|
|
1044
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
|
})
|