@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
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path'
2
+ import { hookHostPlatform, type HookHostPlatform } from './hook-adapter.js'
2
3
  import { configHome } from './install-hooks.js'
3
4
 
4
5
  /**
@@ -36,9 +37,10 @@ export function opencodePluginDir(
36
37
  global: boolean,
37
38
  cwd: string,
38
39
  env: NodeJS.ProcessEnv = process.env,
40
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
39
41
  ): string {
40
42
  return global
41
- ? path.join(configHome(env, 'OPENCODE_CONFIG_DIR', '.config/opencode'), 'plugins')
43
+ ? path.join(configHome(env, 'OPENCODE_CONFIG_DIR', '.config/opencode', platform), 'plugins')
42
44
  : path.join(cwd, '.opencode', 'plugins')
43
45
  }
44
46
 
@@ -46,8 +48,9 @@ export function opencodePluginPath(
46
48
  global: boolean,
47
49
  cwd: string,
48
50
  env: NodeJS.ProcessEnv = process.env,
51
+ platform: NodeJS.Platform | HookHostPlatform = process.platform,
49
52
  ): string {
50
- return path.join(opencodePluginDir(global, cwd, env), OPENCODE_PLUGIN_FILENAME)
53
+ return path.join(opencodePluginDir(global, cwd, env, platform), OPENCODE_PLUGIN_FILENAME)
51
54
  }
52
55
 
53
56
  export interface OpencodePluginOptions {
@@ -55,6 +58,9 @@ export interface OpencodePluginOptions {
55
58
  adapterPath: string
56
59
  /** Child-process lifetime; covers grace, reply wait, and teardown headroom. */
57
60
  timeoutSeconds: number
61
+ platform?: NodeJS.Platform | HookHostPlatform
62
+ /** Registered Node used to interpret the Windows adapter. Ignored on POSIX. */
63
+ nodePath?: string
58
64
  }
59
65
 
60
66
  /**
@@ -70,6 +76,13 @@ export interface OpencodePluginOptions {
70
76
  * never changes OpenCode's own behavior.
71
77
  */
72
78
  export function opencodePluginSource(options: OpencodePluginOptions): string {
79
+ if (hookHostPlatform(options.platform) === 'win32') {
80
+ return win32OpencodePluginSource(options)
81
+ }
82
+ return posixOpencodePluginSource(options)
83
+ }
84
+
85
+ function posixOpencodePluginSource(options: OpencodePluginOptions): string {
73
86
  const { adapterPath, timeoutSeconds } = options
74
87
  return `${OPENCODE_PLUGIN_MARKER}
75
88
  // Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
@@ -196,6 +209,136 @@ export const NotifAIPlugin = async ({ directory }) => {
196
209
  `
197
210
  }
198
211
 
