@cruxy/cli 0.11.0 → 0.13.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 (71) hide show
  1. package/dist/approval/prompt.js +17 -15
  2. package/dist/cli/commands/checkpoint.js +6 -4
  3. package/dist/cli/commands/config.js +10 -7
  4. package/dist/cli/commands/index.js +16 -15
  5. package/dist/cli/commands/init.js +5 -3
  6. package/dist/cli/commands/login.js +5 -3
  7. package/dist/cli/commands/pr.js +8 -7
  8. package/dist/cli/commands/rollback.js +7 -6
  9. package/dist/cli/commands/run.js +26 -7
  10. package/dist/cli/commands/skills.js +12 -10
  11. package/dist/cli/program.js +7 -6
  12. package/dist/cli/repl.js +11 -9
  13. package/dist/cli/session-factory.d.ts +2 -1
  14. package/dist/cli/session-factory.js +10 -3
  15. package/dist/components/frame.js +3 -1
  16. package/dist/components/fuzzy.d.ts +4 -4
  17. package/dist/components/fuzzy.js +14 -13
  18. package/dist/components/select.js +8 -7
  19. package/dist/config/schema.d.ts +123 -0
  20. package/dist/config/schema.js +40 -0
  21. package/dist/errors/constructors.d.ts +21 -0
  22. package/dist/errors/constructors.js +58 -0
  23. package/dist/errors/format.js +8 -8
  24. package/dist/errors/types.d.ts +5 -0
  25. package/dist/errors/types.js +11 -0
  26. package/dist/onboarding/flow.js +6 -6
  27. package/dist/onboarding/steps.js +11 -11
  28. package/dist/plan/approve.js +6 -6
  29. package/dist/plan/render.js +26 -18
  30. package/dist/render/capabilities.js +4 -0
  31. package/dist/render/diff.d.ts +6 -7
  32. package/dist/render/diff.js +33 -22
  33. package/dist/render/highlight.d.ts +3 -3
  34. package/dist/render/highlight.js +15 -15
  35. package/dist/render/index.d.ts +1 -1
  36. package/dist/render/plain-renderer.d.ts +2 -1
  37. package/dist/render/plain-renderer.js +7 -6
  38. package/dist/render/state.d.ts +7 -2
  39. package/dist/render/state.js +16 -10
  40. package/dist/render/tty-renderer.d.ts +2 -1
  41. package/dist/render/tty-renderer.js +20 -17
  42. package/dist/render/types.d.ts +7 -0
  43. package/dist/sandbox/detect.d.ts +22 -0
  44. package/dist/sandbox/detect.js +67 -0
  45. package/dist/sandbox/docker-runtime.d.ts +32 -0
  46. package/dist/sandbox/docker-runtime.js +263 -0
  47. package/dist/sandbox/index.d.ts +7 -0
  48. package/dist/sandbox/index.js +5 -0
  49. package/dist/sandbox/policy.d.ts +17 -0
  50. package/dist/sandbox/policy.js +90 -0
  51. package/dist/sandbox/service.d.ts +57 -0
  52. package/dist/sandbox/service.js +64 -0
  53. package/dist/sandbox/types.d.ts +114 -0
  54. package/dist/sandbox/types.js +17 -0
  55. package/dist/subagent/orchestrator.d.ts +7 -0
  56. package/dist/subagent/orchestrator.js +22 -6
  57. package/dist/testing/run-tests-tool.d.ts +5 -1
  58. package/dist/testing/run-tests-tool.js +8 -1
  59. package/dist/testing/sandbox-runner.d.ts +16 -0
  60. package/dist/testing/sandbox-runner.js +47 -0
  61. package/dist/theme/index.d.ts +2 -0
  62. package/dist/theme/index.js +2 -0
  63. package/dist/theme/resolve.d.ts +32 -0
  64. package/dist/theme/resolve.js +73 -0
  65. package/dist/theme/tokens.d.ts +104 -0
  66. package/dist/theme/tokens.js +52 -0
  67. package/dist/tools/shell/run-command.js +35 -1
  68. package/dist/tools/types.d.ts +10 -0
  69. package/dist/utils/logger.d.ts +2 -0
  70. package/dist/utils/logger.js +7 -4
  71. package/package.json +1 -1
