@mseep/obsidian-agent-client 0.10.6

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 (146) hide show
  1. package/.claude/hooks/gh-setup.sh +49 -0
  2. package/.claude/settings.json +15 -0
  3. package/.claude/skills/release-notes/SKILL.md +331 -0
  4. package/.editorconfig +10 -0
  5. package/.github/FUNDING.yml +2 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
  7. package/.github/ISSUE_TEMPLATE/config.yml +11 -0
  8. package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
  9. package/.github/copilot-instructions.md +45 -0
  10. package/.github/pull_request_template.md +32 -0
  11. package/.github/workflows/ci.yaml +25 -0
  12. package/.github/workflows/docs.yml +58 -0
  13. package/.github/workflows/relay_to_openclaw.yml +59 -0
  14. package/.github/workflows/release.yaml +45 -0
  15. package/.prettierignore +10 -0
  16. package/.prettierrc +13 -0
  17. package/.vscode/extensions.json +7 -0
  18. package/.vscode/settings.json +37 -0
  19. package/.zed/settings.json +42 -0
  20. package/AGENTS.md +330 -0
  21. package/ARCHITECTURE.md +390 -0
  22. package/CONTRIBUTING.md +216 -0
  23. package/LICENSE +202 -0
  24. package/NOTICE +2 -0
  25. package/README.ja.md +121 -0
  26. package/README.md +125 -0
  27. package/docs/.vitepress/config.mts +124 -0
  28. package/docs/.vitepress/theme/custom.css +111 -0
  29. package/docs/.vitepress/theme/index.ts +4 -0
  30. package/docs/agent-setup/claude-code.md +84 -0
  31. package/docs/agent-setup/codex.md +76 -0
  32. package/docs/agent-setup/custom-agents.md +67 -0
  33. package/docs/agent-setup/gemini-cli.md +99 -0
  34. package/docs/agent-setup/index.md +34 -0
  35. package/docs/announcements/gemini-cli-deprecation.md +73 -0
  36. package/docs/getting-started/index.md +78 -0
  37. package/docs/getting-started/quick-start.md +38 -0
  38. package/docs/help/faq.md +181 -0
  39. package/docs/help/troubleshooting.md +221 -0
  40. package/docs/index.md +63 -0
  41. package/docs/public/apple-touch-icon.png +0 -0
  42. package/docs/public/demo.mp4 +0 -0
  43. package/docs/public/favicon-16x16.png +0 -0
  44. package/docs/public/favicon-32x32.png +0 -0
  45. package/docs/public/favicon.ico +0 -0
  46. package/docs/public/images/editing.webp +0 -0
  47. package/docs/public/images/export.webp +0 -0
  48. package/docs/public/images/floating-chat-button.webp +0 -0
  49. package/docs/public/images/floating-chat-instance-menu.webp +0 -0
  50. package/docs/public/images/floating-chat-view.webp +0 -0
  51. package/docs/public/images/mode-selection.webp +0 -0
  52. package/docs/public/images/model-selection.webp +0 -0
  53. package/docs/public/images/multi-session.webp +0 -0
  54. package/docs/public/images/remove-image.webp +0 -0
  55. package/docs/public/images/ribbon-icon.webp +0 -0
  56. package/docs/public/images/selection-context.gif +0 -0
  57. package/docs/public/images/sending-images.webp +0 -0
  58. package/docs/public/images/sending-messages.webp +0 -0
  59. package/docs/public/images/session-history-button.webp +0 -0
  60. package/docs/public/images/slash-commands-1.webp +0 -0
  61. package/docs/public/images/slash-commands-2.webp +0 -0
  62. package/docs/public/images/switch-agent.webp +0 -0
  63. package/docs/public/images/switch-default-agent.webp +0 -0
  64. package/docs/public/images/temporary-disable.gif +0 -0
  65. package/docs/reference/acp-support.md +110 -0
  66. package/docs/usage/chat-export.md +80 -0
  67. package/docs/usage/commands.md +51 -0
  68. package/docs/usage/context-files.md +57 -0
  69. package/docs/usage/editing.md +69 -0
  70. package/docs/usage/floating-chat.md +84 -0
  71. package/docs/usage/index.md +97 -0
  72. package/docs/usage/mcp-tools.md +33 -0
  73. package/docs/usage/mentions.md +70 -0
  74. package/docs/usage/mode-selection.md +28 -0
  75. package/docs/usage/model-selection.md +32 -0
  76. package/docs/usage/multi-session.md +68 -0
  77. package/docs/usage/sending-images.md +64 -0
  78. package/docs/usage/session-history.md +91 -0
  79. package/docs/usage/slash-commands.md +44 -0
  80. package/esbuild.config.mjs +49 -0
  81. package/eslint.config.mjs +25 -0
  82. package/main.js +228 -0
  83. package/manifest.json +11 -0
  84. package/package.json +52 -0
  85. package/src/acp/acp-client.ts +921 -0
  86. package/src/acp/acp-handler.ts +252 -0
  87. package/src/acp/permission-handler.ts +282 -0
  88. package/src/acp/terminal-handler.ts +264 -0
  89. package/src/acp/type-converter.ts +272 -0
  90. package/src/hooks/useAgent.ts +250 -0
  91. package/src/hooks/useAgentMessages.ts +470 -0
  92. package/src/hooks/useAgentSession.ts +544 -0
  93. package/src/hooks/useChatActions.ts +400 -0
  94. package/src/hooks/useHistoryModal.ts +219 -0
  95. package/src/hooks/useSessionHistory.ts +863 -0
  96. package/src/hooks/useSettings.ts +19 -0
  97. package/src/hooks/useSuggestions.ts +342 -0
  98. package/src/main.ts +9 -0
  99. package/src/plugin.ts +1126 -0
  100. package/src/services/chat-exporter.ts +552 -0
  101. package/src/services/message-sender.ts +755 -0
  102. package/src/services/message-state.ts +375 -0
  103. package/src/services/session-helpers.ts +211 -0
  104. package/src/services/session-state.ts +130 -0
  105. package/src/services/session-storage.ts +267 -0
  106. package/src/services/settings-normalizer.ts +255 -0
  107. package/src/services/settings-service.ts +285 -0
  108. package/src/services/update-checker.ts +128 -0
  109. package/src/services/vault-service.ts +558 -0
  110. package/src/services/view-registry.ts +345 -0
  111. package/src/types/agent.ts +92 -0
  112. package/src/types/chat.ts +351 -0
  113. package/src/types/errors.ts +136 -0
  114. package/src/types/obsidian-internals.d.ts +14 -0
  115. package/src/types/session.ts +731 -0
  116. package/src/ui/ChangeDirectoryModal.ts +137 -0
  117. package/src/ui/ChatContext.ts +25 -0
  118. package/src/ui/ChatHeader.tsx +295 -0
  119. package/src/ui/ChatPanel.tsx +1162 -0
  120. package/src/ui/ChatView.tsx +348 -0
  121. package/src/ui/ErrorBanner.tsx +104 -0
  122. package/src/ui/FloatingButton.tsx +351 -0
  123. package/src/ui/FloatingChatView.tsx +531 -0
  124. package/src/ui/InputArea.tsx +1107 -0
  125. package/src/ui/InputToolbar.tsx +371 -0
  126. package/src/ui/MessageBubble.tsx +442 -0
  127. package/src/ui/MessageList.tsx +265 -0
  128. package/src/ui/PermissionBanner.tsx +61 -0
  129. package/src/ui/SessionHistoryModal.tsx +821 -0
  130. package/src/ui/SettingsTab.ts +1337 -0
  131. package/src/ui/SuggestionPopup.tsx +138 -0
  132. package/src/ui/TerminalBlock.tsx +107 -0
  133. package/src/ui/ToolCallBlock.tsx +456 -0
  134. package/src/ui/shared/AttachmentStrip.tsx +57 -0
  135. package/src/ui/shared/IconButton.tsx +55 -0
  136. package/src/ui/shared/MarkdownRenderer.tsx +103 -0
  137. package/src/ui/view-host.ts +56 -0
  138. package/src/utils/error-utils.ts +274 -0
  139. package/src/utils/logger.ts +44 -0
  140. package/src/utils/mention-parser.ts +129 -0
  141. package/src/utils/paths.ts +246 -0
  142. package/src/utils/platform.ts +425 -0
  143. package/styles.css +2322 -0
  144. package/tsconfig.json +18 -0
  145. package/version-bump.mjs +18 -0
  146. package/versions.json +3 -0