212
+ function win32OpencodePluginSource(options: OpencodePluginOptions): string {
213
+ const { adapterPath, timeoutSeconds } = options
214
+ const nodePath = options.nodePath ?? process.execPath
215
+ return `${OPENCODE_PLUGIN_MARKER}
216
+ // Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
217
+ // the next install; change the CLI instead, which is where the logic lives.
218
+ import { spawn } from "node:child_process"
219
+
220
+ const NODE = ${JSON.stringify(nodePath)}
221
+ const ADAPTER = ${JSON.stringify(adapterPath)}
222
+ const TIMEOUT_MS = ${timeoutSeconds * 1000}
223
+ const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
224
+
225
+ /**
226
+ * Runs \`notifai hook <event>\`, feeding it the same JSON envelope the other
227
+ * harnesses send. Resolves to stdout, or null if anything at all went wrong.
228
+ */
229
+ function runHook(event, envelope) {
230
+ return new Promise((resolve) => {
231
+ let child
232
+ try {
233
+ child = spawn(NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "opencode"], {
234
+ env: process.env,
235
+ shell: false,
236
+ windowsHide: true,
237
+ stdio: ["pipe", "pipe", "pipe"],
238
+ })
239
+ } catch {
240
+ resolve(null)
241
+ return
242
+ }
243
+
244
+ let stdout = ""
245
+ let settled = false
246
+ const finish = (value) => {
247
+ if (settled) return
248
+ settled = true
249
+ clearTimeout(timer)
250
+ resolve(value)
251
+ }
252
+ const timer = setTimeout(() => {
253
+ child.kill("SIGTERM")
254
+ finish(null)
255
+ }, TIMEOUT_MS)
256
+
257
+ child.stdout?.setEncoding("utf-8")
258
+ child.stdout?.on("data", (chunk) => {
259
+ stdout += chunk
260
+ })
261
+ // Hook stderr is diagnostics, not plugin output; discard it here.
262
+ child.stderr?.resume()
263
+ child.on("error", () => finish(null))
264
+ child.on("close", () => finish(stdout))
265
+
266
+ try {
267
+ child.stdin?.end(JSON.stringify(envelope))
268
+ } catch {
269
+ finish(null)
270
+ }
271
+ })
272
+ }
273
+
274
+ function additionalContext(stdout) {
275
+ if (typeof stdout !== "string" || stdout.trim() === "") return null
276
+ try {
277
+ const parsed = JSON.parse(stdout)
278
+ const context = parsed?.hookSpecificOutput?.additionalContext
279
+ return typeof context === "string" && context.length > 0 ? context : null
280
+ } catch {
281
+ return null
282
+ }
283
+ }
284
+
285
+ export const NotifAIPlugin = async ({ directory }) => {
286
+ const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
287
+
288
+ return {
289
+ /** Exact active-harness identity for shell commands such as \`notifai ask\`. */
290
+ "shell.env": async (input, output) => {
291
+ output.env.NOTIFAI_ACTIVE_HARNESS = "opencode"
292
+ const sessionID = input?.sessionID
293
+ if (typeof sessionID === "string" && sessionID.length > 0) {
294
+ output.env.NOTIFAI_ACTIVE_SESSION_ID = sessionID
295
+ }
296
+ },
297
+
298
+ /**
299
+ * Presence. The user typing is the signal every harness shares, and it is
300
+ * also what publishes the project -> session pointer \`notifai ask\` reads.
301
+ */
302
+ "chat.message": async (input, output) => {
303
+ const stdout = await runHook("user-prompt-submit", {
304
+ session_id: input?.sessionID ?? input?.info?.sessionID,
305
+ cwd,
306
+ hook_event_name: "UserPromptSubmit",
307
+ })
308
+ const context = additionalContext(stdout)
309
+ if (context !== null) {
310
+ output.parts.push({
311
+ id: "prt_notifai_" + Date.now(),
312
+ sessionID: input.sessionID,
313
+ messageID: output.message.id,
314
+ type: "text",
315
+ text: context,
316
+ synthetic: true,
317
+ })
318
+ }
319
+ },
320
+
321
+ /** Session lifecycle events are delivered through OpenCode's event bus. */
322
+ event: async ({ event }) => {
323
+ if (event?.type === "session.idle") {
324
+ await runHook("stop", {
325
+ session_id: event?.properties?.sessionID,
326
+ cwd,
327
+ hook_event_name: "Stop",
328
+ })
329
+ } else if (event?.type === "session.deleted") {
330
+ await runHook("session-end", {
331
+ session_id: event?.properties?.info?.id,
332
+ cwd,
333
+ hook_event_name: "SessionEnd",
334
+ })
335
+ }
336
+ },
337
+ }
338
+ }
339
+ `
340
+ }
341
+
199
342
  /** True when a plugin file on disk is one of ours, whoever installed it. */