@@ -0,0 +1,57 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { ExecOptions, ExecResult, IsolationPolicy, SandboxCapability, SandboxRuntime } from "./types.js";
3
+ /**
4
+ * The minimal surface the sandbox needs to report a first-run image pull
5
+ * through the U.4 state layer. `StreamRenderer` satisfies it structurally, so
6
+ * callers pass the renderer directly and the sandbox stays render-decoupled.
7
+ */
8
+ export interface SandboxReporter {
9
+ status(text: string | null): void;
10
+ }
11
+ export interface SandboxServiceDeps {
12
+ config: CruxyConfig;
13
+ /** The run's working directory — mounted read-write as the workdir. */
14
+ cwd: string;
15
+ /** Execution runtime seam (defaults to Docker). */
16
+ runtime?: SandboxRuntime;
17
+ /** Capability probe seam (defaults to real docker detection). */
18
+ detect?: () => Promise<SandboxCapability>;
19
+ /** U.4 sink for the "pulling sandbox image…" line (optional). */
20
+ reporter?: SandboxReporter;
21
+ }
22
+ /**
23
+ * The sandbox execution service (C.16) — what `ToolContext.sandbox` points at.
24
+ * It is only ever constructed when the sandbox is enabled, and constructing it
25
+ * is where the fail-loud guarantee lives: {@link SandboxService.create} probes
26
+ * the runtime and throws {@link sandboxUnavailable} if it isn't available, so a
27
+ * user who asked for the box either gets the box or a loud, coded error — never
28
+ * a silent drop back to host execution. `exec` then contains an approved
29
+ * command inside the isolation policy, ensuring the image on first use.
30
+ */
31
+ export declare class SandboxService {
32
+ private readonly runtime;
33
+ private readonly policy;
34
+ private readonly reporter?;
35
+ private imageReady?;
36
+ private constructor();
37
+ /**
38
+ * Resolve the runtime and build the policy. THROWS {@link sandboxUnavailable}
39
+ * when the runtime is missing/unreachable — the caller (session wiring) lets
40
+ * it propagate so the run stops before any command executes. There is no code
41
+ * path from here to host execution.
42
+ */
43
+ static create(deps: SandboxServiceDeps): Promise<SandboxService>;
44
+ /** The runtime backing this service (e.g. "docker") — for logging. */
45
+ get runtimeName(): string;
46
+ /** The resolved isolation policy — exposed for logging/inspection. */
47
+ get isolationPolicy(): IsolationPolicy;
48
+ /**
49
+ * Execute an already-approved command inside the box. Ensures the image once
50
+ * (surfacing the pull via U.4), then delegates to the runtime. A container
51
+ * that fails to start throws a coded error; an ordinary non-zero command exit
52
+ * comes back as a normal {@link ExecResult} — exit code is truth.
53
+ */
54
+ exec(command: string, opts: ExecOptions): Promise<ExecResult>;
55
+ /** Ensure the image is present, at most once per service (memoized). */
56
+ private ensureImage;
57
+ }
@@ -0,0 +1,64 @@
1
+ import { sandboxUnavailable } from "../errors/index.js";
2
+ import { detectDocker } from "./detect.js";
3
+ import { DockerRuntime } from "./docker-runtime.js";
4
+ import { buildPolicy } from "./policy.js";
5
+ /**
6
+ * The sandbox execution service (C.16) — what `ToolContext.sandbox` points at.
7
+ * It is only ever constructed when the sandbox is enabled, and constructing it
8
+ * is where the fail-loud guarantee lives: {@link SandboxService.create} probes
9
+ * the runtime and throws {@link sandboxUnavailable} if it isn't available, so a
10
+ * user who asked for the box either gets the box or a loud, coded error — never
11
+ * a silent drop back to host execution. `exec` then contains an approved
12
+ * command inside the isolation policy, ensuring the image on first use.
13
+ */
14
+ export class SandboxService {
15
+ runtime;
16
+ policy;
17
+ reporter;
18
+ imageReady;
19
+ constructor(runtime, policy, reporter) {
20
+ this.runtime = runtime;
21
+ this.policy = policy;
22
+ this.reporter = reporter;
23
+ }
24
+ /**
25
+ * Resolve the runtime and build the policy. THROWS {@link sandboxUnavailable}
26
+ * when the runtime is missing/unreachable — the caller (session wiring) lets
27
+ * it propagate so the run stops before any command executes. There is no code
28
+ * path from here to host execution.
29
+ */
30
+ static async create(deps) {
31
+ const runtime = deps.runtime ?? new DockerRuntime();
32
+ const detect = deps.detect ?? (() => detectDocker());
33
+ const capability = await detect();
34
+ if (!capability.available) {
35
+ throw sandboxUnavailable(capability.runtime, capability.detail);
36
+ }
37
+ const policy = buildPolicy(deps.config.sandbox, deps.cwd);
38
+ return new SandboxService(runtime, policy, deps.reporter);
39
+ }
40
+ /** The runtime backing this service (e.g. "docker") — for logging. */
41
+ get runtimeName() {
42
+ return this.runtime.name;
43
+ }
44
+ /** The resolved isolation policy — exposed for logging/inspection. */
45
+ get isolationPolicy() {
46
+ return this.policy;
47
+ }
48
+ /**
49
+ * Execute an already-approved command inside the box. Ensures the image once
50
+ * (surfacing the pull via U.4), then delegates to the runtime. A container
51
+ * that fails to start throws a coded error; an ordinary non-zero command exit
52
+ * comes back as a normal {@link ExecResult} — exit code is truth.
53
+ */
54
+ async exec(command, opts) {
55
+ await this.ensureImage();
56
+ return this.runtime.exec(command, this.policy, opts);
57
+ }
58
+ /** Ensure the image is present, at most once per service (memoized). */
59
+ ensureImage() {
60
+ return (this.imageReady ??= this.runtime
61
+ .ensureImage(this.policy.image, () => this.reporter?.status(`pulling sandbox image ${this.policy.image}…`))
62
+ .then(() => this.reporter?.status(null)));
63
+ }
64
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Sandbox execution (C.16) — the isolation boundary beneath the U.3 gate.
3
+ *
4
+ * When the sandbox is enabled, the highest-risk tools (`run_command`,
5
+ * `run_tests`) execute inside a container instead of directly on the host: the
6
+ * project workdir is mounted so the agent edits real files, but the *process*
7
+ * cannot reach the network, cannot touch the host beyond that mount, and cannot
8
+ * exhaust the machine. Approval still gates every command (see U.3) — the
9
+ * sandbox contains what an approved command is able to do; it does not replace
10
+ * the decision to run it.
11
+ *
12
+ * Execution is abstracted behind {@link SandboxRuntime} (Docker ships; podman /
13
+ * none slot in without touching call sites), and the neutral {@link ExecResult}
14
+ * matches what host execution conceptually returns so `run_command`/`run_tests`
15
+ * stay substrate-agnostic — exit code is the source of truth on either path.
16
+ */
17
+ /** Egress policy for the container. `none` is the default (deny all). */
18
+ export type NetworkPolicy = "none" | "host-loopback" | "full";
19
+ /** One resolved bind mount: an absolute host path exposed in the container. */
20
+ export interface BindMount {
21
+ /** Absolute host path. */
22
+ readonly source: string;
23
+ /** Absolute container path (the workdir mount uses `source === target`). */
24
+ readonly target: string;
25
+ /** Read-only when true; the workdir mount is read-write. */
26
+ readonly readonly: boolean;
27
+ }
28
+ /**
29
+ * A fully-resolved isolation policy — every knob the runtime needs, already
30
+ * merged from config and validated. `docker-runtime` turns this (plus the
31
+ * command) into an argv; nothing here is optional or defaulted downstream.
32
+ */
33
+ export interface IsolationPolicy {
34
+ /** Pinned base image the container runs (never chosen dynamically). */
35
+ readonly image: string;
36
+ /** Egress policy — `none` unless the user deliberately widened it. */
37
+ readonly network: NetworkPolicy;
38
+ /** Non-root `uid:gid` the container runs as (host uid, so mounts stay writable). */
39
+ readonly user: string;
40
+ /** Memory cap (`--memory`, also mirrored to `--memory-swap` to disable swap). */
41
+ readonly memory: string;
42
+ /** Process/thread cap (`--pids-limit`). */
43
+ readonly pids: number;
44
+ /** CPU cap (`--cpus`, fractional allowed). */
45
+ readonly cpus: number;
46
+ /** The project workdir, mounted read-write at the identical absolute path. */
47
+ readonly workdir: BindMount;
48
+ /** Extra explicit mounts beyond the workdir (from `sandbox.mounts`). */
49
+ readonly mounts: readonly BindMount[];
50
+ /** Writable in-memory tmp mount point; the rest of the root fs is read-only. */
51
+ readonly tmpfs: string;
52
+ }
53
+ /** Which end of the output to keep when the byte cap is exceeded. */
54
+ export type CaptureBias = "head" | "tail";
55
+ /** Per-exec bounds handed to a runtime. */
56
+ export interface ExecOptions {
57
+ /** Host project directory → bind-mounted as the workdir (same absolute path). */
58
+ readonly cwd: string;
59
+ /** Wall-clock timeout in ms; overrun kills the container and returns a failure. */
60
+ readonly timeoutMs: number;
61
+ /** Cap on combined stdout+stderr bytes captured. */
62
+ readonly maxOutputBytes: number;
63
+ /**
64
+ * Head-bias keeps the start of the output (`run_command`); tail-bias keeps
65
+ * the end, where test runners print their failure summaries (`run_tests`).
66
+ */
67
+ readonly capture: CaptureBias;
68
+ }
69
+ /**
70
+ * The neutral outcome of one sandboxed execution — the SAME shape host
71
+ * execution conceptually produces, so both `run_command` and `run_tests` map it
72
+ * to their own result type without caring which substrate ran the command.
73
+ */
74
+ export interface ExecResult {
75
+ /** Process exit code; `null` on a signal kill / timeout. Exit code is truth. */
76
+ readonly exitCode: number | null;
77
+ /** Combined stdout+stderr, already bias-capped to `maxOutputBytes`. */
78
+ readonly output: string;
79
+ /** True when output was dropped to honor the cap. */
80
+ readonly outputTruncated: boolean;
81
+ /** Measured wall-clock duration of the execution. */
82
+ readonly durationMs: number;
83
+ /** True when the wall-clock timeout fired and the container was killed. */
84
+ readonly timedOut: boolean;
85
+ }
86
+ /**
87
+ * The swappable execution seam. Docker ships as {@link DockerRuntime}; a future
88
+ * podman/other runtime implements this without touching the tools or service.
89
+ * `exec` never falls back to the host and never throws for an ordinary non-zero
90
+ * command exit — it throws only when the container itself cannot run (a coded
91
+ * {@link CruxyError}), so the fail-loud guarantee is structural.
92
+ */
93
+ export interface SandboxRuntime {
94
+ /** Identifier surfaced in errors/logs (e.g. "docker"). */
95
+ readonly name: string;
96
+ /**
97
+ * Ensure `image` is present locally, pulling it if needed. `onPull` fires
98
+ * once, only if a pull actually starts (so callers can surface it via U.4).
99
+ * Throws a coded {@link CruxyError} on pull/build failure — never silently
100
+ * substitutes another image.
101
+ */
102
+ ensureImage(image: string, onPull?: () => void): Promise<void>;
103
+ /** Run `command` inside the box under `policy`, returning a neutral result. */
104
+ exec(command: string, policy: IsolationPolicy, opts: ExecOptions): Promise<ExecResult>;
105
+ }
106
+ /** Result of probing for a container runtime; presence is a capability. */
107
+ export interface SandboxCapability {
108
+ /** True when the runtime binary exists AND its daemon is reachable. */
109
+ readonly available: boolean;
110
+ /** The runtime that was probed. */
111
+ readonly runtime: string;
112
+ /** Why it's unavailable (for the fail-loud error), when not available. */
113
+ readonly detail?: string;
114
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Sandbox execution (C.16) — the isolation boundary beneath the U.3 gate.
3
+ *
4
+ * When the sandbox is enabled, the highest-risk tools (`run_command`,
5
+ * `run_tests`) execute inside a container instead of directly on the host: the
6
+ * project workdir is mounted so the agent edits real files, but the *process*
7
+ * cannot reach the network, cannot touch the host beyond that mount, and cannot
8
+ * exhaust the machine. Approval still gates every command (see U.3) — the
9
+ * sandbox contains what an approved command is able to do; it does not replace
10
+ * the decision to run it.
11
+ *
12
+ * Execution is abstracted behind {@link SandboxRuntime} (Docker ships; podman /
13
+ * none slot in without touching call sites), and the neutral {@link ExecResult}
14
+ * matches what host execution conceptually returns so `run_command`/`run_tests`
15
+ * stay substrate-agnostic — exit code is the source of truth on either path.
16
+ */
17
+ export {};
@@ -3,6 +3,7 @@ import type { ApprovalDecision } from "../approval/types.js";
3
3
  import type { CruxyConfig } from "../config/index.js";
4
4
  import type { StreamRenderer } from "../render/index.js";
5
5
  import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
6
+ import type { SandboxService } from "../sandbox/index.js";
6
7
  import type { SubagentResult, SubagentSpec } from "./types.js";
7
8
  /**
8
9
  * Everything a spawn needs from the surrounding session, injected by the
@@ -24,6 +25,12 @@ export interface SubagentOrchestratorDeps {
24
25
  } | null;
25
26
  projectInstructions?: string | null;
26
27
  renderer?: StreamRenderer;
28
+ /**
29
+ * The run's sandbox (C.16), when enabled. Threaded into the child ctx so a
30
+ * subagent's shell/test commands run in the SAME box as the parent's —
31
+ * sandboxing is never silently dropped for a child.
32
+ */
33
+ sandbox?: SandboxService;
27
34
  /**
28
35
  * Build a fresh, fully-wrapped approval gate for one child run: a NEW
29
36
  * `ApprovalService` (so the child gets its own empty session allowlist —
@@ -65,9 +65,12 @@ export class SubagentOrchestrator {
65
65
  recordArtifacts(action, artifacts, deps.cwd);
66
66
  return decision;
67
67
  },
68
+ sandbox: deps.sandbox,
68
69
  };
69
70
  const label = taskLabel(spec.task);
70
- deps.renderer?.note(`⏵ subagent: ${label}`);
71
+ if (deps.renderer) {
72
+ deps.renderer.note(`${deps.renderer.theme.glyph.play} subagent: ${label}`);
73
+ }
71
74
  deps.renderer?.setPhase({ kind: "subagent", label });
72
75
  // The isolation seam: a brand-new history seeded with ONLY the task. The
73
76
  // parent's messages are never in scope here, and this array dies with the
@@ -96,7 +99,9 @@ export class SubagentOrchestrator {
96
99
  deps.renderer?.setPhase(null);
97
100
  throw err;
98
101
  }
99
- deps.renderer?.note(`✗ subagent failed: ${label}`);
102
+ if (deps.renderer) {
103
+ deps.renderer.note(`${deps.renderer.theme.glyph.failure} subagent failed: ${label}`);
104
+ }
100
105
  deps.renderer?.setPhase(null);
101
106
  return {
102
107
  status: "failed",
@@ -123,7 +128,9 @@ export class SubagentOrchestrator {
123
128
  usage: run.usage,
124
129
  };
125
130
  if (run.stop === "completed") {
126
- this.deps.renderer?.note(`✓ subagent done: ${label}`);
131
+ const r = this.deps.renderer;
132
+ if (r)
133
+ r.note(`${r.theme.glyph.success} subagent done: ${label}`);
127
134
  return { status: "done", ...base };
128
135
  }
129
136
  // Both cap paths are the same outcome for the parent: a truncated, partial
@@ -132,7 +139,9 @@ export class SubagentOrchestrator {
132
139
  const reason = run.stop === "budget"
133
140
  ? (run.stopReason ?? "budget cap reached")
134
141
  : `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
135
- this.deps.renderer?.note(`✗ subagent stopped (budget): ${label}`);
142
+ const r = this.deps.renderer;
143
+ if (r)
144
+ r.note(`${r.theme.glyph.failure} subagent stopped (budget): ${label}`);
136
145
  return {
137
146
  status: "budget-exceeded",
138
147
  ...base,
@@ -195,12 +204,16 @@ function lastAssistantText(messages) {
195
204
  */
196
205
  class SubagentRenderer {
197
206
  caps;
207
+ theme;
198
208
  inner;
199
209
  label;
210
+ prefix;
200
211
  constructor(inner, label) {
201
212
  this.inner = inner;
202
213
  this.label = label;
203
214
  this.caps = inner.caps;
215
+ this.theme = inner.theme;
216
+ this.prefix = `subagent ${inner.theme.glyph.sep} `;
204
217
  }
205
218
  /** Turn framing belongs to the parent's turn — the child's is dropped. */
206
219
  beginTurn() { }
@@ -209,7 +222,7 @@ class SubagentRenderer {
209
222
  write() { }
210
223
  endSegment() { }
211
224
  note(text) {
212
- this.inner.note(`subagent · ${text}`);
225
+ this.inner.note(`${this.prefix}${text}`);
213
226
  }
214
227
  preview(preview) {
215
228
  this.inner.preview(preview);
@@ -231,7 +244,10 @@ class SubagentRenderer {
231
244
  /** The plan executor owns the progress register (C.31) — never the child. */
232
245
  progress() { }
233
246
  toolLifecycle(event) {
234
- this.inner.toolLifecycle({ ...event, label: `subagent · ${event.label}` });
247
+ this.inner.toolLifecycle({
248
+ ...event,
249
+ label: `${this.prefix}${event.label}`,
250
+ });
235
251
  }
236
252
  promptResolved() {
237
253
  this.inner.promptResolved();
@@ -32,7 +32,11 @@ declare const parameters: z.ZodObject<{
32
32
  command?: string | undefined;
33
33
  }>;
34
34
  export interface RunTestsToolDeps {
35
- /** Execution seam (tests inject a fake; default spawns the real command). */
35
+ /**
36
+ * Execution seam (tests inject a fake). When unset, the runner is chosen per
37
+ * run from `ctx.sandbox`: present → {@link SandboxTestRunner} (run in the box,
38
+ * C.16), absent → host {@link CommandTestRunner} — unchanged.
39
+ */
36
40
  runner?: TestRunner;
37
41
  /** Detection seam (defaults to config + package.json detection). */
38
42
  detect?: (ctx: ToolContext) => TestCommand | null;
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { ErrorCode } from "../errors/index.js";
3
3
  import { detectTestCommand } from "./detect.js";
4
4
  import { CommandTestRunner } from "./runner.js";
5
+ import { SandboxTestRunner } from "./sandbox-runner.js";
5
6
  /**
6
7
  * The `run_tests` tool (C.13): execute the project's test suite and return a
7
8
  * structured result the model can iterate on (edit → re-run → repeat). The
@@ -58,7 +59,6 @@ function renderResult(result, command, iteration) {
58
59
  }
59
60
  /** Build the `run_tests` tool. One instance = one session's iteration budget. */
60
61
  export function makeRunTestsTool(deps = {}) {
61
- const runner = deps.runner ?? new CommandTestRunner();
62
62
  const detect = deps.detect ??
63
63
  ((ctx) => detectTestCommand(ctx.cwd, ctx.config));
64
64
  const budget = new TestIterationBudget();
@@ -104,6 +104,13 @@ export function makeRunTestsTool(deps = {}) {
104
104
  error: decision.feedback ?? "test run denied by the user",
105
105
  };
106
106
  }
107
+ // Substrate chosen by ctx.sandbox (C.16): in the box when enabled, else
108
+ // host — identical result shape either way. An explicit deps.runner
109
+ // (tests) always wins. A sandbox that can't run throws (fail loud).
110
+ const runner = deps.runner ??
111
+ (ctx.sandbox
112
+ ? new SandboxTestRunner(ctx.sandbox)
113
+ : new CommandTestRunner());
107
114
  const result = await runner.run(resolved.command, {
108
115
  cwd: ctx.cwd,
109
116
  timeoutMs: ctx.config.shell.timeoutMs,
@@ -0,0 +1,16 @@
1
+ import type { SandboxService } from "../sandbox/index.js";
2
+ import type { TestRunner, TestRunOptions, TestRunResult } from "./types.js";
3
+ /**
4
+ * A {@link TestRunner} that runs the suite inside the C.16 sandbox instead of
5
+ * on the host. It maps the neutral sandbox {@link ExecResult} onto the exact
6
+ * same {@link TestRunResult} the host {@link CommandTestRunner} produces — so
7
+ * honest-green (`passed = exitCode === 0`), tail-biased capture, and the
8
+ * timeout note all hold identically inside the box. A container-start failure
9
+ * is NOT swallowed into a result: `sandbox.exec` throws a coded error that
10
+ * propagates (fail loud), never a fabricated pass.
11
+ */
12
+ export declare class SandboxTestRunner implements TestRunner {
13
+ private readonly sandbox;
14
+ constructor(sandbox: SandboxService);
15
+ run(command: string, opts: TestRunOptions): Promise<TestRunResult>;
16
+ }
@@ -0,0 +1,47 @@
1
+ import { parseFailures } from "./parse.js";
2
+ /**
3
+ * A {@link TestRunner} that runs the suite inside the C.16 sandbox instead of
4
+ * on the host. It maps the neutral sandbox {@link ExecResult} onto the exact
5
+ * same {@link TestRunResult} the host {@link CommandTestRunner} produces — so
6
+ * honest-green (`passed = exitCode === 0`), tail-biased capture, and the
7
+ * timeout note all hold identically inside the box. A container-start failure
8
+ * is NOT swallowed into a result: `sandbox.exec` throws a coded error that
9
+ * propagates (fail loud), never a fabricated pass.
10
+ */
11
+ export class SandboxTestRunner {
12
+ sandbox;
13
+ constructor(sandbox) {
14
+ this.sandbox = sandbox;
15
+ }
16
+ async run(command, opts) {
17
+ const result = await this.sandbox.exec(command, {
18
+ cwd: opts.cwd,
19
+ timeoutMs: opts.timeoutMs,
20
+ maxOutputBytes: opts.captureBytes,
21
+ capture: "tail", // failures live at the end of test output
22
+ });
23
+ if (result.timedOut) {
24
+ return {
25
+ passed: false,
26
+ exitCode: null,
27
+ durationMs: result.durationMs,
28
+ failures: [],
29
+ output: result.output +
30
+ `\n… [test run timed out after ${opts.timeoutMs}ms and was killed]`,
31
+ outputTruncated: result.outputTruncated,
32
+ };
33
+ }
34
+ // THE honest-green rule, unchanged by the substrate: exit 0 means passed.
35
+ const passed = result.exitCode === 0;
36
+ const parsed = passed ? { failures: [] } : parseFailures(result.output);
37
+ return {
38
+ passed,
39
+ exitCode: result.exitCode,
40
+ durationMs: result.durationMs,
41
+ ...(parsed.total !== undefined ? { total: parsed.total } : {}),
42
+ failures: parsed.failures,
43
+ output: result.output,
44
+ outputTruncated: result.outputTruncated,
45
+ };
46
+ }
47
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./tokens.js";
2
+ export * from "./resolve.js";
@@ -0,0 +1,2 @@
1
+ export * from "./tokens.js";
2
+ export * from "./resolve.js";
@@ -0,0 +1,32 @@
1
+ import { type Theme, type ThemeCapabilities } from "./tokens.js";
2
+ /**
3
+ * Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
4
+ * fully independent by construction:
5
+ *
6
+ * - `color` drives the stylers only. `pc.createColors(false)` is the identity,
7
+ * so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
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]`.
12
+ */
13
+ export declare function resolveTheme(caps: ThemeCapabilities): Theme;
14
+ /**
15
+ * Whether the terminal can render unicode glyphs. `TERM=dumb` and an explicit
16
+ * `CRUXY_ASCII` opt-in fall back to the ASCII table; everything else (real
17
+ * terminals AND pipes) keeps unicode — piping ✓/✗ to a file is fine, and this
18
+ * preserves pre-U.1 behavior for non-TTY output.
19
+ *
20
+ * The single source of the rule: `render/capabilities.ts` calls this to fill
21
+ * `RenderCapabilities.unicode`, and the boolean-seam surfaces (errors,
22
+ * approval, plan, onboarding, CLI commands) call it to build a theme without a
23
+ * signature change.
24
+ */
25
+ export declare function detectUnicode(env?: NodeJS.ProcessEnv): boolean;
26
+ /**
27
+ * 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.
31
+ */
32
+ export declare function themeForColor(color: boolean, env?: NodeJS.ProcessEnv): Theme;
@@ -0,0 +1,73 @@
1
+ import pc from "picocolors";
2
+ import { ASCII_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
3
+ /**
4
+ * Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
5
+ * fully independent by construction:
6
+ *
7
+ * - `color` drives the stylers only. `pc.createColors(false)` is the identity,
8
+ * so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
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]`.
13
+ */
14
+ export function resolveTheme(caps) {
15
+ const c = pc.createColors(caps.color);
16
+ const glyph = caps.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
17
+ const strong = c.bold;
18
+ const indent = (text, level = 1) => {
19
+ const pad = " ".repeat(Math.max(0, level));
20
+ return text
21
+ .split("\n")
22
+ .map((line) => (line === "" ? line : pad + line))
23
+ .join("\n");
24
+ };
25
+ return {
26
+ danger: c.red,
27
+ warning: c.yellow,
28
+ success: c.green,
29
+ accent: c.cyan,
30
+ muted: c.dim,
31
+ strong,
32
+ syntax: {
33
+ keyword: c.magenta,
34
+ string: c.green,
35
+ number: c.yellow,
36
+ comment: c.dim,
37
+ },
38
+ glyph,
39
+ heading: strong,
40
+ indent,
41
+ kv: (key, value, keyWidth) => `${strong(keyWidth ? key.padEnd(keyWidth) : key)} ${value}`,
42
+ sep: ` ${glyph.sep} `,
43
+ color: caps.color,
44
+ unicode: caps.unicode,
45
+ };
46
+ }
47
+ /**
48
+ * Whether the terminal can render unicode glyphs. `TERM=dumb` and an explicit
49
+ * `CRUXY_ASCII` opt-in fall back to the ASCII table; everything else (real
50
+ * terminals AND pipes) keeps unicode — piping ✓/✗ to a file is fine, and this
51
+ * preserves pre-U.1 behavior for non-TTY output.
52
+ *
53
+ * The single source of the rule: `render/capabilities.ts` calls this to fill
54
+ * `RenderCapabilities.unicode`, and the boolean-seam surfaces (errors,
55
+ * approval, plan, onboarding, CLI commands) call it to build a theme without a
56
+ * signature change.
57
+ */
58
+ export function detectUnicode(env = process.env) {
59
+ if (env.CRUXY_ASCII !== undefined && env.CRUXY_ASCII !== "")
60
+ return false;
61
+ if (env.TERM === "dumb")
62
+ return false;
63
+ return true;
64
+ }
65
+ /**
66
+ * 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.
70
+ */
71
+ export function themeForColor(color, env = process.env) {
72
+ return resolveTheme({ color, unicode: detectUnicode(env) });
73
+ }