@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +17 -7
  3. package/dist/commands.d.ts +2 -0
  4. package/dist/commands.js +43 -38
  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 +54 -22
  20. package/dist/install-hooks.js +125 -56
  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 +144 -34
  34. package/src/commands.ts +65 -35
  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 +188 -27
  44. package/src/install-hooks.ts +177 -52
  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,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 accepts that shape as `hooks.json`
38
- * or as inline `[hooks]` tables in the same layer's `config.toml`; one
39
- * representation per 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.
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
- `${quote(adapterPath)} hook ${event} ${OWNER_MARKER}` +
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(adapterPath: string, harness: Harness | undefined): HookHandler {
157
- const command = hookCommand(adapterPath, 'stop', harness)
158
- 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') {
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['HOME'] !== undefined && env['HOME'] !== '' ? env['HOME'] : os.homedir()
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/hooks.json`
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
- * `hooks.json` inside that layer against the main repository. In an ordinary
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
- * Both harnesses let an environment variable relocate their whole config
358
- * directory, and tooling that manages sessions does exactly that — Orca points
359
- * `CODEX_HOME` at a per-account home, so writing to `~/.codex` installs hooks
360
- * 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.
361
444
  */
362
- 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 {
363
451
  const override = env[variable]
364
- 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)
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
- ? configHome(env, 'CODEX_HOME', '.codex')
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 loads `hooks.json` and inline `[hooks]` side by side, runs every
403
- * matching handler, and warns when both exist. `[hooks.state]` is the trust
404
- * store, not a hook definition, so it does not count as a representation.
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) writeTarget = paths.configToml
418
- else if (jsonEvents.length > 0 && tomlEvents.length === 0) writeTarget = paths.hooksJson
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(env: NodeJS.ProcessEnv = process.env): Harness[] {
547
- 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)
548
652
  const found: Harness[] = []
549
653
  if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
550
- if (existsSync(path.join(home, '.codex'))) found.push('codex')
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(cwd: string, env: NodeJS.ProcessEnv = process.env): Harness[] {
564
- 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
+ ])
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(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 {
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(configHome(env, 'CODEX_HOME', '.codex'), 'config.toml')
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 = `${quote(hookAdapterPath(adapterHome))} hook `
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',
@@ -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
  })