@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +17 -7
  3. package/dist/commands.d.ts +2 -0
  4. package/dist/commands.js +31 -39
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config.d.ts +2 -2
  7. package/dist/config.js +5 -9
  8. package/dist/config.js.map +1 -1
  9. package/dist/credentials.d.ts +54 -3
  10. package/dist/credentials.js +237 -18
  11. package/dist/credentials.js.map +1 -1
  12. package/dist/file-lock.js +15 -6
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/hook-adapter.d.ts +6 -2
  15. package/dist/hook-adapter.js +110 -29
  16. package/dist/hook-adapter.js.map +1 -1
  17. package/dist/hooks.js +2 -1
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +50 -21
  20. package/dist/install-hooks.js +141 -57
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/native-skills.js +5 -10
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/opencode-plugin.d.ts +7 -2
  25. package/dist/opencode-plugin.js +142 -4
  26. package/dist/opencode-plugin.js.map +1 -1
  27. package/dist/platform.d.ts +73 -0
  28. package/dist/platform.js +143 -0
  29. package/dist/platform.js.map +1 -0
  30. package/dist/ui/config-view.js +5 -1
  31. package/dist/ui/config-view.js.map +1 -1
  32. package/package.json +3 -3
  33. package/src/commands.test.ts +100 -26
  34. package/src/commands.ts +49 -36
  35. package/src/config.ts +11 -9
  36. package/src/credentials.test.ts +291 -0
  37. package/src/credentials.ts +274 -17
  38. package/src/file-lock.ts +34 -9
  39. package/src/hook-adapter.test.ts +136 -0
  40. package/src/hook-adapter.ts +120 -28
  41. package/src/hooks.test.ts +27 -1
  42. package/src/hooks.ts +2 -1
  43. package/src/install-hooks.test.ts +174 -33
  44. package/src/install-hooks.ts +186 -54
  45. package/src/native-skills.ts +5 -10
  46. package/src/opencode-plugin.ts +148 -3
  47. package/src/platform.test.ts +152 -0
  48. package/src/platform.ts +173 -0
  49. package/src/ui/config-view.ts +5 -5
@@ -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 { hookAdapterPath } from './hook-adapter.js'
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 still loads a leftover
38
- * `hooks.json`, but that file is the legacy representation. We write inline
39
- * `[hooks]` tables in the same layer's `config.toml`. One representation per
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
- `${quote(adapterPath)} hook ${event} ${OWNER_MARKER}` +
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(adapterPath: string, harness: Harness | undefined): HookHandler {
158
- const command = hookCommand(adapterPath, 'stop', harness)
159
- if (harness === 'claude-code') {
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['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
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
- * Both harnesses let an environment variable relocate their whole config
359
- * directory, and tooling that manages sessions does exactly that — Orca points
360
- * `CODEX_HOME` at a per-account home, so writing to `~/.codex` installs hooks
361
- * into a file Codex never reads and the failure is silent. Observed 2026-08-02.
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(env: NodeJS.ProcessEnv, variable: string, fallback: string): string {
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
- return override !== undefined && override !== '' ? override : path.join(os.homedir(), fallback)
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
- ? configHome(env, 'CODEX_HOME', '.codex')
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` is the legacy file. We write inline `[hooks]` in
404
- * `config.toml` unless this layer already has someone else's hooks only in
405
- * `hooks.json` — writing a second file would make Codex load both and run
406
- * every matching handler. `[hooks.state]` is the trust store, not a hook
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
- // Current representation. Stay on the legacy file only when that is
418
- // already where someone else's hooks live and toml has none.
419
- let writeTarget = paths.configToml
420
- if (jsonEvents.length > 0 && tomlEvents.length === 0 && jsonForeign) {
421
- writeTarget = paths.hooksJson
422
- } else if (dual && jsonForeign && !tomlForeign) {
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(env: NodeJS.ProcessEnv = process.env): Harness[] {
540
- const home = env['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
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(path.join(home, '.codex'))) found.push('codex')
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(cwd: string, env: NodeJS.ProcessEnv = process.env): Harness[] {
557
- const seen = new Set<Harness>([...localHarnessEvidence(cwd), ...globalHarnessEvidence(env)])
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(cwd: string, env: NodeJS.ProcessEnv = process.env): Harness | null {
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(configHome(env, 'CODEX_HOME', '.codex'), 'config.toml')
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 = `${quote(hookAdapterPath(adapterHome))} hook `
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',
@@ -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(home, '.agents', '.skill-lock.json')
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
- const home = env['HOME'] ?? env['USERPROFILE'] ?? os.homedir()
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 child = spawn('npx', args, {
107
- cwd: options.cwd,
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
  })