@cruxy/cli 0.29.3 → 0.29.5

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.
@@ -1,6 +1,7 @@
1
1
  import { CruxyError, providerUnsupported } from "../errors/index.js";
2
2
  import { resolveTaskModel, } from "../routing/index.js";
3
3
  import { buildSystemPrompt } from "./prompts.js";
4
+ import { resolveShell } from "../tools/shell/resolve-shell.js";
4
5
  /** Tools whose successful call is a file change (drives the `on-file-change`
5
6
  * hook). Kept in sync with the file-mutating tool set. */
6
7
  const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "apply_patch"]);
@@ -45,6 +46,10 @@ async function driveLoop(args, renderer, routed) {
45
46
  let messages = [...args.messages];
46
47
  const usage = { input_tokens: 0, output_tokens: 0 };
47
48
  const maxIterations = config.agent.maxIterations;
49
+ // Resolve the host shell up front (JC#8) so a Windows box with no usable shell
50
+ // fails loud NOW (CRUXY_E_NO_SHELL) rather than mid-task, and the model gets the
51
+ // dialect directive from turn 1. Memoized, so run_command / run_tests reuse it.
52
+ const shellPlan = resolveShell(config.shell);
48
53
  // The tool catalogue and environment are stable across the loop, so build the
49
54
  // system prompt once. The builder degrades gracefully when git is null.
50
55
  const system = buildSystemPrompt({
@@ -60,6 +65,7 @@ async function driveLoop(args, renderer, routed) {
60
65
  recalledMemory: args.recalledMemory ?? null,
61
66
  planMode: args.planMode ?? false,
62
67
  subagent: args.subagent ?? false,
68
+ shellDialect: shellPlan.dialect,
63
69
  });
64
70
  let iterations = 0;
65
71
  for (let i = 0; i < maxIterations; i++) {
@@ -6,6 +6,7 @@
6
6
  * dynamic environment block, so the model always knows where it is, what it
7
7
  * can do, and how it's expected to behave.
8
8
  */
9
+ import type { ShellDialect } from "../tools/shell/resolve-shell.js";
9
10
  export interface ToolSummary {
10
11
  name: string;
11
12
  }
@@ -38,6 +39,13 @@ export interface PromptContext {
38
39
  planMode?: boolean;
39
40
  /** Subagent run (C.14): inject the bounded-subtask directive. */
40
41
  subagent?: boolean;
42
+ /**
43
+ * The resolved shell's command dialect (from resolve-shell.ts). When it is not
44
+ * a POSIX shell — e.g. PowerShell, because no Git Bash was found on Windows —
45
+ * a directive tells the model to emit that dialect instead of bash. Absent /
46
+ * "posix" → no directive (bash-isms are correct there; a nudge would be wrong).
47
+ */
48
+ shellDialect?: ShellDialect;
41
49
  }
42
50
  /** Assemble the full system prompt for a session. */
43
51
  export declare function buildSystemPrompt(ctx: PromptContext): string;
@@ -70,6 +70,29 @@ function renderEnvironment(ctx) {
70
70
  }
71
71
  return `## Environment\n${lines.join("\n")}`;
72
72
  }
73
+ /**
74
+ * The command-dialect directive (JC#4). Emitted ONLY for a non-POSIX shell:
75
+ * under bash / Git Bash the model's default bash syntax is correct, so a "use
76
+ * PowerShell" nudge would be actively wrong. Keeps the model from emitting
77
+ * bash-isms that a Windows PowerShell/cmd shell would silently mis-run.
78
+ */
79
+ function renderShellDialect(dialect) {
80
+ if (!dialect || dialect === "posix")
81
+ return null;
82
+ if (dialect === "powershell") {
83
+ return `## Shell dialect
84
+ \`run_command\` and \`run_tests\` execute through **PowerShell**, not a POSIX/bash shell. Emit native PowerShell:
85
+ - Sequence with \`;\`; run-on-success with \`if ($?) { ... }\` — NOT bash \`&&\` / \`||\`.
86
+ - Environment variables are \`$env:NAME\` (read) and \`$env:NAME = "v"\` (set) — never \`$NAME\` or \`export NAME=\`.
87
+ - Discard output with \`2>$null\` / \`| Out-Null\`, not \`2>/dev/null\`; no heredocs or single-quote-escaping tricks.
88
+ Cross-platform tools (npm, pnpm, git, cargo, node, python) take the same arguments here as anywhere.`;
89
+ }
90
+ return `## Shell dialect
91
+ \`run_command\` and \`run_tests\` execute through **cmd.exe**, not a POSIX/bash shell. Emit native cmd:
92
+ - Sequence with \`&\`; environment variables are \`%NAME%\`; set them with \`set NAME=value\`.
93
+ - No bash constructs (\`export\`, single-quoted strings, \`2>/dev/null\`, heredocs).
94
+ Cross-platform tools (npm, git, cargo, node) take the same arguments here as anywhere.`;
95
+ }
73
96
  function renderTools(tools) {
74
97
  if (tools.length === 0) {
75
98
  return "## Tools\nNo tools are available this session; respond in text only.";
@@ -89,6 +112,9 @@ export function buildSystemPrompt(ctx) {
89
112
  const approval = "Side-effecting actions (file writes, shell commands) require the user's approval; destructive or irreversible actions are flagged distinctly.";
90
113
  const core = CORE.replace("${APPROVAL_CLAUSE}", approval);
91
114
  const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
115
+ const dialect = renderShellDialect(ctx.shellDialect);
116
+ if (dialect)
117
+ sections.push(dialect);
92
118
  if (ctx.planMode)
93
119
  sections.push(PLAN_MODE_SECTION);
94
120
  if (ctx.subagent)
@@ -25,6 +25,7 @@ export function testCommand() {
25
25
  cwd,
26
26
  timeoutMs: config.shell.timeoutMs,
27
27
  captureBytes: config.test.captureBytes,
28
+ shell: config.shell,
28
29
  });
29
30
  const seconds = (result.durationMs / 1000).toFixed(1);
30
31
  if (result.passed) {
@@ -105,13 +105,34 @@ export declare const ShellConfigSchema: z.ZodObject<{
105
105
  timeoutMs: z.ZodDefault<z.ZodNumber>;
106
106
  /** Cap on combined stdout+stderr bytes captured; the rest is truncated. */
107
107
  maxOutputBytes: z.ZodDefault<z.ZodNumber>;
108
+ /**
109
+ * Explicit shell to run commands through, overriding platform detection. A
110
+ * path (`/bin/bash`, `C:\\Program Files\\Git\\bin\\bash.exe`, `pwsh`). When
111
+ * set it WINS over the Git Bash → PowerShell tiering (see resolve-shell.ts):
112
+ * the user owns the choice. Unset → POSIX uses the default shell; Windows
113
+ * probes for Git Bash, then PowerShell, then fails loud (CRUXY_E_NO_SHELL).
114
+ */
115
+ executable: z.ZodOptional<z.ZodString>;
116
+ /**
117
+ * The command dialect of `executable`, so the model is told which syntax to
118
+ * emit. Only sniffed automatically for unambiguous basenames (bash/sh →
119
+ * posix, pwsh/powershell → powershell, cmd → cmd); set this for anything else
120
+ * or the override is rejected (CRUXY_E_CONFIG_INVALID). Ignored without
121
+ * `executable`.
122
+ */
123
+ dialect: z.ZodOptional<z.ZodEnum<["posix", "powershell", "cmd"]>>;
108
124
  }, "strict", z.ZodTypeAny, {
109
125
  timeoutMs: number;
110
126
  maxOutputBytes: number;
127
+ executable?: string | undefined;
128
+ dialect?: "posix" | "powershell" | "cmd" | undefined;
111
129
  }, {
112
130
  timeoutMs?: number | undefined;
113
131
  maxOutputBytes?: number | undefined;
132
+ executable?: string | undefined;
133
+ dialect?: "posix" | "powershell" | "cmd" | undefined;
114
134
  }>;
135
+ export type ShellConfig = z.infer<typeof ShellConfigSchema>;
115
136
  /** Context-window management: when to compact the running conversation. */
116
137
  export declare const ContextConfigSchema: z.ZodObject<{
117
138
  /** Approximate model context budget, in tokens (heuristic estimate). */
@@ -1096,12 +1117,32 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1096
1117
  timeoutMs: z.ZodDefault<z.ZodNumber>;
1097
1118
  /** Cap on combined stdout+stderr bytes captured; the rest is truncated. */
1098
1119
  maxOutputBytes: z.ZodDefault<z.ZodNumber>;
1120
+ /**
1121
+ * Explicit shell to run commands through, overriding platform detection. A
1122
+ * path (`/bin/bash`, `C:\\Program Files\\Git\\bin\\bash.exe`, `pwsh`). When
1123
+ * set it WINS over the Git Bash → PowerShell tiering (see resolve-shell.ts):
1124
+ * the user owns the choice. Unset → POSIX uses the default shell; Windows
1125
+ * probes for Git Bash, then PowerShell, then fails loud (CRUXY_E_NO_SHELL).
1126
+ */
1127
+ executable: z.ZodOptional<z.ZodString>;
1128
+ /**
1129
+ * The command dialect of `executable`, so the model is told which syntax to
1130
+ * emit. Only sniffed automatically for unambiguous basenames (bash/sh →
1131
+ * posix, pwsh/powershell → powershell, cmd → cmd); set this for anything else
1132
+ * or the override is rejected (CRUXY_E_CONFIG_INVALID). Ignored without
1133
+ * `executable`.
1134
+ */
1135
+ dialect: z.ZodOptional<z.ZodEnum<["posix", "powershell", "cmd"]>>;
1099
1136
  }, "strict", z.ZodTypeAny, {
1100
1137
  timeoutMs: number;
1101
1138
  maxOutputBytes: number;
1139
+ executable?: string | undefined;
1140
+ dialect?: "posix" | "powershell" | "cmd" | undefined;
1102
1141
  }, {
1103
1142
  timeoutMs?: number | undefined;
1104
1143
  maxOutputBytes?: number | undefined;
1144
+ executable?: string | undefined;
1145
+ dialect?: "posix" | "powershell" | "cmd" | undefined;
1105
1146
  }>>;
1106
1147
  context: z.ZodDefault<z.ZodObject<{
1107
1148
  /** Approximate model context budget, in tokens (heuristic estimate). */
@@ -1830,6 +1871,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1830
1871
  shell: {
1831
1872
  timeoutMs: number;
1832
1873
  maxOutputBytes: number;
1874
+ executable?: string | undefined;
1875
+ dialect?: "posix" | "powershell" | "cmd" | undefined;
1833
1876
  };
1834
1877
  agent: {
1835
1878
  maxIterations: number;
@@ -1985,6 +2028,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1985
2028
  shell?: {
1986
2029
  timeoutMs?: number | undefined;
1987
2030
  maxOutputBytes?: number | undefined;
2031
+ executable?: string | undefined;
2032
+ dialect?: "posix" | "powershell" | "cmd" | undefined;
1988
2033
  } | undefined;
1989
2034
  agent?: {
1990
2035
  maxIterations?: number | undefined;
@@ -85,6 +85,22 @@ export const ShellConfigSchema = z
85
85
  timeoutMs: z.number().int().positive().default(120000),
86
86
  /** Cap on combined stdout+stderr bytes captured; the rest is truncated. */
87
87
  maxOutputBytes: z.number().int().positive().default(102400),
88
+ /**
89
+ * Explicit shell to run commands through, overriding platform detection. A
90
+ * path (`/bin/bash`, `C:\\Program Files\\Git\\bin\\bash.exe`, `pwsh`). When
91
+ * set it WINS over the Git Bash → PowerShell tiering (see resolve-shell.ts):
92
+ * the user owns the choice. Unset → POSIX uses the default shell; Windows
93
+ * probes for Git Bash, then PowerShell, then fails loud (CRUXY_E_NO_SHELL).
94
+ */
95
+ executable: z.string().min(1).optional(),
96
+ /**
97
+ * The command dialect of `executable`, so the model is told which syntax to
98
+ * emit. Only sniffed automatically for unambiguous basenames (bash/sh →
99
+ * posix, pwsh/powershell → powershell, cmd → cmd); set this for anything else
100
+ * or the override is rejected (CRUXY_E_CONFIG_INVALID). Ignored without
101
+ * `executable`.
102
+ */
103
+ dialect: z.enum(["posix", "powershell", "cmd"]).optional(),
88
104
  })
89
105
  .strict();
90
106
  /** Context-window management: when to compact the running conversation. */
@@ -207,6 +207,12 @@ export declare const ErrorCode: {
207
207
  * `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
208
208
  * rather than pretending there are simply no jobs. */
209
209
  readonly JobsDisabled: "CRUXY_E_JOBS_DISABLED";
210
+ /** No usable host shell was found to run commands through. On Windows this
211
+ * means neither Git Bash nor PowerShell could be located and no
212
+ * `shell.executable` override was set. Fail loud at session start — NEVER
213
+ * silently fall back to cmd.exe running bash-shaped commands (which
214
+ * mis-parses `;`/`&&`/quoting and can report a false success). */
215
+ readonly NoShell: "CRUXY_E_NO_SHELL";
210
216
  /** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
211
217
  * a hard stop (the iteration cap or a token budget) or was cancelled, rather
212
218
  * than finishing on its own. Fail loud with a non-zero exit so CI never reads a
@@ -228,6 +228,13 @@ export const ErrorCode = {
228
228
  * `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
229
229
  * rather than pretending there are simply no jobs. */
230
230
  JobsDisabled: "CRUXY_E_JOBS_DISABLED",
231
+ // host shell (exit 21) — the run_command / run_tests execution substrate
232
+ /** No usable host shell was found to run commands through. On Windows this
233
+ * means neither Git Bash nor PowerShell could be located and no
234
+ * `shell.executable` override was set. Fail loud at session start — NEVER
235
+ * silently fall back to cmd.exe running bash-shaped commands (which
236
+ * mis-parses `;`/`&&`/quoting and can report a false success). */
237
+ NoShell: "CRUXY_E_NO_SHELL",
231
238
  // one-shot run outcome (exit 20)
232
239
  /** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
233
240
  * a hard stop (the iteration cap or a token budget) or was cancelled, rather
@@ -360,6 +367,10 @@ const EXIT_CODES = {
360
367
  // cancelled) without completing gets its own greppable exit code, so CI can tell
361
368
  // "the agent gave up" apart from a provider/auth/config failure.
362
369
  [ErrorCode.AgentIncomplete]: 20,
370
+ // Host shell. No usable shell to execute commands through — a fail-loud
371
+ // execution-substrate stop (kin to CRUXY_E_SANDBOX_UNAVAILABLE, but for the
372
+ // host path), with its own greppable exit code.
373
+ [ErrorCode.NoShell]: 21,
363
374
  };
364
375
  /** The process exit code for an error code (defaults to 1 for safety). */
365
376
  export function exitCodeFor(code) {
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawnTree } from "../utils/process-tree.js";
2
2
  import { killTree, killTrackedTrees, registerForCleanup, trackedTreeCount, } from "../utils/child-tree.js";
3
3
  // Re-exported under their historical LSP names so callers and tests keep
4
4
  // importing them from here; the machinery now lives in the shared child-tree
@@ -31,11 +31,11 @@ export class StdioTransport {
31
31
  /** Deregisters this process from the process-exit kill-tree backstop. */
32
32
  unregisterCleanup;
33
33
  constructor(spec, root) {
34
- // `detached` makes the child a process-group leader so the whole tree can be
35
- // killed via a negative-PID signal same discipline as run_command (C.16).
36
- this.child = spawn(spec.command, spec.args, {
34
+ // `spawnTree` heads a killable tree with the platform-correct grouping
35
+ // POSIX process group, or win32 `windowsHide` (NOT `detached`, which opens a
36
+ // console there) — so `killTree` reaps the whole server tree on either OS.
37
+ this.child = spawnTree(spec.command, spec.args, {
37
38
  cwd: root,
38
- detached: true,
39
39
  stdio: ["pipe", "pipe", "pipe"],
40
40
  });
41
41
  this.unregisterCleanup = registerForCleanup(this.child.pid);
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawnTree } from "../utils/process-tree.js";
2
2
  import { killTree, registerForCleanup } from "../utils/child-tree.js";
3
3
  /**
4
4
  * JSON-RPC 2.0 over an MCP server's stdio (C.27). Owns the child process: spawns
@@ -30,12 +30,12 @@ export class McpStdioTransport {
30
30
  disposed = false;
31
31
  unregisterCleanup;
32
32
  constructor(spec, root) {
33
- // `detached` makes the child a process-group leader so the whole tree can be
34
- // killed via a negative-PID signal same discipline as run_command (C.16)
35
- // and the LSP transport (C.12).
36
- this.child = spawn(spec.command, spec.args, {
33
+ // `spawnTree` heads a killable tree with the platform-correct grouping
34
+ // POSIX process group, or win32 `windowsHide` (NOT `detached`, which opens a
35
+ // console there) same discipline as run_command (C.16) and the LSP
36
+ // transport (C.12); `killTree` reaps the whole server tree on either OS.
37
+ this.child = spawnTree(spec.command, spec.args, {
37
38
  cwd: root,
38
- detached: true,
39
39
  stdio: ["pipe", "pipe", "pipe"],
40
40
  env: { ...process.env, ...(spec.env ?? {}) },
41
41
  });
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { killTree, spawnTree } from "../utils/process-tree.js";
3
4
  import { sandboxExec, sandboxImage } from "../errors/index.js";
4
5
  /**
5
6
  * The shipped {@link SandboxRuntime}: shells out to the `docker` CLI (no SDK —
@@ -62,8 +63,11 @@ export class DockerRuntime {
62
63
  const capture = new OutputCapture(opts.maxOutputBytes, opts.capture);
63
64
  let child;
64
65
  try {
65
- // `detached` groups the docker client so a timeout kills the whole tree.
66
- child = spawn(this.bin, argv, { detached: true });
66
+ // `spawnTree` groups the docker client (POSIX process group / win32 OS
67
+ // tree) so a timeout reaps the whole tree via `killTree` on either OS —
68
+ // the docker CLIENT runs on the host, so on Windows this needs the
69
+ // taskkill walk, not a POSIX-only negative-PID signal.
70
+ child = spawnTree(this.bin, argv);
67
71
  }
68
72
  catch (err) {
69
73
  reject(sandboxExec(err));
@@ -121,9 +125,8 @@ export class DockerRuntime {
121
125
  /** Best-effort container teardown after a timeout kill. */
122
126
  forceRemove(container) {
123
127
  try {
124
- const rm = spawn(this.bin, ["rm", "-f", container], {
128
+ const rm = spawnTree(this.bin, ["rm", "-f", container], {
125
129
  stdio: "ignore",
126
- detached: true,
127
130
  });
128
131
  rm.on("error", () => { });
129
132
  rm.unref();
@@ -253,14 +256,3 @@ class OutputCapture {
253
256
  return { output: all.toString("utf8"), truncated: this.truncated };
254
257
  }
255
258
  }
256
- /** Kill the docker client's process group (POSIX; matches run_command). */
257
- function killTree(pid) {
258
- if (pid === undefined)
259
- return;
260
- try {
261
- process.kill(-pid, "SIGKILL");
262
- }
263
- catch {
264
- // Already exited, or no group — nothing to kill.
265
- }
266
- }
@@ -141,6 +141,7 @@ export function makeRunTestsTool(deps = {}) {
141
141
  cwd: ctx.cwd,
142
142
  timeoutMs: ctx.config.shell.timeoutMs,
143
143
  captureBytes: ctx.config.test.captureBytes,
144
+ shell: ctx.config.shell,
144
145
  });
145
146
  budget.record(result.passed);
146
147
  const payload = renderResult(result, resolved, {
@@ -1,4 +1,5 @@
1
- import { killTree, spawnTree } from "../utils/process-tree.js";
1
+ import { killTree } from "../utils/process-tree.js";
2
+ import { spawnShell } from "../tools/shell/resolve-shell.js";
2
3
  import { parseFailures } from "./parse.js";
3
4
  /**
4
5
  * The shipped {@link TestRunner}: spawn the command via the system shell (the
@@ -15,10 +16,11 @@ export class CommandTestRunner {
15
16
  const capture = new TailCapture(opts.captureBytes);
16
17
  let child;
17
18
  try {
18
- // Same killable-tree discipline as run_command: `spawnTree` groups the
19
- // shell (POSIX process group / win32 OS tree) so a timeout can reap the
20
- // whole tree via `killTree`.
21
- child = spawnTree(command, [], { shell: true, cwd: opts.cwd });
19
+ // Same seam as run_command: `spawnShell` routes through the resolved
20
+ // shell (Git Bash / PowerShell on Windows) and groups it as a killable
21
+ // tree (POSIX process group / win32 OS tree) so a timeout reaps the whole
22
+ // tree via `killTree`.
23
+ child = spawnShell(command, opts.shell, { cwd: opts.cwd });
22
24
  }
23
25
  catch (err) {
24
26
  resolve(failed(null, err.message, startedAt));
@@ -4,6 +4,7 @@
4
4
  * under a hard cap. The cardinal rule lives in `runner.ts`: **passed is
5
5
  * derived from the exit code and nothing else.**
6
6
  */
7
+ import type { ShellSelection } from "../tools/shell/resolve-shell.js";
7
8
  /** A resolved test command and where it came from — shown, never guessed. */
8
9
  export interface TestCommand {
9
10
  command: string;
@@ -40,10 +41,14 @@ export interface TestRunOptions {
40
41
  timeoutMs: number;
41
42
  /** Cap on captured output bytes (tail-biased). */
42
43
  captureBytes: number;
44
+ /** Shell selection, so the host runner routes through the resolved shell (Git
45
+ * Bash / PowerShell on Windows) — the same seam as run_command. The sandbox
46
+ * runner ignores it (the container has its own shell). */
47
+ shell: ShellSelection;
43
48
  }
44
49
  /**
45
50
  * The swappable execution seam (same discipline as VectorStore/ForgeProvider):
46
- * the shipped {@link CommandTestRunner} spawns the command via the system
51
+ * the shipped {@link CommandTestRunner} spawns the command via the resolved
47
52
  * shell; tests inject fakes, and a future framework-native runner (e.g. a
48
53
  * vitest API runner) slots in without touching the tool.
49
54
  */
@@ -1,7 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
- import { resolveToolPath } from "./paths.js";
4
+ import { resolveToolPath, toPosix } from "./paths.js";
5
5
  import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
6
6
  /** How many leading lines of a created file the approval preview shows. */
7
7
  const PREVIEW_LINES = 20;
@@ -116,7 +116,9 @@ export const applyPatchTool = {
116
116
  };
117
117
  /** Validate one operation against the filesystem and compute its final bytes. */
118
118
  async function planOp(i, op, abs, ctx) {
119
- const rel = path.relative(ctx.cwd, abs);
119
+ // Forward-slash for model-facing output (the `applied` lines and error
120
+ // messages), consistent with every other path tool — see {@link toPosix}.
121
+ const rel = toPosix(path.relative(ctx.cwd, abs));
120
122
  if (op.type === "create") {
121
123
  if (await exists(abs)) {
122
124
  return { ok: false, error: opError(i, op, "file already exists") };
@@ -69,6 +69,16 @@ export declare function resolveInRoot(ctx: ToolContext, p: string): Promise<stri
69
69
  * shared by `glob` and `grep_files` so the two walk-rooted tools cannot diverge
70
70
  * (closing G1/G2). Path *arguments* go through {@link resolveToolPath}; glob
71
71
  * *patterns* are not resolvable paths, so this predicate guards the walk instead.
72
+ *
73
+ * NB — a known internal inconsistency, documented rather than fixed: this split on
74
+ * `/[/\\]/` (and the sibling `firstSegmentIsRoot` in grep_files) treats `\` as a
75
+ * separator on EVERY platform, but the resolution kernel {@link confineToRoot} →
76
+ * `path.resolve` does NOT on POSIX (there `\` is a legal filename character). The
77
+ * two only diverge for a `..\`-style argument on POSIX, which is unreachable from
78
+ * anything the tools EMIT (grep/apply_patch produce `\` only on Windows, where
79
+ * `path.resolve` does treat it as a separator). It is left as-is deliberately:
80
+ * "fixing" confineToRoot to fold `\`→`/` on POSIX would break access to files
81
+ * whose names legitimately contain a backslash.
72
82
  */
73
83
  export declare function isEscapingPattern(pattern: string): boolean;
74
84
  /**
@@ -76,6 +86,25 @@ export declare function isEscapingPattern(pattern: string): boolean;
76
86
  * constant so every no-path tool that fans across roots renders the same label.
77
87
  */
78
88
  export declare const ROOT_LABEL_SEP = " \u25B8 ";
89
+ /**
90
+ * Rewrite the OS path separator to forward slashes for MODEL-FACING output — the
91
+ * one display convention every path tool shares. The rest of the toolset already
92
+ * emits POSIX paths on Windows (the indexer normalises the same way in
93
+ * `indexing/walker.ts`, and git's porcelain and tinyglobby both hand back `/`);
94
+ * this seam is what keeps the two `path.relative`-based stragglers, `grep_files`
95
+ * and `apply_patch`, from drifting to native `src\b.ts`.
96
+ *
97
+ * It splits on `path.sep`, NOT a literal `\`, so it only ever rewrites the real OS
98
+ * separator: on POSIX (`path.sep === "/"`) it is a no-op that PRESERVES a literal
99
+ * backslash in a filename — legal on POSIX — rather than mangling it; on Windows
100
+ * (`path.sep === "\\"`) it turns `path.relative`'s `src\b.ts` into `src/b.ts`.
101
+ *
102
+ * Purely cosmetic at the boundary — the model never needs native separators back:
103
+ * a path it echoes into read_file/edit_file resolves through {@link confineToRoot}
104
+ * → `path.resolve`, which accepts `/` on Windows, so the forward-slash form
105
+ * round-trips as-is.
106
+ */
107
+ export declare function toPosix(p: string): string;
79
108
  /**
80
109
  * Prefix a root-relative path with its root name — but ONLY in a genuine
81
110
  * multi-root session. In single-root the label is dropped so output stays
@@ -92,6 +92,16 @@ export async function resolveInRoot(ctx, p) {
92
92
  * shared by `glob` and `grep_files` so the two walk-rooted tools cannot diverge
93
93
  * (closing G1/G2). Path *arguments* go through {@link resolveToolPath}; glob
94
94
  * *patterns* are not resolvable paths, so this predicate guards the walk instead.
95
+ *
96
+ * NB — a known internal inconsistency, documented rather than fixed: this split on
97
+ * `/[/\\]/` (and the sibling `firstSegmentIsRoot` in grep_files) treats `\` as a
98
+ * separator on EVERY platform, but the resolution kernel {@link confineToRoot} →
99
+ * `path.resolve` does NOT on POSIX (there `\` is a legal filename character). The
100
+ * two only diverge for a `..\`-style argument on POSIX, which is unreachable from
101
+ * anything the tools EMIT (grep/apply_patch produce `\` only on Windows, where
102
+ * `path.resolve` does treat it as a separator). It is left as-is deliberately:
103
+ * "fixing" confineToRoot to fold `\`→`/` on POSIX would break access to files
104
+ * whose names legitimately contain a backslash.
95
105
  */
96
106
  export function isEscapingPattern(pattern) {
97
107
  return path.isAbsolute(pattern) || pattern.split(/[/\\]/).includes("..");
@@ -101,6 +111,27 @@ export function isEscapingPattern(pattern) {
101
111
  * constant so every no-path tool that fans across roots renders the same label.
102
112
  */
103
113
  export const ROOT_LABEL_SEP = " ▸ ";
114
+ /**
115
+ * Rewrite the OS path separator to forward slashes for MODEL-FACING output — the
116
+ * one display convention every path tool shares. The rest of the toolset already
117
+ * emits POSIX paths on Windows (the indexer normalises the same way in
118
+ * `indexing/walker.ts`, and git's porcelain and tinyglobby both hand back `/`);
119
+ * this seam is what keeps the two `path.relative`-based stragglers, `grep_files`
120
+ * and `apply_patch`, from drifting to native `src\b.ts`.
121
+ *
122
+ * It splits on `path.sep`, NOT a literal `\`, so it only ever rewrites the real OS
123
+ * separator: on POSIX (`path.sep === "/"`) it is a no-op that PRESERVES a literal
124
+ * backslash in a filename — legal on POSIX — rather than mangling it; on Windows
125
+ * (`path.sep === "\\"`) it turns `path.relative`'s `src\b.ts` into `src/b.ts`.
126
+ *
127
+ * Purely cosmetic at the boundary — the model never needs native separators back:
128
+ * a path it echoes into read_file/edit_file resolves through {@link confineToRoot}
129
+ * → `path.resolve`, which accepts `/` on Windows, so the forward-slash form
130
+ * round-trips as-is.
131
+ */
132
+ export function toPosix(p) {
133
+ return p.split(path.sep).join("/");
134
+ }
104
135
  /**
105
136
  * Prefix a root-relative path with its root name — but ONLY in a genuine
106
137
  * multi-root session. In single-root the label is dropped so output stays
@@ -109,11 +140,13 @@ export const ROOT_LABEL_SEP = " ▸ ";
109
140
  * point at a different root than the one that was walked (the honesty pin).
110
141
  */
111
142
  export function labelPath(root, rel, isMultiRoot) {
112
- return isMultiRoot ? labelName(root.name, rel) : rel;
143
+ // `rel` is normalised to forward slashes exactly once on each branch: via
144
+ // {@link labelName} when labelled, or directly when the label is dropped.
145
+ return isMultiRoot ? labelName(root.name, rel) : toPosix(rel);
113
146
  }
114
147
  /** As {@link labelPath} but from a bare root name (for hits that carry only a name). */
115
148
  export function labelName(rootName, rel) {
116
- return `${rootName}${ROOT_LABEL_SEP}${rel}`;
149
+ return `${rootName}${ROOT_LABEL_SEP}${toPosix(rel)}`;
117
150
  }
118
151
  /**
119
152
  * The Funnel-B selector for **no-path** tools (`glob`, `grep_files`, `list_files`,
@@ -1,4 +1,5 @@
1
- import { killTree, spawnTree } from "../../utils/process-tree.js";
1
+ import { killTree } from "../../utils/process-tree.js";
2
+ import { spawnShell } from "./resolve-shell.js";
2
3
  /**
3
4
  * Gate `command` through `ctx.requestApproval`, then — only if allowed — execute
4
5
  * it via the sandbox (when `ctx.sandbox` is set) or the bounded host spawn. A
@@ -50,10 +51,12 @@ async function runSandboxed(command, ctx) {
50
51
  function runBounded(command, ctx) {
51
52
  const { timeoutMs, maxOutputBytes } = ctx.config.shell;
52
53
  return new Promise((resolve) => {
53
- // `spawnTree` makes the child the head of a killable tree (its own process
54
- // group on POSIX; the OS parent-PID tree on win32) so the whole tree — the
55
- // shell plus anything it spawns can be killed on timeout via `killTree`.
56
- const child = spawnTree(command, [], { shell: true, cwd: ctx.cwd });
54
+ // `spawnShell` routes through the resolved shell (POSIX default shell, Git
55
+ // Bash, or PowerShell see resolve-shell.ts) as the head of a killable tree
56
+ // (its own process group on POSIX; the OS parent-PID tree on win32), so the
57
+ // whole tree the shell plus anything it spawns — can be killed via
58
+ // `killTree`. A win32 host with no usable shell threw at session start.
59
+ const child = spawnShell(command, ctx.config.shell, { cwd: ctx.cwd });
57
60
  const chunks = [];
58
61
  let captured = 0;
59
62
  let truncated = false;
@@ -0,0 +1,81 @@
1
+ import { type ChildProcess, type SpawnOptions } from "node:child_process";
2
+ /**
3
+ * WHICH shell a host command runs through, and — just as important — WHICH
4
+ * dialect the model must emit for it. `run_command` and `run_tests` both spawn
5
+ * with the platform default shell (`{ shell: true }` → `/bin/sh` on POSIX,
6
+ * `%ComSpec%`/cmd.exe on win32). The model emits bash, so on Windows a command
7
+ * like `echo x; exit 1` runs as a single `echo`, exits 0, and reads as a false
8
+ * success. This module makes Windows host execution first-class instead:
9
+ *
10
+ * Tier 0 `shell.executable` override → use it verbatim (the user owns it).
11
+ * Tier 1 Git Bash (Git for Windows) → full POSIX parity, no model changes.
12
+ * Tier 2 PowerShell (pwsh, else powershell.exe) → the model is told, via a
13
+ * system-prompt directive keyed on {@link ShellPlan.dialect}, to emit
14
+ * native PowerShell.
15
+ * Tier 3 nothing usable → throw CRUXY_E_NO_SHELL. We NEVER hand bash-shaped
16
+ * strings to cmd.exe silently — that is the exact false-green above.
17
+ *
18
+ * A non-default shell is ALWAYS invoked with explicit argv (`bash -c <cmd>`,
19
+ * `powershell -NoProfile -Command <cmd>`), never Node's `shell: <path>` string
20
+ * form: Node builds that command line with cmd.exe quoting rules, which mangle a
21
+ * command bound for bash/PowerShell. POSIX with no override keeps `shell: true`
22
+ * verbatim — a zero-change path off Windows.
23
+ */
24
+ export type ShellDialect = "posix" | "powershell" | "cmd";
25
+ export type ShellSource = "default" | "config" | "git-bash" | "powershell";
26
+ /**
27
+ * The shell-selection fields the resolver reads — a structural subset of the
28
+ * config's `ShellConfig` (which also carries `timeoutMs`/`maxOutputBytes`). Kept
29
+ * narrow so callers and tests pass only what selection needs.
30
+ */
31
+ export interface ShellSelection {
32
+ executable?: string;
33
+ dialect?: ShellDialect;
34
+ }
35
+ /** How to spawn a host command, and which dialect the model should emit for it. */
36
+ export type ShellPlan =
37
+ /** POSIX default: `spawn(command, { shell: true })`, byte-identical to before. */
38
+ {
39
+ kind: "system";
40
+ dialect: "posix";
41
+ source: "default";
42
+ }
43
+ /** Explicit shell binary + fixed flags; the command is the final argv element. */
44
+ | {
45
+ kind: "explicit";
46
+ file: string;
47
+ flags: readonly string[];
48
+ dialect: ShellDialect;
49
+ source: ShellSource;
50
+ };
51
+ /** Injected so the resolver's win32 logic is unit-testable on a POSIX CI host. */
52
+ export interface ShellEnv {
53
+ platform: NodeJS.Platform;
54
+ env: NodeJS.ProcessEnv;
55
+ exists: (candidate: string) => boolean;
56
+ }
57
+ /**
58
+ * Infer a dialect from a shell path's basename, but ONLY for the unambiguous
59
+ * names — anything exotic (`nu`, `fish`, `xonsh`, …) returns undefined so the
60
+ * caller demands an explicit `shell.dialect` rather than guessing wrong.
61
+ */
62
+ export declare function sniffDialect(executable: string): ShellDialect | undefined;
63
+ /**
64
+ * Resolve the shell PLAN from config + environment. Pure over its injected
65
+ * {@link ShellEnv}, so the win32 tiers are exercised on any CI host. Throws
66
+ * CRUXY_E_NO_SHELL (no usable shell) or CRUXY_E_CONFIG_INVALID (an override
67
+ * whose dialect can't be determined) — both fail loud, never a silent fallback.
68
+ */
69
+ export declare function computeShellPlan(shell: ShellSelection, env?: ShellEnv): ShellPlan;
70
+ /** The memoized production resolver. Throws (fail loud) exactly as compute does. */
71
+ export declare function resolveShell(shell: ShellSelection): ShellPlan;
72
+ /** Test hook: drop the memoized plan so the next resolve re-detects. */
73
+ export declare function __resetShellResolution(): void;
74
+ /**
75
+ * Spawn `command` through the resolved shell as a killable process tree — the
76
+ * single seam both `run_command` and `run_tests` route through. `system` keeps
77
+ * the exact `{ shell: true }` spawn; every other plan invokes the shell binary
78
+ * with explicit argv so the command reaches bash/PowerShell unmangled (see the
79
+ * module header).
80
+ */
81
+ export declare function spawnShell(command: string, shell: ShellSelection, options?: SpawnOptions): ChildProcess;
@@ -0,0 +1,158 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnTree } from "../../utils/process-tree.js";
4
+ import { CruxyError, ErrorCode } from "../../errors/types.js";
5
+ const realEnv = {
6
+ platform: process.platform,
7
+ env: process.env,
8
+ exists: existsSync,
9
+ };
10
+ /** The invocation flags for each dialect (the command follows as one argv slot). */
11
+ function flagsFor(dialect) {
12
+ switch (dialect) {
13
+ case "powershell":
14
+ // `-NoProfile` skips slow/interfering user profiles; `-Command` takes the
15
+ // command as one string argument.
16
+ return ["-NoProfile", "-Command"];
17
+ case "cmd":
18
+ return ["/d", "/s", "/c"];
19
+ case "posix":
20
+ return ["-c"];
21
+ }
22
+ }
23
+ function explicit(file, dialect, source) {
24
+ return { kind: "explicit", file, flags: flagsFor(dialect), dialect, source };
25
+ }
26
+ /**
27
+ * Infer a dialect from a shell path's basename, but ONLY for the unambiguous
28
+ * names — anything exotic (`nu`, `fish`, `xonsh`, …) returns undefined so the
29
+ * caller demands an explicit `shell.dialect` rather than guessing wrong.
30
+ */
31
+ export function sniffDialect(executable) {
32
+ // Split on either separator so a POSIX or a Windows path both reduce to the
33
+ // bare shell name, then drop a trailing `.exe`.
34
+ const base = executable.split(/[\\/]/).pop() ?? executable;
35
+ const name = base.toLowerCase().replace(/\.exe$/, "");
36
+ switch (name) {
37
+ case "bash":
38
+ case "sh":
39
+ return "posix";
40
+ case "pwsh":
41
+ case "powershell":
42
+ return "powershell";
43
+ case "cmd":
44
+ return "cmd";
45
+ default:
46
+ return undefined;
47
+ }
48
+ }
49
+ /** True for the WSL launcher (`C:\Windows\System32\bash.exe`) — a Linux shell
50
+ * with `/mnt/c` path semantics, never Git Bash. Explicitly rejected. */
51
+ function isWslBash(candidate) {
52
+ return /\\system32\\/i.test(candidate);
53
+ }
54
+ /** Probe the well-known Git-for-Windows install locations for `bash.exe`. */
55
+ function findGitBash(env) {
56
+ const roots = [
57
+ env.env["ProgramFiles"],
58
+ env.env["ProgramW6432"],
59
+ env.env["ProgramFiles(x86)"],
60
+ env.env["LOCALAPPDATA"] &&
61
+ path.win32.join(env.env["LOCALAPPDATA"], "Programs"),
62
+ ].filter((r) => Boolean(r));
63
+ for (const root of roots) {
64
+ const candidate = path.win32.join(root, "Git", "bin", "bash.exe");
65
+ // Path-probe only — never `where bash`, which resolves the WSL launcher.
66
+ if (isWslBash(candidate))
67
+ continue;
68
+ if (env.exists(candidate))
69
+ return candidate;
70
+ }
71
+ return undefined;
72
+ }
73
+ /** Resolve a bare executable name against PATH (+ the win32 PATH dir walk). */
74
+ function onWindowsPath(name, env) {
75
+ const raw = env.env["PATH"] ?? env.env["Path"] ?? "";
76
+ for (const dir of raw.split(";").filter(Boolean)) {
77
+ const full = path.win32.join(dir, name);
78
+ if (env.exists(full))
79
+ return full;
80
+ }
81
+ return undefined;
82
+ }
83
+ /** PowerShell 7+ (`pwsh`) preferred; Windows PowerShell 5.1 the guaranteed floor. */
84
+ function findPowerShell(env) {
85
+ return onWindowsPath("pwsh.exe", env) ?? onWindowsPath("powershell.exe", env);
86
+ }
87
+ /**
88
+ * Resolve the shell PLAN from config + environment. Pure over its injected
89
+ * {@link ShellEnv}, so the win32 tiers are exercised on any CI host. Throws
90
+ * CRUXY_E_NO_SHELL (no usable shell) or CRUXY_E_CONFIG_INVALID (an override
91
+ * whose dialect can't be determined) — both fail loud, never a silent fallback.
92
+ */
93
+ export function computeShellPlan(shell, env = realEnv) {
94
+ // Tier 0: explicit override always wins, on every platform.
95
+ if (shell.executable) {
96
+ const dialect = shell.dialect ?? sniffDialect(shell.executable);
97
+ if (!dialect) {
98
+ throw new CruxyError({
99
+ code: ErrorCode.ConfigInvalid,
100
+ title: "Cannot determine the dialect of the configured shell",
101
+ cause: `shell.executable is "${shell.executable}", but its command dialect could not be inferred from its name.`,
102
+ nextSteps: ["Set shell.dialect to one of: posix, powershell, cmd."],
103
+ });
104
+ }
105
+ return explicit(shell.executable, dialect, "config");
106
+ }
107
+ // POSIX with no override: the historical default shell, byte-for-byte.
108
+ if (env.platform !== "win32") {
109
+ return { kind: "system", dialect: "posix", source: "default" };
110
+ }
111
+ // Tier 1: Git Bash → full POSIX parity, model unchanged.
112
+ const bash = findGitBash(env);
113
+ if (bash)
114
+ return explicit(bash, "posix", "git-bash");
115
+ // Tier 2: PowerShell → the model is told to emit PowerShell (see prompts.ts).
116
+ const ps = findPowerShell(env);
117
+ if (ps)
118
+ return explicit(ps, "powershell", "powershell");
119
+ // Tier 3: nothing usable — fail loud, never cmd.exe-pretends-to-be-bash.
120
+ throw new CruxyError({
121
+ code: ErrorCode.NoShell,
122
+ title: "No usable shell found to run commands",
123
+ cause: "On Windows, cruxy runs commands through Git Bash or PowerShell; neither was found, and no shell.executable override is set.",
124
+ nextSteps: [
125
+ "Install Git for Windows (https://git-scm.com/download/win) to get Git Bash.",
126
+ "Or set shell.executable in your cruxy config to a shell of your choice (and shell.dialect if its name is non-standard).",
127
+ ],
128
+ });
129
+ }
130
+ // Resolve once per process (JC#8): detection touches the filesystem, and the
131
+ // answer is stable for a session. `computeShellPlan` stays the injectable,
132
+ // un-memoized unit the tests drive.
133
+ let cached;
134
+ /** The memoized production resolver. Throws (fail loud) exactly as compute does. */
135
+ export function resolveShell(shell) {
136
+ return (cached ??= computeShellPlan(shell));
137
+ }
138
+ /** Test hook: drop the memoized plan so the next resolve re-detects. */
139
+ export function __resetShellResolution() {
140
+ cached = undefined;
141
+ }
142
+ /**
143
+ * Spawn `command` through the resolved shell as a killable process tree — the
144
+ * single seam both `run_command` and `run_tests` route through. `system` keeps
145
+ * the exact `{ shell: true }` spawn; every other plan invokes the shell binary
146
+ * with explicit argv so the command reaches bash/PowerShell unmangled (see the
147
+ * module header).
148
+ */
149
+ export function spawnShell(command, shell, options = {}) {
150
+ const plan = resolveShell(shell);
151
+ if (plan.kind === "system") {
152
+ return spawnTree(command, [], { ...options, shell: true });
153
+ }
154
+ return spawnTree(plan.file, [...plan.flags, command], {
155
+ ...options,
156
+ shell: false,
157
+ });
158
+ }
@@ -15,10 +15,13 @@ import { spawn, } from "node:child_process";
15
15
  * could never do on Windows (it would kill only the direct child and orphan
16
16
  * the grandchildren).
17
17
  *
18
- * This module exists because that pairing used to be copy-pasted — three
18
+ * This module exists because that pairing used to be copy-pasted — four
19
19
  * POSIX-only `killTree` variants (run_command, the test runner, the LSP/MCP
20
- * backstop), each of which silently orphaned grandchildren on Windows. There is
21
- * now one implementation; a change to the kill discipline changes every path.
20
+ * backstop, and the docker client), each of which silently orphaned
21
+ * grandchildren on Windows. There is now one implementation; a change to the
22
+ * kill discipline changes every path. Spawn sites likewise route through
23
+ * `spawnTree` (run_command, run_tests, LSP, MCP, docker) so none re-introduces
24
+ * the win32 `detached` console-flash the grouping logic exists to avoid.
22
25
  */
23
26
  const isWindows = process.platform === "win32";
24
27
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.29.3",
3
+ "version": "0.29.5",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {