@cruxy/cli 0.13.0 → 0.16.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 (64) hide show
  1. package/dist/agent/loop.d.ts +14 -0
  2. package/dist/agent/loop.js +47 -1
  3. package/dist/agent/session.d.ts +11 -1
  4. package/dist/agent/session.js +14 -1
  5. package/dist/approval/prompt.js +17 -3
  6. package/dist/brand/index.d.ts +1 -0
  7. package/dist/brand/index.js +1 -0
  8. package/dist/brand/voice.d.ts +74 -0
  9. package/dist/brand/voice.js +73 -0
  10. package/dist/cli/commands/checkpoint.js +1 -1
  11. package/dist/cli/commands/hooks.d.ts +8 -0
  12. package/dist/cli/commands/hooks.js +83 -0
  13. package/dist/cli/commands/init.js +1 -1
  14. package/dist/cli/commands/pr.js +1 -1
  15. package/dist/cli/commands/rollback.js +1 -1
  16. package/dist/cli/commands/run.js +13 -3
  17. package/dist/cli/commands/skills.js +2 -2
  18. package/dist/cli/program.js +5 -2
  19. package/dist/cli/repl.d.ts +2 -1
  20. package/dist/cli/repl.js +54 -3
  21. package/dist/cli/session-factory.d.ts +2 -2
  22. package/dist/cli/session-factory.js +4 -2
  23. package/dist/components/fuzzy.js +7 -1
  24. package/dist/config/schema.d.ts +81 -30
  25. package/dist/config/schema.js +22 -0
  26. package/dist/constants.d.ts +9 -0
  27. package/dist/constants.js +9 -0
  28. package/dist/errors/constructors.d.ts +16 -0
  29. package/dist/errors/constructors.js +57 -0
  30. package/dist/errors/types.d.ts +11 -0
  31. package/dist/errors/types.js +19 -0
  32. package/dist/hooks/config.d.ts +21 -0
  33. package/dist/hooks/config.js +253 -0
  34. package/dist/hooks/index.d.ts +6 -0
  35. package/dist/hooks/index.js +6 -0
  36. package/dist/hooks/runner.d.ts +76 -0
  37. package/dist/hooks/runner.js +114 -0
  38. package/dist/hooks/service.d.ts +38 -0
  39. package/dist/hooks/service.js +49 -0
  40. package/dist/hooks/slash.d.ts +48 -0
  41. package/dist/hooks/slash.js +58 -0
  42. package/dist/hooks/trust.d.ts +46 -0
  43. package/dist/hooks/trust.js +106 -0
  44. package/dist/hooks/types.d.ts +147 -0
  45. package/dist/hooks/types.js +61 -0
  46. package/dist/onboarding/steps.js +5 -2
  47. package/dist/render/capabilities.d.ts +10 -2
  48. package/dist/render/capabilities.js +26 -6
  49. package/dist/render/index.d.ts +9 -5
  50. package/dist/render/index.js +12 -5
  51. package/dist/render/plain-renderer.d.ts +3 -3
  52. package/dist/render/plain-renderer.js +10 -2
  53. package/dist/render/screen-reader-renderer.d.ts +45 -0
  54. package/dist/render/screen-reader-renderer.js +75 -0
  55. package/dist/render/types.d.ts +15 -1
  56. package/dist/theme/resolve.d.ts +18 -7
  57. package/dist/theme/resolve.js +32 -10
  58. package/dist/theme/tokens.d.ts +16 -1
  59. package/dist/theme/tokens.js +27 -0
  60. package/dist/tools/shell/exec.d.ts +53 -0
  61. package/dist/tools/shell/exec.js +128 -0
  62. package/dist/tools/shell/run-command.d.ts +4 -0
  63. package/dist/tools/shell/run-command.js +26 -116
  64. package/package.json +1 -1