200
343
  export function isOurOpencodePlugin(contents: string): boolean {
201
344
  return contents.includes(OPENCODE_PLUGIN_MARKER)
@@ -216,10 +359,11 @@ export function isOurOpencodePlugin(contents: string): boolean {
216
359
  */
217
360
  export function opencodePluginTarget(
218
361
  contents: string,
219
- ): { adapter: string; current: boolean; timeoutSeconds?: number } | null {
362
+ ): { adapter: string; current: boolean; timeoutSeconds?: number; nodePath?: string } | null {
220
363
  if (!isOurOpencodePlugin(contents)) return null
221
364
  const adapter = /^const ADAPTER = (".*")$/m.exec(contents)?.[1]
222
365
  const legacyScript = /^const SCRIPT = (".*")$/m.exec(contents)?.[1]
366
+ const node = /^const NODE = (".*")$/m.exec(contents)?.[1]
223
367
  const version = Number(/^const ADAPTER_VERSION = (\d+)$/m.exec(contents)?.[1] ?? 1)
224
368
  const timeoutMs = Number(/^const TIMEOUT_MS = (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
225
369
  if (adapter === undefined && legacyScript === undefined) return null
@@ -228,6 +372,7 @@ export function opencodePluginTarget(
228
372
  adapter: JSON.parse(adapter ?? legacyScript!) as string,
229
373
  current: version === OPENCODE_ADAPTER_VERSION,
230
374
  ...(Number.isFinite(timeoutMs) ? { timeoutSeconds: timeoutMs / 1000 } : {}),
375
+ ...(node === undefined ? {} : { nodePath: JSON.parse(node) as string }),
231
376
  }
232
377
  } catch {
233
378
  return null
@@ -0,0 +1,152 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+ import {
5
+ accountHome,
6
+ cmdScript,
7
+ configHome,
8
+ isWindowsAbsolute,
9
+ npxLaunch,
10
+ quoteCmdArgument,
11
+ stateHome,
12
+ urlOpenLaunch,
13
+ } from './platform.js'
14
+ import { globalConfigDir, stateDir } from './config.js'
15
+
16
+ const PAIRING_URL = 'https://notifai.example/approve?code=AB&next=1|calc'
17
+
18
+ describe('urlOpenLaunch', () => {
19
+ it('keeps a metacharacter pairing URL as one Windows argument and hides the console', () => {
20
+ const launch = urlOpenLaunch(PAIRING_URL, 'win32')
21
+ expect(launch.file.toLowerCase()).not.toMatch(/^cmd(\.exe)?$/)
22
+ expect(launch.args).toEqual(['url.dll,FileProtocolHandler', PAIRING_URL])
23
+ expect(launch.options.windowsHide).toBe(true)
24
+ expect(launch.options.detached).toBe(true)
25
+ expect(launch.options.stdio).toBe('ignore')
26
+ })
27
+
28
+ it('preserves best-effort open and xdg-open on POSIX', () => {
29
+ expect(urlOpenLaunch(PAIRING_URL, 'darwin')).toEqual({
30
+ file: 'open',
31
+ args: [PAIRING_URL],
32
+ options: { stdio: 'ignore', detached: true },
33
+ })
34
+ expect(urlOpenLaunch(PAIRING_URL, 'linux')).toEqual({
35
+ file: 'xdg-open',
36
+ args: [PAIRING_URL],
37
+ options: { stdio: 'ignore', detached: true },
38
+ })
39
+ })
40
+ })
41
+
42
+ describe('npxLaunch', () => {
43
+ const source = 'Raidiant-io/notifai#v1.0.1'
44
+ const args = ['-y', 'skills', 'add', source, '--skill', 'notifai', '--yes']
45
+
46
+ it('runs the .cmd shim through cmd on Windows with quoted arguments', () => {
47
+ const launch = npxLaunch(args, {
48
+ cwd: 'C:\\proj',
49
+ env: { ComSpec: 'C:\\Windows\\system32\\cmd.exe' },
50
+ platform: 'win32',
51
+ })
52
+ expect(launch.file).toBe('C:\\Windows\\system32\\cmd.exe')
53
+ expect(launch.args.slice(0, 4)).toEqual(['/d', '/s', '/v:off', '/c'])
54
+ expect(launch.args).toHaveLength(5)
55
+ expect(launch.args[4]).toBe(cmdScript('npx.cmd', args))
56
+ expect(launch.args[4]).toContain('npx.cmd')
57
+ expect(launch.args[4]).toContain(quoteCmdArgument(source))
58
+ expect(launch.options.windowsVerbatimArguments).toBe(true)
59
+ expect(launch.options.windowsHide).toBe(true)
60
+ expect(launch.options.stdio).toBe('inherit')
61
+ expect(launch.options.cwd).toBe('C:\\proj')
62
+ })
63
+
64
+ it('quotes a hostile source so cmd cannot treat & as a second command', () => {
65
+ const hostile = 'evil&calc.exe'
66
+ const launch = npxLaunch(['-y', 'skills', 'add', hostile, '--skill', 'notifai'], {
67
+ cwd: 'C:\\proj',
68
+ env: {},
69
+ platform: 'win32',
70
+ })
71
+ const script = launch.args[4] ?? ''
72
+ expect(script).toContain(quoteCmdArgument(hostile))
73
+ expect(script.startsWith('"') && script.endsWith('"')).toBe(true)
74
+ expect(launch.file).toBe('cmd.exe')
75
+ })
76
+
77
+ it('spawns npx directly on POSIX without a shell', () => {
78
+ const launch = npxLaunch(args, { cwd: '/tmp/proj', env: { PATH: '/usr/bin' }, platform: 'linux' })
79
+ expect(launch).toEqual({
80
+ file: 'npx',
81
+ args,
82
+ options: { cwd: '/tmp/proj', env: { PATH: '/usr/bin' }, stdio: 'inherit' },
83
+ })
84
+ expect(launch.options.windowsVerbatimArguments).toBeUndefined()
85
+ })
86
+ })
87
+
88
+ describe('accountHome', () => {
89
+ it('rejects an MSYS HOME on Windows and uses a Windows-absolute USERPROFILE', () => {
90
+ expect(
91
+ accountHome({ HOME: '/c/Users/rafael', USERPROFILE: 'C:\\Users\\rafael' }, 'win32'),
92
+ ).toBe('C:\\Users\\rafael')
93
+ expect(accountHome({ HOME: '/home/rafael', USERPROFILE: 'C:/Users/rafael' }, 'win32')).toBe(
94
+ 'C:/Users/rafael',
95
+ )
96
+ expect(isWindowsAbsolute('/c/Users/rafael')).toBe(false)
97
+ expect(isWindowsAbsolute('C:\\Users\\rafael')).toBe(true)
98
+ })
99
+
100
+ it('honours a Windows-absolute HOME and falls back to os.homedir()', () => {
101
+ expect(accountHome({ HOME: 'D:\\homes\\dev', USERPROFILE: 'C:\\Users\\rafael' }, 'win32')).toBe(
102
+ 'D:\\homes\\dev',
103
+ )
104
+ expect(accountHome({ HOME: '/c/Users/rafael' }, 'win32')).toBe(os.homedir())
105
+ })
106
+
107
+ it('preserves POSIX HOME, including a POSIX-looking value', () => {
108
+ expect(accountHome({ HOME: '/home/rafael', USERPROFILE: 'C:\\Users\\rafael' }, 'linux')).toBe(
109
+ '/home/rafael',
110
+ )
111
+ expect(accountHome({ HOME: '/c/Users/rafael' }, 'darwin')).toBe('/c/Users/rafael')
112
+ expect(accountHome({}, 'linux')).toBe(os.homedir())
113
+ })
114
+ })
115
+
116
+ describe('known-folder roots', () => {
117
+ it('defaults Windows config to APPDATA/notifai and state to LOCALAPPDATA/notifai', () => {
118
+ const env = {
119
+ APPDATA: 'C:\\Users\\rafael\\AppData\\Roaming',
120
+ LOCALAPPDATA: 'C:\\Users\\rafael\\AppData\\Local',
121
+ HOME: '/c/Users/rafael',
122
+ USERPROFILE: 'C:\\Users\\rafael',
123
+ }
124
+ expect(configHome(env, 'win32')).toBe(env.APPDATA)
125
+ expect(stateHome(env, 'win32')).toBe(env.LOCALAPPDATA)
126
+ expect(globalConfigDir(env, 'win32')).toBe(path.join(env.APPDATA, 'notifai'))
127
+ expect(stateDir(env, 'win32')).toBe(path.join(env.LOCALAPPDATA, 'notifai'))
128
+ })
129
+
130
+ it('lets explicit XDG roots override Windows known folders', () => {
131
+ const env = {
132
+ XDG_CONFIG_HOME: 'D:\\xdg\\config',
133
+ XDG_STATE_HOME: 'D:\\xdg\\state',
134
+ APPDATA: 'C:\\Users\\rafael\\AppData\\Roaming',
135
+ LOCALAPPDATA: 'C:\\Users\\rafael\\AppData\\Local',
136
+ }
137
+ expect(globalConfigDir(env, 'win32')).toBe(path.join('D:\\xdg\\config', 'notifai'))
138
+ expect(stateDir(env, 'win32')).toBe(path.join('D:\\xdg\\state', 'notifai'))
139
+ })
140
+
141
+ it('keeps POSIX XDG defaults under the account home', () => {
142
+ const env = { HOME: '/home/rafael' }
143
+ expect(globalConfigDir(env, 'linux')).toBe(path.join('/home/rafael', '.config', 'notifai'))
144
+ expect(stateDir(env, 'linux')).toBe(path.join('/home/rafael', '.local', 'state', 'notifai'))
145
+ expect(globalConfigDir({ XDG_CONFIG_HOME: '/custom/config' }, 'darwin')).toBe(
146
+ path.join('/custom/config', 'notifai'),
147
+ )
148
+ expect(stateDir({ XDG_STATE_HOME: '/custom/state' }, 'linux')).toBe(
149
+ path.join('/custom/state', 'notifai'),
150
+ )
151
+ })
152
+ })
@@ -0,0 +1,173 @@
1
+ import { spawn, type SpawnOptions } from 'node:child_process'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ /**
6
+ * How this process should launch one child. Tests inspect the plan instead of
7
+ * opening a browser or running npx; production hands it to `spawn`.
8
+ */
9
+ export interface ProcessLaunch {
10
+ file: string
11
+ args: string[]
12
+ options: SpawnOptions
13
+ }
14
+
15
+ /** Drive letter or UNC. MSYS `/c/Users/…` is not a Windows account home. */
16
+ export function isWindowsAbsolute(value: string): boolean {
17
+ return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
18
+ }
19
+
20
+ /**
21
+ * The account home used for Notifai's own defaults.
22
+ *
23
+ * On Windows, Git Bash and MSYS overwrite `HOME` with a POSIX path that is not
24
+ * a usable NTFS root. Those values are ignored; a Windows-absolute
25
+ * `USERPROFILE` or `os.homedir()` wins. A Windows-absolute `HOME` is still
26
+ * honoured. POSIX keeps `HOME` as the override it has always been.
27
+ */
28
+ export function accountHome(
29
+ env: NodeJS.ProcessEnv = process.env,
30
+ platform: NodeJS.Platform = process.platform,
31
+ ): string {
32
+ if (platform === 'win32') {
33
+ const home = env['HOME']
34
+ if (typeof home === 'string' && isWindowsAbsolute(home)) return home
35
+ const profile = env['USERPROFILE']
36
+ if (typeof profile === 'string' && isWindowsAbsolute(profile)) return profile
37
+ return os.homedir()
38
+ }
39
+ const home = env['HOME']
40
+ if (typeof home === 'string' && home !== '') return home
41
+ return os.homedir()
42
+ }
43
+
44
+ /**
45
+ * Directory that contains the `notifai/` configuration folder.
46
+ *
47
+ * An explicit `XDG_CONFIG_HOME` always wins, including on Windows. Otherwise
48
+ * Windows uses `%APPDATA%` and POSIX uses `~/.config`.
49
+ */
50
+ export function configHome(
51
+ env: NodeJS.ProcessEnv = process.env,
52
+ platform: NodeJS.Platform = process.platform,
53
+ ): string {
54
+ const xdg = env['XDG_CONFIG_HOME']
55
+ if (typeof xdg === 'string' && xdg !== '') return xdg
56
+ if (platform === 'win32') {
57
+ const appdata = env['APPDATA']
58
+ if (typeof appdata === 'string' && appdata !== '') return appdata
59
+ }
60
+ return path.join(accountHome(env, platform), '.config')
61
+ }
62
+
63
+ /**
64
+ * Directory that contains the `notifai/` machine-local state folder.
65
+ *
66
+ * An explicit `XDG_STATE_HOME` always wins. Otherwise Windows uses
67
+ * `%LOCALAPPDATA%` and POSIX uses `~/.local/state`.
68
+ */
69
+ export function stateHome(
70
+ env: NodeJS.ProcessEnv = process.env,
71
+ platform: NodeJS.Platform = process.platform,
72
+ ): string {
73
+ const xdg = env['XDG_STATE_HOME']
74
+ if (typeof xdg === 'string' && xdg !== '') return xdg
75
+ if (platform === 'win32') {
76
+ const local = env['LOCALAPPDATA']
77
+ if (typeof local === 'string' && local !== '') return local
78
+ }
79
+ return path.join(accountHome(env, platform), '.local', 'state')
80
+ }
81
+
82
+ /**
83
+ * Open a pairing or support URL.
84
+ *
85
+ * Windows goes through `rundll32` so the URL is one CreateProcess argument and
86
+ * `cmd` never reparses `?a=1&b=2`. `windowsHide` keeps the helper from flashing
87
+ * a console. macOS and Linux stay best-effort `open` / `xdg-open`.
88
+ */
89
+ export function urlOpenLaunch(
90
+ url: string,
91
+ platform: NodeJS.Platform = process.platform,
92
+ ): ProcessLaunch {
93
+ if (platform === 'darwin') {
94
+ return { file: 'open', args: [url], options: { stdio: 'ignore', detached: true } }
95
+ }
96
+ if (platform === 'win32') {
97
+ return {
98
+ file: 'rundll32.exe',
99
+ args: ['url.dll,FileProtocolHandler', url],
100
+ options: { stdio: 'ignore', detached: true, windowsHide: true },
101
+ }
102
+ }
103
+ return { file: 'xdg-open', args: [url], options: { stdio: 'ignore', detached: true } }
104
+ }
105
+
106
+ /** Best-effort browser open. Failure is silent: the URL is printed either way. */
107
+ export function openUrl(url: string, platform: NodeJS.Platform = process.platform): void {
108
+ try {
109
+ const launch = urlOpenLaunch(url, platform)
110
+ spawn(launch.file, launch.args, launch.options).unref()
111
+ } catch {
112
+ // The caller already printed the URL.
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Quote one cmd.exe argument so it cannot start a second command.
118
+ *
119
+ * `%` expands even inside quotes, so it is doubled. Quotes themselves are
120
+ * doubled. Everything else is inert once quoted, including `& | > < ^`.
121
+ */
122
+ export function quoteCmdArgument(value: string): string {
123
+ return `"${value.replace(/%/g, '%%').replace(/"/g, '""')}"`
124
+ }
125
+
126
+ /**
127
+ * A single `/c` argument for `cmd /d /s /c`.
128
+ *
129
+ * `/s` strips the first and last quote when both ends are quotes, so the
130
+ * payload is wrapped once more around the already-quoted argv. What cmd then
131
+ * runs is `"npx.cmd" "-y" …` with no unquoted metacharacters.
132
+ */
133
+ export function cmdScript(command: string, args: readonly string[]): string {
134
+ return `"${[command, ...args].map(quoteCmdArgument).join(' ')}"`
135
+ }
136
+
137
+ /**
138
+ * Launch `npx` so Windows can run the `.cmd` shim.
139
+ *
140
+ * `spawn('npx', …)` is CreateProcess of a file named `npx`, which does not
141
+ * exist — npm installs `npx.cmd`. cmd.exe can run that shim; CreateProcess
142
+ * cannot. Arguments stay in one quoted `/c` string rather than `shell: true`.
143
+ */
144
+ export function npxLaunch(
145
+ args: readonly string[],
146
+ options: {
147
+ cwd: string
148
+ env: NodeJS.ProcessEnv
149
+ platform?: NodeJS.Platform
150
+ stdio?: SpawnOptions['stdio']
151
+ },
152
+ ): ProcessLaunch {
153
+ const platform = options.platform ?? process.platform
154
+ const stdio = options.stdio ?? 'inherit'
155
+ if (platform === 'win32') {
156
+ return {
157
+ file: options.env['ComSpec'] && options.env['ComSpec'] !== '' ? options.env['ComSpec'] : 'cmd.exe',
158
+ args: ['/d', '/s', '/v:off', '/c', cmdScript('npx.cmd', args)],
159
+ options: {
160
+ cwd: options.cwd,
161
+ env: options.env,
162
+ stdio,
163
+ windowsHide: true,
164
+ windowsVerbatimArguments: true,
165
+ },
166
+ }
167
+ }
168
+ return {
169
+ file: 'npx',
170
+ args: [...args],
171
+ options: { cwd: options.cwd, env: options.env, stdio },
172
+ }
173
+ }
@@ -64,11 +64,11 @@ export function renderConfigList(
64
64
  }
65
65
 
66
66
  lines.push('')
67
- lines.push(
68
- style.dim(
69
- `${glyph.arrow} ${style.code('notifai config explain <key>')} for what one of these means and how to change it.`,
70
- ),
71
- )
67
+ const command = 'notifai config explain <key>'
68
+ const help = `${glyph.arrow} ${command} for what one of these means and how to change it.`
69
+ for (const line of wrap(help, columns)) {
70
+ lines.push(style.dim(line.replace(command, style.code(command))))
71
+ }
72
72
  return lines
73
73
  }
74
74