@@ -0,0 +1,425 @@
1
+ import { execSync } from "child_process";
2
+ import { Platform } from "obsidian";
3
+
4
+ /**
5
+ * Shell escaping utilities for different platforms.
6
+ */
7
+
8
+ /**
9
+ * Escape a shell argument for Windows cmd.exe.
10
+ * Only wraps in double quotes if the argument contains spaces or special characters.
11
+ *
12
+ * In cmd.exe:
13
+ * - Double quotes are escaped by doubling them: " → ""
14
+ * - Percent signs are escaped by doubling them: % → %% (to prevent environment variable expansion)
15
+ */
16
+ export function escapeShellArgWindows(arg: string): string {
17
+ // Escape percent signs and double quotes
18
+ const escaped = arg.replace(/%/g, "%%").replace(/"/g, '""');
19
+
20
+ // Only wrap in quotes if contains spaces or special characters that need quoting
21
+ if (/[\s&()<>|^]/.test(arg)) {
22
+ return `"${escaped}"`;
23
+ }
24
+ return escaped;
25
+ }
26
+
27
+ /**
28
+ * Resolve the login shell for the current platform.
29
+ * Uses $SHELL environment variable when available (covers NixOS, etc.),
30
+ * falls back to platform defaults (/bin/zsh on macOS, /bin/sh on Linux).
31
+ */
32
+ export function getLoginShell(): string {
33
+ if (process.env.SHELL) {
34
+ return process.env.SHELL;
35
+ }
36
+ return Platform.isMacOS ? "/bin/zsh" : "/bin/sh";
37
+ }
38
+
39
+ /**
40
+ * Escape a shell argument for Bash/Zsh/POSIX shells.
41
+ * Wraps the argument in single quotes and escapes internal single quotes
42
+ * using the '\'' idiom (end quote, escaped quote, start quote).
43
+ *
44
+ * Example: hello'world → 'hello'\''world'
45
+ */
46
+ export function escapeShellArgBash(arg: string): string {
47
+ return `'${arg.replace(/'/g, "'\\''")}'`;
48
+ }
49
+
50
+ /**
51
+ * Cache for the full Windows PATH to avoid repeated registry queries.
52
+ */
53
+ let cachedFullPath: string | null = null;
54
+
55
+ /**
56
+ * Get the full Windows PATH environment variable from the registry.
57
+ *
58
+ * Electron apps launched from shortcuts don't inherit the full user PATH.
59
+ * This function queries both system and user PATH from the registry
60
+ * and combines them to get the complete PATH.
61
+ *
62
+ * @returns The full PATH string, or null if unable to retrieve
63
+ */
64
+ export function getFullWindowsPath(): string | null {
65
+ if (!Platform.isWin) {
66
+ return null;
67
+ }
68
+
69
+ if (cachedFullPath !== null) {
70
+ return cachedFullPath;
71
+ }
72
+
73
+ try {
74
+ // Get system PATH from registry
75
+ const systemPath = execSync(
76
+ 'reg query "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment" /v Path',
77
+ { encoding: "utf8", windowsHide: true },
78
+ );
79
+
80
+ // Get user PATH from registry
81
+ const userPath = execSync('reg query "HKCU\\Environment" /v Path', {
82
+ encoding: "utf8",
83
+ windowsHide: true,
84
+ });
85
+
86
+ // Parse the registry output to extract PATH values
87
+ const systemPathValue = parseRegQueryOutput(systemPath);
88
+ const userPathValue = parseRegQueryOutput(userPath);
89
+
90
+ // Combine system and user PATH (user PATH typically comes first)
91
+ const paths: string[] = [];
92
+ if (userPathValue) {
93
+ paths.push(userPathValue);
94
+ }
95
+ if (systemPathValue) {
96
+ paths.push(systemPathValue);
97
+ }
98
+
99
+ cachedFullPath = paths.join(";");
100
+ return cachedFullPath;
101
+ } catch {
102
+ // If registry query fails, return null
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Parse the output of `reg query` command to extract the PATH value.
109
+ */
110
+ function parseRegQueryOutput(output: string): string | null {
111
+ // Registry output format:
112
+ // HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment
113
+ // Path REG_EXPAND_SZ C:\Windows\system32;C:\Windows;...
114
+ const lines = output.split("\n");
115
+ for (const line of lines) {
116
+ const trimmed = line.trim();
117
+ // Look for lines containing "Path" and "REG_"
118
+ if (trimmed.toLowerCase().startsWith("path")) {
119
+ // Split by REG_SZ or REG_EXPAND_SZ and take the value part
120
+ const match = trimmed.match(/Path\s+REG_(?:EXPAND_)?SZ\s+(.+)/i);
121
+ if (match) {
122
+ return match[1].trim();
123
+ }
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Get enhanced environment variables for Windows.
131
+ *
132
+ * This merges the current process.env with the full PATH from registry,
133
+ * ensuring that executables like python, node, etc. can be found.
134
+ *
135
+ * @param baseEnv - The base environment variables to enhance
136
+ * @returns Enhanced environment variables with full PATH
137
+ */
138
+ export function getEnhancedWindowsEnv(
139
+ baseEnv: NodeJS.ProcessEnv,
140
+ ): NodeJS.ProcessEnv {
141
+ if (!Platform.isWin) {
142
+ return baseEnv;
143
+ }
144
+
145
+ const fullPath = getFullWindowsPath();
146
+ if (!fullPath) {
147
+ return baseEnv;
148
+ }
149
+
150
+ // Merge the full PATH with any existing PATH modifications
151
+ const existingPath = baseEnv.PATH || "";
152
+ const existingPaths = existingPath.split(";").filter((p) => p.length > 0);
153
+ const fullPaths = fullPath.split(";").filter((p) => p.length > 0);
154
+
155
+ // Combine: keep existing modifications first, then add paths from registry
156
+ // that aren't already present
157
+ const combinedPaths = [...existingPaths];
158
+ for (const p of fullPaths) {
159
+ if (!combinedPaths.some((ep) => ep.toLowerCase() === p.toLowerCase())) {
160
+ combinedPaths.push(p);
161
+ }
162
+ }
163
+
164
+ return {
165
+ ...baseEnv,
166
+ PATH: combinedPaths.join(";"),
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Clear the cached PATH (useful for testing or when PATH might have changed).
172
+ */
173
+ export function clearWindowsPathCache(): void {
174
+ cachedFullPath = null;
175
+ }
176
+
177
+ /**
178
+ * Convert Windows path to WSL path format.
179
+ * Example: C:\Users\name\vault → /mnt/c/Users/name/vault
180
+ *
181
+ * Note: This function is only called in WSL mode on Windows.
182
+ */
183
+ export function convertWindowsPathToWsl(windowsPath: string): string {
184
+ // Normalize backslashes to forward slashes
185
+ const normalized = windowsPath.replace(/\\/g, "/");
186
+
187
+ // Match drive letter pattern: C:/... or C:\...
188
+ const match = normalized.match(/^([A-Za-z]):(\/.*)/);
189
+
190
+ if (match) {
191
+ const driveLetter = match[1].toLowerCase();
192
+ const pathPart = match[2];
193
+ return `/mnt/${driveLetter}${pathPart}`;
194
+ }
195
+
196
+ return windowsPath;
197
+ }
198
+
199
+ /**
200
+ * Convert WSL path to Windows path format.
201
+ * Example: /mnt/c/Users/name/vault → C:\Users\name\vault
202
+ *
203
+ * Note: This function is only called in WSL mode on Windows.
204
+ */
205
+ export function convertWslPathToWindows(wslPath: string): string {
206
+ const match = wslPath.match(/^\/mnt\/([a-zA-Z])\/(.*)/);
207
+
208
+ if (match) {
209
+ const driveLetter = match[1].toUpperCase();
210
+ const pathPart = match[2].replace(/\//g, "\\");
211
+ return `${driveLetter}:\\${pathPart}`;
212
+ }
213
+
214
+ return wslPath;
215
+ }
216
+
217
+ /**
218
+ * Compare two directory paths accounting for Windows ↔ WSL format differences.
219
+ */
220
+ export function isSameDirectory(pathA: string, pathB: string): boolean {
221
+ if (pathA === pathB) return true;
222
+
223
+ const normalize = (p: string): string => {
224
+ const win = convertWslPathToWindows(p);
225
+ return win.replace(/\\/g, "/").replace(/\/+$/, "");
226
+ };
227
+
228
+ const a = normalize(pathA);
229
+ const b = normalize(pathB);
230
+
231
+ return Platform.isWin ? a.toLowerCase() === b.toLowerCase() : a === b;
232
+ }
233
+
234
+ /**
235
+ * Build a WSL shell wrapper that sources ~/.profile, detects the user's
236
+ * $SHELL, and falls back to /bin/sh for non-POSIX shells (fish, elvish,
237
+ * nushell, xonsh).
238
+ *
239
+ * IMPORTANT: wsl.exe pre-expands $VAR references using WSL environment
240
+ * variables before passing them to the Linux shell. Intermediate variables
241
+ * (e.g., s=$SHELL; exec $s) will NOT work because wsl.exe expands $s to
242
+ * empty. Always reference $SHELL or ${SHELL:-/bin/sh} directly.
243
+ *
244
+ * @param innerCommand - The POSIX command to execute inside the login shell
245
+ * @returns The full wrapper command string to pass as argument to `sh -c`
246
+ */
247
+ export function buildWslShellWrapper(innerCommand: string): string {
248
+ const innerEscaped = innerCommand.replace(/'/g, "'\\''");
249
+ return (
250
+ `. ~/.profile 2>/dev/null; ` +
251
+ `case \${SHELL:-/bin/sh} in ` +
252
+ `*/fish|*/elvish|*/nushell|*/xonsh) exec /bin/sh -l -c '${innerEscaped}';; ` +
253
+ `*) exec \${SHELL:-/bin/sh} -l -c '${innerEscaped}';; ` +
254
+ `esac`
255
+ );
256
+ }
257
+
258
+ /**
259
+ * Wrap a command to run inside WSL using wsl.exe.
260
+ * Generates wsl.exe command with proper arguments for executing commands in WSL environment.
261
+ */
262
+ export function wrapCommandForWsl(
263
+ command: string,
264
+ args: string[],
265
+ cwd: string,
266
+ distribution?: string,
267
+ additionalPath?: string,
268
+ ): { command: string; args: string[] } {
269
+ // Validate working directory path
270
+ // Check for UNC paths (\\server\share) which are not supported by WSL
271
+ if (/^\\\\/.test(cwd)) {
272
+ throw new Error(
273
+ `UNC paths are not supported in WSL mode: ${cwd}. Please use a local drive path.`,
274
+ );
275
+ }
276
+
277
+ const wslCwd = convertWindowsPathToWsl(cwd);
278
+
279
+ // Verify path conversion succeeded (if it was a Windows path with drive letter)
280
+ // If conversion failed, wslCwd will be the same as cwd but still match Windows path pattern
281
+ if (wslCwd === cwd && /^[A-Za-z]:[\\/]/.test(cwd)) {
282
+ throw new Error(`Failed to convert Windows path to WSL format: ${cwd}`);
283
+ }
284
+
285
+ // Build wsl.exe arguments
286
+ const wslArgs: string[] = [];
287
+
288
+ // Specify WSL distribution if provided
289
+ if (distribution) {
290
+ // Validate distribution name (alphanumeric, dot, dash, underscore only)
291
+ if (!/^[a-zA-Z0-9._-]+$/.test(distribution)) {
292
+ throw new Error(`Invalid WSL distribution name: ${distribution}`);
293
+ }
294
+ wslArgs.push("-d", distribution);
295
+ }
296
+
297
+ // Build command to execute inside WSL
298
+ // Use login shell (-l) to inherit PATH from user's shell profile
299
+ const escapedArgs = args.map(escapeShellArgBash).join(" ");
300
+ const argsString = escapedArgs.length > 0 ? ` ${escapedArgs}` : "";
301
+
302
+ // Add additional PATH if provided (e.g., for Node.js)
303
+ let pathPrefix = "";
304
+ if (additionalPath) {
305
+ const wslPath = convertWindowsPathToWsl(additionalPath);
306
+ // Quote PATH value to handle paths with spaces
307
+ pathPrefix = `export PATH="${escapePathForShell(wslPath)}:$PATH"; `;
308
+ }
309
+
310
+ const innerCommand = `${pathPrefix}cd ${escapeShellArgBash(wslCwd)} && ${command}${argsString}`;
311
+ wslArgs.push("sh", "-c", buildWslShellWrapper(innerCommand));
312
+
313
+ return {
314
+ command: "C:\\Windows\\System32\\wsl.exe",
315
+ args: wslArgs,
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Escape a path value for use in shell PATH variable (double-quoted context).
321
+ * Escapes double quotes and backslashes for use within double quotes.
322
+ */
323
+ function escapePathForShell(path: string): string {
324
+ return path.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
325
+ }
326
+
327
+ /**
328
+ * Result of platform-specific command preparation.
329
+ */
330
+ export interface PreparedCommand {
331
+ /** The command to pass to spawn() */
332
+ command: string;
333
+ /** The arguments to pass to spawn() */
334
+ args: string[];
335
+ /** Whether spawn() should use shell: true (Windows non-WSL only) */
336
+ needsShell: boolean;
337
+ }
338
+
339
+ /**
340
+ * Prepare a command for execution by wrapping it in the appropriate
341
+ * platform-specific shell.
342
+ *
343
+ * - **WSL**: Wraps via wrapCommandForWsl (wsl.exe → sh -c → login shell)
344
+ * - **macOS/Linux**: Wraps in login shell (-l -c) with optional PATH injection
345
+ * - **Windows non-WSL**: Escapes for cmd.exe (shell: true)
346
+ *
347
+ * @param command - The command to execute
348
+ * @param args - Command arguments
349
+ * @param cwd - Working directory
350
+ * @param options - Platform and configuration options
351
+ * @returns Prepared command ready for spawn()
352
+ */
353
+ export function prepareShellCommand(
354
+ command: string,
355
+ args: string[],
356
+ cwd: string,
357
+ options: {
358
+ /** Whether WSL mode is enabled (Windows only) */
359
+ wslMode: boolean;
360
+ /** WSL distribution name */
361
+ wslDistribution?: string;
362
+ /** Node.js directory to inject into PATH (absolute path only) */
363
+ nodeDir?: string;
364
+ /**
365
+ * When true, always escape command and args with single quotes.
366
+ * When false, pass command as-is if args is empty (allows shell
367
+ * to parse pipes, &&, etc. in tool_call commands).
368
+ * Default: true
369
+ */
370
+ alwaysEscape?: boolean;
371
+ },
372
+ ): PreparedCommand {
373
+ const alwaysEscape = options.alwaysEscape ?? true;
374
+
375
+ // WSL mode (Windows only)
376
+ if (Platform.isWin && options.wslMode) {
377
+ const wrapped = wrapCommandForWsl(
378
+ command,
379
+ args,
380
+ cwd,
381
+ options.wslDistribution,
382
+ options.nodeDir,
383
+ );
384
+ return {
385
+ command: wrapped.command,
386
+ args: wrapped.args,
387
+ needsShell: false,
388
+ };
389
+ }
390
+
391
+ // macOS / Linux — login shell
392
+ if (Platform.isMacOS || Platform.isLinux) {
393
+ const shell = getLoginShell();
394
+ let commandString: string;
395
+ if (args.length > 0 || alwaysEscape) {
396
+ commandString = [command, ...args]
397
+ .map(escapeShellArgBash)
398
+ .join(" ");
399
+ } else {
400
+ commandString = command;
401
+ }
402
+
403
+ // Prepend PATH export if nodeDir is provided
404
+ if (options.nodeDir) {
405
+ const escapedNodeDir = options.nodeDir.replace(/'/g, "'\\''");
406
+ commandString = `export PATH='${escapedNodeDir}':"$PATH"; ${commandString}`;
407
+ }
408
+
409
+ return {
410
+ command: shell,
411
+ args: ["-l", "-c", commandString],
412
+ needsShell: false,
413
+ };
414
+ }
415
+
416
+ // Windows (non-WSL) — cmd.exe
417
+ if (args.length > 0 || alwaysEscape) {
418
+ return {
419
+ command: escapeShellArgWindows(command),
420
+ args: args.map(escapeShellArgWindows),
421
+ needsShell: true,
422
+ };
423
+ }
424
+ return { command, args, needsShell: true };
425
+ }