@@ -0,0 +1,75 @@
1
+ import { PlainRenderer } from "./plain-renderer.js";
2
+ import { describePhase, phaseIdentity } from "./state.js";
3
+ /**
4
+ * The screen-reader renderer (U.11): {@link PlainRenderer}'s linear,
5
+ * append-only, zero-cursor-control output — plus two things a screen reader
6
+ * needs that the plain path drops.
7
+ *
8
+ * 1. **Worded status.** Its theme resolves the {@link SCREEN_READER_GLYPHS}
9
+ * table (via `caps.screenReader`), so every inherited code path that writes
10
+ * `theme.glyph.success` prints `done` instead of `✓`, `failed` for `✗`, etc.
11
+ * Nothing here special-cases it — the glyph table does the work.
12
+ *
13
+ * 2. **Announced state.** A screen reader cannot see an in-place live region, so
14
+ * each state change is emitted as its own committed line (never a redraw):
15
+ * a phase becomes `working: read_file src/x.ts`, a tool run brackets as
16
+ * `working: …` → `done …`, and plan progress announces `step 2 of 5: title`.
17
+ * Announcements are deduped by phase *identity* so token-count updates within
18
+ * the same activity don't repeat a line.
19
+ *
20
+ * Everything else — text streaming, `endSegment`, `note`, `preview` — is
21
+ * inherited unchanged. This is the whole of screen-reader mode: no parallel
22
+ * path, no behavior change to the plain or TTY renderers.
23
+ */
24
+ export class ScreenReaderRenderer extends PlainRenderer {
25
+ /** Identity of the last phase announced, so we speak each activity once. */
26
+ lastPhaseId = "";
27
+ /** Text of the last progress line announced, to avoid repeats. */
28
+ lastProgress = "";
29
+ constructor(caps, out, err) {
30
+ super(caps, out, err);
31
+ }
32
+ /**
33
+ * Announce a phase transition as a committed line. `null`, `awaiting-approval`
34
+ * (the prompt block is its own announcement), and `calling-tool` (announced by
35
+ * {@link toolLifecycle}) are silent; repeats of the same activity are deduped.
36
+ */
37
+ setPhase(phase) {
38
+ const id = phaseIdentity(phase);
39
+ if (id === this.lastPhaseId)
40
+ return;
41
+ this.lastPhaseId = id;
42
+ if (phase === null ||
43
+ phase.kind === "awaiting-approval" ||
44
+ phase.kind === "calling-tool") {
45
+ return;
46
+ }
47
+ this.note(describePhase(phase, this.theme.glyph));
48
+ }
49
+ /** Announce plan-step progress as `step i of n: title`, deduped. */
50
+ progress(state) {
51
+ if (state === null) {
52
+ this.lastProgress = "";
53
+ return;
54
+ }
55
+ const line = `step ${state.step} of ${state.of}: ${state.title}`;
56
+ if (line === this.lastProgress)
57
+ return;
58
+ this.lastProgress = line;
59
+ this.note(line);
60
+ }
61
+ /**
62
+ * Bracket a tool call with two committed lines — `working: label` on start,
63
+ * `done/failed label` on end — so a screen-reader user hears both that a call
64
+ * began (crucial for long ones) and how it resolved. The end note + honest
65
+ * duration is the inherited plain behavior; only the start line is added.
66
+ */
67
+ toolLifecycle(event) {
68
+ if (event.event === "start") {
69
+ super.toolLifecycle(event); // records the start time (silent in plain)
70
+ this.note(`${this.theme.glyph.running}: ${event.label}`);
71
+ return;
72
+ }
73
+ super.toolLifecycle(event); // commits `done/failed label (duration)`
74
+ }
75
+ }
@@ -20,8 +20,22 @@ export interface RenderCapabilities {
20
20
  color: boolean;
21
21
  /** Cursor-control sequences are safe (`tty` and not `TERM=dumb`). */
22
22
  cursor: boolean;
23
- /** Animation is welcome (`cursor` and CRUXY_NO_SPINNER unset). */
23
+ /** Animation is welcome (`cursor` and motion is not reduced). */
24
24
  spinner: boolean;
25
+ /**
26
+ * Motion is reduced (U.11): no spinner animation/timer, no in-place
27
+ * re-animation — state survives as static text, only movement stops. True
28
+ * under `CRUXY_NO_SPINNER` (alias) / `NO_MOTION` / `CRUXY_REDUCED_MOTION`, and
29
+ * implied by `screenReader`. The one axis the spinner gate keys off.
30
+ */
31
+ reducedMotion: boolean;
32
+ /**
33
+ * Screen-reader mode (U.11): plain, linear, announce-friendly output — no
34
+ * live-region redraws (each state change is a committed line), no spinners,
35
+ * status glyphs rendered as words. Opt-in via `CRUXY_SCREEN_READER` /
36
+ * `ACCESSIBLE`; routes rendering to the linear path regardless of `cursor`.
37
+ */
38
+ screenReader: boolean;
25
39
  /** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
26
40
  * independent of `color`. Drives the theme's glyph table, not its stylers. */
27
41
  unicode: boolean;
@@ -1,14 +1,15 @@
1
1
  import { type Theme, type ThemeCapabilities } from "./tokens.js";
2
2
  /**
3
- * Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
3
+ * Theme resolution (U.1/U.11) — the ONE place picocolors is used. The axes are
4
4
  * fully independent by construction:
5
5
  *
6
6
  * - `color` drives the stylers only. `pc.createColors(false)` is the identity,
7
7
  * so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
8
8
  * tests.
9
- * - `unicode` drives the glyph table only. It never touches color: a NO_COLOR
10
- * unicode terminal still prints ✓/✗ (glyphs aren't ANSI), and a colored
11
- * CRUXY_ASCII terminal prints a colored `[ok]`.
9
+ * - the glyph table is chosen by `screenReader` (words) else `unicode`
10
+ * (✓/✗) else ASCII (`[ok]`/`[x]`). Never touches color: a NO_COLOR unicode
11
+ * terminal still prints ✓/✗ (glyphs aren't ANSI), a colored CRUXY_ASCII
12
+ * terminal prints a colored `[ok]`, and a screen reader gets colorless words.
12
13
  */
13
14
  export declare function resolveTheme(caps: ThemeCapabilities): Theme;
14
15
  /**
@@ -23,10 +24,20 @@ export declare function resolveTheme(caps: ThemeCapabilities): Theme;
23
24
  * signature change.
24
25
  */
25
26
  export declare function detectUnicode(env?: NodeJS.ProcessEnv): boolean;
27
+ /**
28
+ * Whether the user asked for screen-reader mode (U.11) — opt-in via
29
+ * `CRUXY_SCREEN_READER` or the ecosystem `ACCESSIBLE` flag (set-and-non-empty).
30
+ * Deliberately NOT inferred from a pipe: auto-wording every non-TTY stream would
31
+ * change existing CI/plain output. The single source of the rule, called by
32
+ * `render/capabilities.ts` (to fill `RenderCapabilities.screenReader`) and by
33
+ * the boolean-seam {@link themeForColor} surfaces (approval, plan, onboarding).
34
+ */
35
+ export declare function detectScreenReader(env?: NodeJS.ProcessEnv): boolean;
26
36
  /**
27
37
  * Build a theme for a surface that only knows a `color` boolean (the U.3
28
- * PromptIO, error formatting, plan/onboarding/CLI copy). Unicode is detected
29
- * from the environment so these surfaces still degrade on dumb terminals,
30
- * with no change to their public boolean signatures.
38
+ * PromptIO, error formatting, plan/onboarding/CLI copy). Unicode and
39
+ * screen-reader mode are detected from the environment so these surfaces still
40
+ * degrade on dumb terminals and speak words to a screen reader, with no change
41
+ * to their public boolean signatures.
31
42
  */
32
43
  export declare function themeForColor(color: boolean, env?: NodeJS.ProcessEnv): Theme;
@@ -1,19 +1,24 @@
1
1
  import pc from "picocolors";
2
- import { ASCII_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
2
+ import { ASCII_GLYPHS, SCREEN_READER_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
3
3
  /**
4
- * Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
4
+ * Theme resolution (U.1/U.11) — the ONE place picocolors is used. The axes are
5
5
  * fully independent by construction:
6
6
  *
7
7
  * - `color` drives the stylers only. `pc.createColors(false)` is the identity,
8
8
  * so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
9
9
  * tests.
10
- * - `unicode` drives the glyph table only. It never touches color: a NO_COLOR
11
- * unicode terminal still prints ✓/✗ (glyphs aren't ANSI), and a colored
12
- * CRUXY_ASCII terminal prints a colored `[ok]`.
10
+ * - the glyph table is chosen by `screenReader` (words) else `unicode`
11
+ * (✓/✗) else ASCII (`[ok]`/`[x]`). Never touches color: a NO_COLOR unicode
12
+ * terminal still prints ✓/✗ (glyphs aren't ANSI), a colored CRUXY_ASCII
13
+ * terminal prints a colored `[ok]`, and a screen reader gets colorless words.
13
14
  */
14
15
  export function resolveTheme(caps) {
15
16
  const c = pc.createColors(caps.color);
16
- const glyph = caps.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
17
+ const glyph = caps.screenReader
18
+ ? SCREEN_READER_GLYPHS
19
+ : caps.unicode
20
+ ? UNICODE_GLYPHS
21
+ : ASCII_GLYPHS;
17
22
  const strong = c.bold;
18
23
  const indent = (text, level = 1) => {
19
24
  const pad = " ".repeat(Math.max(0, level));
@@ -62,12 +67,29 @@ export function detectUnicode(env = process.env) {
62
67
  return false;
63
68
  return true;
64
69
  }
70
+ /**
71
+ * Whether the user asked for screen-reader mode (U.11) — opt-in via
72
+ * `CRUXY_SCREEN_READER` or the ecosystem `ACCESSIBLE` flag (set-and-non-empty).
73
+ * Deliberately NOT inferred from a pipe: auto-wording every non-TTY stream would
74
+ * change existing CI/plain output. The single source of the rule, called by
75
+ * `render/capabilities.ts` (to fill `RenderCapabilities.screenReader`) and by
76
+ * the boolean-seam {@link themeForColor} surfaces (approval, plan, onboarding).
77
+ */
78
+ export function detectScreenReader(env = process.env) {
79
+ const set = (v) => v !== undefined && v !== "";
80
+ return set(env.CRUXY_SCREEN_READER) || set(env.ACCESSIBLE);
81
+ }
65
82
  /**
66
83
  * Build a theme for a surface that only knows a `color` boolean (the U.3
67
- * PromptIO, error formatting, plan/onboarding/CLI copy). Unicode is detected
68
- * from the environment so these surfaces still degrade on dumb terminals,
69
- * with no change to their public boolean signatures.
84
+ * PromptIO, error formatting, plan/onboarding/CLI copy). Unicode and
85
+ * screen-reader mode are detected from the environment so these surfaces still
86
+ * degrade on dumb terminals and speak words to a screen reader, with no change
87
+ * to their public boolean signatures.
70
88
  */
71
89
  export function themeForColor(color, env = process.env) {
72
- return resolveTheme({ color, unicode: detectUnicode(env) });
90
+ return resolveTheme({
91
+ color,
92
+ unicode: detectUnicode(env),
93
+ screenReader: detectScreenReader(env),
94
+ });
73
95
  }
@@ -90,10 +90,16 @@ export interface Theme {
90
90
  readonly color: boolean;
91
91
  readonly unicode: boolean;
92
92
  }
93
- /** The two axes a theme is resolved from — a structural subset of RenderCapabilities. */
93
+ /** The axes a theme is resolved from — a structural subset of RenderCapabilities. */
94
94
  export interface ThemeCapabilities {
95
95
  color: boolean;
96
96
  unicode: boolean;
97
+ /**
98
+ * Screen-reader mode (U.11): status glyphs render as words. Optional so every
99
+ * existing `{color, unicode}` caller is unchanged (defaults to off). When set
100
+ * it takes precedence over `unicode` for the glyph table.
101
+ */
102
+ screenReader?: boolean;
97
103
  }
98
104
  /** The unicode glyph table (real terminals). */
99
105
  export declare const UNICODE_GLYPHS: ThemeGlyphs;
@@ -102,3 +108,12 @@ export declare const UNICODE_GLYPHS: ThemeGlyphs;
102
108
  * mojibake — the intentional U.1 degradation for unicode-unsafe terminals.
103
109
  */
104
110
  export declare const ASCII_GLYPHS: ThemeGlyphs;
111
+ /**
112
+ * The screen-reader glyph table (U.11): the status glyphs a screen reader would
113
+ * otherwise announce as bare punctuation are spelled as words — `done`,
114
+ * `failed`, `working`, `pending` — so `✓ read_file` becomes `done read_file`.
115
+ * Structural glyphs stay ASCII-legible (a pointer/caret has no useful word).
116
+ * This is the entire "worded status" of screen-reader mode: swap the table,
117
+ * reuse every existing renderer/theme code path unchanged.
118
+ */
119
+ export declare const SCREEN_READER_GLYPHS: ThemeGlyphs;
@@ -50,3 +50,30 @@ export const ASCII_GLYPHS = {
50
50
  spinnerFrames: ["-", "\\", "|", "/"],
51
51
  spinnerStatic: "~",
52
52
  };
53
+ /**
54
+ * The screen-reader glyph table (U.11): the status glyphs a screen reader would
55
+ * otherwise announce as bare punctuation are spelled as words — `done`,
56
+ * `failed`, `working`, `pending` — so `✓ read_file` becomes `done read_file`.
57
+ * Structural glyphs stay ASCII-legible (a pointer/caret has no useful word).
58
+ * This is the entire "worded status" of screen-reader mode: swap the table,
59
+ * reuse every existing renderer/theme code path unchanged.
60
+ */
61
+ export const SCREEN_READER_GLYPHS = {
62
+ success: "done",
63
+ failure: "failed",
64
+ pending: "pending",
65
+ running: "working",
66
+ pointer: ">",
67
+ caret: ">",
68
+ arrow: "->",
69
+ caretUp: "up",
70
+ caretDown: "down",
71
+ cursorBar: "",
72
+ bullet: "-",
73
+ sep: "-",
74
+ ellipsis: "...",
75
+ play: ">",
76
+ // Unused in the linear screen-reader path (no live region), kept legible.
77
+ spinnerFrames: ["working"],
78
+ spinnerStatic: "working",
79
+ };
@@ -0,0 +1,53 @@
1
+ import type { ToolContext } from "../types.js";
2
+ /**
3
+ * The ONE gated + sandboxed shell path (C.16 + C.19). Both `run_command` and the
4
+ * C.19 hook runner funnel through {@link runGatedShell}: a command reaches
5
+ * execution only after passing the SAME `ctx.requestApproval` gate and only
6
+ * through the SAME `ctx.sandbox` (or host) substrate. There is deliberately no
7
+ * second exec route, so a hook can never get a privileged path — proven by the
8
+ * fact that both callers invoke this exact function.
9
+ *
10
+ * The structured {@link ShellExecResult} carries the raw exit code (which
11
+ * `run_command`'s text `ToolResult` hides) so the hook runner can decide
12
+ * blocking pass/fail on exit 0 vs non-zero, while `run_command` maps the same
13
+ * result back to its byte-identical `ToolResult`.
14
+ */
15
+ /** The raw outcome of executing a shell command (gate already passed). */
16
+ export interface ShellExecResult {
17
+ /** The wall-clock timeout tripped and the process tree was killed. */
18
+ timedOut: boolean;
19
+ /** Numeric exit code, or null when killed / signalled / unknown. */
20
+ exitCode: number | null;
21
+ /** Terminating signal (host path only); null under the sandbox. */
22
+ signal: string | null;
23
+ /** Combined stdout+stderr, capped to `shell.maxOutputBytes`. */
24
+ output: string;
25
+ /** Output was truncated at the cap. */
26
+ truncated: boolean;
27
+ /** The host process failed to *start* (spawn error). Sandbox start failures
28
+ * throw a coded error instead (propagated, never returned here). */
29
+ spawnError?: string;
30
+ }
31
+ /** The result of the gate + (if allowed) execution. */
32
+ export interface GatedShellOutcome {
33
+ /** The U.3 gate allowed the command. When false, nothing executed. */
34
+ approved: boolean;
35
+ /** The rejection feedback when `approved` is false. */
36
+ rejection?: string;
37
+ /** The execution result — present iff `approved`. */
38
+ exec?: ShellExecResult;
39
+ }
40
+ /**
41
+ * Gate `command` through `ctx.requestApproval`, then — only if allowed — execute
42
+ * it via the sandbox (when `ctx.sandbox` is set) or the bounded host spawn. A
43
+ * rejection returns `{ approved: false }` and runs nothing. A non-interactive
44
+ * gate throws `CRUXY_E_APPROVAL_REQUIRED` (propagated, never swallowed); a
45
+ * sandbox that can't run throws its coded error (fail loud, no host fallback).
46
+ */
47
+ export declare function runGatedShell(command: string, ctx: ToolContext): Promise<GatedShellOutcome>;
48
+ /**
49
+ * Execute an already-approved command. Substrate is chosen SOLELY by
50
+ * `ctx.sandbox`: present → the container (C.16), never the host; absent → the
51
+ * bounded host spawn. No fallback path — a sandbox that can't run throws.
52
+ */
53
+ export declare function execShell(command: string, ctx: ToolContext): Promise<ShellExecResult>;
@@ -0,0 +1,128 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Gate `command` through `ctx.requestApproval`, then — only if allowed — execute
4
+ * it via the sandbox (when `ctx.sandbox` is set) or the bounded host spawn. A
5
+ * rejection returns `{ approved: false }` and runs nothing. A non-interactive
6
+ * gate throws `CRUXY_E_APPROVAL_REQUIRED` (propagated, never swallowed); a
7
+ * sandbox that can't run throws its coded error (fail loud, no host fallback).
8
+ */
9
+ export async function runGatedShell(command, ctx) {
10
+ const decision = await ctx.requestApproval({ kind: "shell", command });
11
+ if (!decision.allow) {
12
+ return { approved: false, rejection: decision.feedback };
13
+ }
14
+ return { approved: true, exec: await execShell(command, ctx) };
15
+ }
16
+ /**
17
+ * Execute an already-approved command. Substrate is chosen SOLELY by
18
+ * `ctx.sandbox`: present → the container (C.16), never the host; absent → the
19
+ * bounded host spawn. No fallback path — a sandbox that can't run throws.
20
+ */
21
+ export function execShell(command, ctx) {
22
+ return ctx.sandbox ? runSandboxed(command, ctx) : runBounded(command, ctx);
23
+ }
24
+ /** Run in the sandbox and normalize its neutral ExecResult (start failures
25
+ * throw coded errors from `sandbox.exec` and propagate — never caught here). */
26
+ async function runSandboxed(command, ctx) {
27
+ const { timeoutMs, maxOutputBytes } = ctx.config.shell;
28
+ const result = await ctx.sandbox.exec(command, {
29
+ cwd: ctx.cwd,
30
+ timeoutMs,
31
+ maxOutputBytes,
32
+ capture: "head",
33
+ });
34
+ return {
35
+ timedOut: result.timedOut,
36
+ exitCode: result.exitCode,
37
+ signal: null,
38
+ output: result.output,
39
+ truncated: result.outputTruncated,
40
+ };
41
+ }
42
+ /** Spawn the command on the host, capture bounded output, enforce the timeout. */
43
+ function runBounded(command, ctx) {
44
+ const { timeoutMs, maxOutputBytes } = ctx.config.shell;
45
+ return new Promise((resolve) => {
46
+ // `detached` makes the child its own process-group leader so the whole tree
47
+ // (the shell plus anything it spawns) can be killed on timeout.
48
+ const child = spawn(command, { shell: true, cwd: ctx.cwd, detached: true });
49
+ const chunks = [];
50
+ let captured = 0;
51
+ let truncated = false;
52
+ const capture = (buf) => {
53
+ if (truncated)
54
+ return;
55
+ const room = maxOutputBytes - captured;
56
+ if (buf.length <= room) {
57
+ chunks.push(buf);
58
+ captured += buf.length;
59
+ }
60
+ else {
61
+ if (room > 0) {
62
+ chunks.push(buf.subarray(0, room));
63
+ captured += room;
64
+ }
65
+ truncated = true;
66
+ }
67
+ };
68
+ child.stdout?.on("data", capture);
69
+ child.stderr?.on("data", capture);
70
+ // A single guard so the timeout-kill and the natural close can't both fire.
71
+ let settled = false;
72
+ const timer = setTimeout(() => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ killTree(child.pid);
77
+ resolve({
78
+ timedOut: true,
79
+ exitCode: null,
80
+ signal: null,
81
+ output: "",
82
+ truncated,
83
+ });
84
+ }, timeoutMs);
85
+ child.on("error", (err) => {
86
+ if (settled)
87
+ return;
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ resolve({
91
+ timedOut: false,
92
+ exitCode: null,
93
+ signal: null,
94
+ output: "",
95
+ truncated: false,
96
+ spawnError: err.message,
97
+ });
98
+ });
99
+ child.on("close", (code, signal) => {
100
+ if (settled)
101
+ return;
102
+ settled = true;
103
+ clearTimeout(timer);
104
+ resolve({
105
+ timedOut: false,
106
+ exitCode: code,
107
+ signal: signal ?? null,
108
+ output: Buffer.concat(chunks).toString("utf8"),
109
+ truncated,
110
+ });
111
+ });
112
+ });
113
+ }
114
+ /**
115
+ * Kill the command's entire process group. POSIX-specific (negative pid targets
116
+ * the group); fine on our darwin/linux targets. Swallows errors — the process
117
+ * may already be gone.
118
+ */
119
+ function killTree(pid) {
120
+ if (pid === undefined)
121
+ return;
122
+ try {
123
+ process.kill(-pid, "SIGKILL");
124
+ }
125
+ catch {
126
+ // Already exited, or no group — nothing to kill.
127
+ }
128
+ }
@@ -4,6 +4,10 @@ import type { Tool } from "../types.js";
4
4
  * Run an arbitrary shell command in the project root. The highest-risk tool we
5
5
  * ship: it is gated on `ctx.approve` (a denial runs nothing) and bounded by a
6
6
  * timeout that kills the whole process tree plus a cap on captured output.
7
+ *
8
+ * Gate + execution live in the shared {@link runGatedShell} (also used by the
9
+ * C.19 hook runner — the single, un-bypassable shell path); this tool only maps
10
+ * the structured result back onto its `ToolResult` framing.
7
11
  */
8
12
  export declare const runCommandTool: Tool<z.ZodObject<{
9
13
  command: z.ZodString;
@@ -1,9 +1,13 @@
1
- import { spawn } from "node:child_process";
2
1
  import { z } from "zod";
2
+ import { runGatedShell } from "./exec.js";
3
3
  /**
4
4
  * Run an arbitrary shell command in the project root. The highest-risk tool we
5
5
  * ship: it is gated on `ctx.approve` (a denial runs nothing) and bounded by a
6
6
  * timeout that kills the whole process tree plus a cap on captured output.
7
+ *
8
+ * Gate + execution live in the shared {@link runGatedShell} (also used by the
9
+ * C.19 hook runner — the single, un-bypassable shell path); this tool only maps
10
+ * the structured result back onto its `ToolResult` framing.
7
11
  */
8
12
  export const runCommandTool = {
9
13
  name: "run_command",
@@ -14,129 +18,35 @@ export const runCommandTool = {
14
18
  .describe("The shell command to run (executed via the system shell)."),
15
19
  }),
16
20
  async execute(input, ctx) {
17
- // Approve BEFORE anything runs; a denial executes nothing. A thrown
18
- // CRUXY_E_APPROVAL_REQUIRED (non-interactive) propagates — do not catch.
19
- const decision = await ctx.requestApproval({
20
- kind: "shell",
21
- command: input.command,
22
- });
23
- if (!decision.allow) {
21
+ // A thrown CRUXY_E_APPROVAL_REQUIRED (non-interactive) / sandbox coded error
22
+ // propagates from runGatedShell — do not catch (fail loud).
23
+ const outcome = await runGatedShell(input.command, ctx);
24
+ if (!outcome.approved) {
24
25
  return {
25
26
  ok: false,
26
- error: decision.feedback ?? "command denied by the user",
27
+ error: outcome.rejection ?? "command denied by the user",
27
28
  };
28
29
  }
29
- // Substrate is chosen SOLELY by ctx.sandbox: present → run in the box (C.16),
30
- // never on the host; absent → host, unchanged. There is no fallback path — a
31
- // sandbox that can't run throws a coded error (see runSandboxed) rather than
32
- // silently reaching runBounded.
33
- return ctx.sandbox
34
- ? runSandboxed(input.command, ctx)
35
- : runBounded(input.command, ctx);
30
+ return mapExecResult(outcome.exec, ctx);
36
31
  },
37
32
  };
38
33
  /**
39
- * Run inside the sandbox and map the neutral ExecResult onto the IDENTICAL
40
- * ToolResult the host path produces same "exit code N" framing, same
41
- * truncation note, same timeout message so the tool is substrate-agnostic.
42
- * A container-start / image failure throws a coded CruxyError from
43
- * `sandbox.exec` and propagates; we deliberately do not catch it (fail loud).
34
+ * Map a {@link ShellExecResult} onto the tool's `ToolResult` the same "exit
35
+ * code N" framing, truncation note, and timeout / spawn-error messages as
36
+ * before the shared-path refactor. `exitCode ?? signal ?? "unknown"` reproduces
37
+ * both the sandbox (`exitCode ?? "unknown"`, signal always null) and host
38
+ * (`code ?? signal ?? "unknown"`) framings from the original tool.
44
39
  */
45
- function runSandboxed(command, ctx) {
40
+ function mapExecResult(r, ctx) {
46
41
  const { timeoutMs, maxOutputBytes } = ctx.config.shell;
47
- return ctx
48
- .sandbox.exec(command, {
49
- cwd: ctx.cwd,
50
- timeoutMs,
51
- maxOutputBytes,
52
- capture: "head",
53
- })
54
- .then((result) => {
55
- if (result.timedOut) {
56
- return { ok: false, error: `timed out after ${timeoutMs}ms` };
57
- }
58
- const exit = result.exitCode ?? "unknown";
59
- let output = `exit code ${exit}\n${result.output}`;
60
- if (result.outputTruncated) {
61
- output += `\n… [output truncated at ${maxOutputBytes} bytes]`;
62
- }
63
- return { ok: true, output };
64
- });
65
- }
66
- /** Spawn the command, capture bounded output, and enforce the timeout. */
67
- function runBounded(command, ctx) {
68
- const { timeoutMs, maxOutputBytes } = ctx.config.shell;
69
- return new Promise((resolve) => {
70
- // `detached` makes the child its own process-group leader so the whole tree
71
- // (the shell plus anything it spawns) can be killed on timeout.
72
- const child = spawn(command, {
73
- shell: true,
74
- cwd: ctx.cwd,
75
- detached: true,
76
- });
77
- const chunks = [];
78
- let captured = 0;
79
- let truncated = false;
80
- const capture = (buf) => {
81
- if (truncated)
82
- return;
83
- const room = maxOutputBytes - captured;
84
- if (buf.length <= room) {
85
- chunks.push(buf);
86
- captured += buf.length;
87
- }
88
- else {
89
- if (room > 0) {
90
- chunks.push(buf.subarray(0, room));
91
- captured += room;
92
- }
93
- truncated = true;
94
- }
95
- };
96
- child.stdout?.on("data", capture);
97
- child.stderr?.on("data", capture);
98
- // A single guard so the timeout-kill and the natural close can't both fire.
99
- let settled = false;
100
- const timer = setTimeout(() => {
101
- if (settled)
102
- return;
103
- settled = true;
104
- killTree(child.pid);
105
- resolve({ ok: false, error: `timed out after ${timeoutMs}ms` });
106
- }, timeoutMs);
107
- child.on("error", (err) => {
108
- if (settled)
109
- return;
110
- settled = true;
111
- clearTimeout(timer);
112
- resolve({ ok: false, error: err.message });
113
- });
114
- child.on("close", (code, signal) => {
115
- if (settled)
116
- return;
117
- settled = true;
118
- clearTimeout(timer);
119
- const exit = code ?? signal ?? "unknown";
120
- let output = `exit code ${exit}\n${Buffer.concat(chunks).toString("utf8")}`;
121
- if (truncated) {
122
- output += `\n… [output truncated at ${maxOutputBytes} bytes]`;
123
- }
124
- resolve({ ok: true, output });
125
- });
126
- });
127
- }
128
- /**
129
- * Kill the command's entire process group. POSIX-specific (negative pid targets
130
- * the group); fine on our darwin/linux targets. Swallows errors — the process
131
- * may already be gone.
132
- */
133
- function killTree(pid) {
134
- if (pid === undefined)
135
- return;
136
- try {
137
- process.kill(-pid, "SIGKILL");
138
- }
139
- catch {
140
- // Already exited, or no group — nothing to kill.
42
+ if (r.timedOut)
43
+ return { ok: false, error: `timed out after ${timeoutMs}ms` };
44
+ if (r.spawnError !== undefined)
45
+ return { ok: false, error: r.spawnError };
46
+ const exit = r.exitCode ?? r.signal ?? "unknown";
47
+ let output = `exit code ${exit}\n${r.output}`;
48
+ if (r.truncated) {
49
+ output += `\n… [output truncated at ${maxOutputBytes} bytes]`;
141
50
  }
51
+ return { ok: true, output };
142
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.13.0",
3
+ "version": "0.16.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {