@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
@@ -87,7 +87,7 @@ export function withCheckpointGate(requestApproval, checkpoints, cwd) {
87
87
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
88
88
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
89
89
  */
90
- export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints) {
90
+ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints, sandbox) {
91
91
  const provider = createProvider({
92
92
  provider: config.model.provider,
93
93
  apiKey,
@@ -120,6 +120,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
120
120
  git,
121
121
  projectInstructions,
122
122
  renderer,
123
+ sandbox,
123
124
  makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
124
125
  });
125
126
  if (config.subagent.maxDepth > 0) {
@@ -136,7 +137,13 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
136
137
  policy: planPolicy,
137
138
  io,
138
139
  });
139
- const ctx = { cwd, config, logger, requestApproval: gate(approval) };
140
+ const ctx = {
141
+ cwd,
142
+ config,
143
+ logger,
144
+ requestApproval: gate(approval),
145
+ sandbox,
146
+ };
140
147
  const planRunner = ({ messages, projectInstructions, renderer: turnRenderer, }) => runPlanSession({
141
148
  provider,
142
149
  config,
@@ -166,7 +173,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
166
173
  interactive: ttyInteractive,
167
174
  io,
168
175
  });
169
- const ctx = { cwd, config, logger, requestApproval: gate(approval) };
176
+ const ctx = { cwd, config, logger, requestApproval: gate(approval), sandbox };
170
177
  return new Session({
171
178
  provider,
172
179
  registry: execRegistry,
@@ -1,3 +1,4 @@
1
+ import { resolveTheme } from "../theme/index.js";
1
2
  /** Erase the current line and return the cursor to column 0 (same as U.2). */
2
3
  const CLEAR_LINE = "\r\x1b[2K";
3
4
  /** Move the cursor up one row. */
@@ -11,6 +12,7 @@ export function stripAnsi(text) {
11
12
  }
12
13
  export function createFrame(write, caps) {
13
14
  let drawn = 0;
15
+ const ellipsis = resolveTheme(caps).glyph.ellipsis;
14
16
  /**
15
17
  * Truncate to width-1 (cursor rests after the last cell; a full-width row
16
18
  * would auto-wrap on some terminals). Width is measured on VISIBLE
@@ -23,7 +25,7 @@ export function createFrame(write, caps) {
23
25
  const plain = stripAnsi(line);
24
26
  if (plain.length <= room)
25
27
  return line;
26
- return plain.slice(0, room - 1) + "…";
28
+ return plain.slice(0, room - 1) + ellipsis;
27
29
  };
28
30
  const erase = () => {
29
31
  if (drawn === 0)
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { type Theme } from "../theme/index.js";
2
2
  import { type ComponentIO, type InteractiveResult } from "./input.js";
3
3
  /**
4
4
  * Fuzzy finding (U.7): a deterministic, honest subsequence scorer (pure,
@@ -36,10 +36,10 @@ export interface RankedItem<T> {
36
36
  */
37
37
  export declare function rankItems<T>(items: readonly T[], toLabel: (item: T) => string, query: string): RankedItem<T>[];
38
38
  /**
39
- * Bold the matched characters of a label. With color off (NO_COLOR, pipe)
40
- * picocolors' disabled palette is the identity — plain text, zero ANSI.
39
+ * Bold the matched characters of a label via the theme's accent role. With
40
+ * color off (NO_COLOR, pipe) the roles are identity — plain text, zero ANSI.
41
41
  */
42
- export declare function highlightMatch(label: string, positions: readonly number[], colors: ReturnType<typeof pc.createColors>): string;
42
+ export declare function highlightMatch(label: string, positions: readonly number[], theme: Theme): string;
43
43
  export interface FuzzyFindOptions<T> {
44
44
  /** Label an item filters/renders under. Required — items are opaque. */
45
45
  toLabel: (item: T) => string;
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { resolveTheme } from "../theme/index.js";
2
2
  import { createFrame } from "./frame.js";
3
3
  import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
4
4
  /** Word-boundary characters that earn the boundary bonus for the NEXT char. */
@@ -69,16 +69,16 @@ export function rankItems(items, toLabel, query) {
69
69
  return ranked.map(({ item, label, match }) => ({ item, label, match }));
70
70
  }
71
71
  /**
72
- * Bold the matched characters of a label. With color off (NO_COLOR, pipe)
73
- * picocolors' disabled palette is the identity — plain text, zero ANSI.
72
+ * Bold the matched characters of a label via the theme's accent role. With
73
+ * color off (NO_COLOR, pipe) the roles are identity — plain text, zero ANSI.
74
74
  */
75
- export function highlightMatch(label, positions, colors) {
75
+ export function highlightMatch(label, positions, theme) {
76
76
  if (positions.length === 0)
77
77
  return label;
78
78
  const matched = new Set(positions);
79
79
  let out = "";
80
80
  for (let i = 0; i < label.length; i++) {
81
- out += matched.has(i) ? colors.bold(colors.cyan(label[i])) : label[i];
81
+ out += matched.has(i) ? theme.strong(theme.accent(label[i])) : label[i];
82
82
  }
83
83
  return out;
84
84
  }
@@ -94,7 +94,8 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
94
94
  return fallback;
95
95
  if (items.length === 0)
96
96
  return { kind: "cancelled" };
97
- const colors = pc.createColors(io.caps.color);
97
+ const t = resolveTheme(io.caps);
98
+ const g = t.glyph;
98
99
  const maxVisible = opts.maxVisible ?? 10;
99
100
  const frame = createFrame(io.write, io.caps);
100
101
  let query = "";
@@ -102,10 +103,10 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
102
103
  const paint = (ranked) => {
103
104
  const lines = [];
104
105
  if (opts.title)
105
- lines.push(colors.bold(opts.title));
106
- lines.push(`${colors.cyan("›")} ${query}${colors.dim("▏")}`);
106
+ lines.push(t.heading(opts.title));
107
+ lines.push(`${t.accent(g.caret)} ${query}${t.muted(g.cursorBar)}`);
107
108
  if (ranked.length === 0) {
108
- lines.push(colors.dim(" no results — backspace to widen"));
109
+ lines.push(t.muted(" no results — backspace to widen"));
109
110
  }
110
111
  else {
111
112
  // Keep the highlighted row inside the viewport.
@@ -113,13 +114,13 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
113
114
  const visible = ranked.slice(top, top + maxVisible);
114
115
  for (const [i, row] of visible.entries()) {
115
116
  const selected = top + i === cursor;
116
- const marker = selected ? colors.cyan("❯") : " ";
117
- const label = highlightMatch(row.label, row.match.positions, colors);
118
- lines.push(`${marker} ${selected ? label : colors.dim(label)}`);
117
+ const marker = selected ? t.accent(g.pointer) : " ";
118
+ const label = highlightMatch(row.label, row.match.positions, t);
119
+ lines.push(`${marker} ${selected ? label : t.muted(label)}`);
119
120
  }
120
121
  const hidden = ranked.length - visible.length;
121
122
  if (hidden > 0)
122
- lines.push(colors.dim(` ${hidden} more`));
123
+ lines.push(t.muted(` ${g.ellipsis} ${hidden} more`));
123
124
  }
124
125
  frame.render(lines);
125
126
  };
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { resolveTheme } from "../theme/index.js";
2
2
  import { createFrame } from "./frame.js";
3
3
  import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
4
4
  /**
@@ -15,26 +15,27 @@ export async function selectList(items, opts = {}, io = defaultComponentIO()) {
15
15
  if (items.length === 0)
16
16
  return { kind: "cancelled" };
17
17
  const toLabel = opts.toLabel ?? ((item) => String(item));
18
- const colors = pc.createColors(io.caps.color);
18
+ const t = resolveTheme(io.caps);
19
+ const g = t.glyph;
19
20
  const maxVisible = opts.maxVisible ?? 10;
20
21
  const frame = createFrame(io.write, io.caps);
21
22
  let cursor = Math.min(Math.max(opts.initialIndex ?? 0, 0), items.length - 1);
22
23
  const paint = () => {
23
24
  const lines = [];
24
25
  if (opts.title)
25
- lines.push(colors.bold(opts.title));
26
+ lines.push(t.heading(opts.title));
26
27
  const top = Math.min(Math.max(0, cursor - maxVisible + 1), Math.max(0, items.length - maxVisible));
27
28
  const visible = items.slice(top, top + maxVisible);
28
29
  for (const [i, item] of visible.entries()) {
29
30
  const selected = top + i === cursor;
30
- const marker = selected ? colors.cyan("❯") : " ";
31
+ const marker = selected ? t.accent(g.pointer) : " ";
31
32
  const label = toLabel(item);
32
- lines.push(`${marker} ${selected ? label : colors.dim(label)}`);
33
+ lines.push(`${marker} ${selected ? label : t.muted(label)}`);
33
34
  }
34
35
  const hidden = items.length - visible.length;
35
36
  if (hidden > 0)
36
- lines.push(colors.dim(` ${hidden} more`));
37
- lines.push(colors.dim(" ↑/↓ move · enter select · esc cancel"));
37
+ lines.push(t.muted(` ${g.ellipsis} ${hidden} more`));
38
+ lines.push(t.muted(` ${g.caretUp}/${g.caretDown} move ${g.sep} enter select ${g.sep} esc cancel`));
38
39
  frame.render(lines);
39
40
  };
40
41
  io.keys.begin();
@@ -283,6 +283,62 @@ export declare const SubagentConfigSchema: z.ZodObject<{
283
283
  timeoutMs?: number | undefined;
284
284
  } | undefined;
285
285
  }>;
286
+ /**
287
+ * Sandbox / container execution (C.16): defense-in-depth beneath the U.3 gate.
288
+ * When enabled, `run_command` and `run_tests` execute inside an isolated,
289
+ * network-denied, resource-capped, non-root container with only the project
290
+ * workdir mounted — never on the host directly. OFF by default (a container
291
+ * runtime isn't universal); turning it on is a hard promise, so if the runtime
292
+ * is missing execution FAILS LOUD rather than silently falling back to the host.
293
+ */
294
+ export declare const SandboxConfigSchema: z.ZodObject<{
295
+ /** Master switch. When true, shell + test commands run in the sandbox. */
296
+ enabled: z.ZodDefault<z.ZodBoolean>;
297
+ /**
298
+ * Base image the container runs. Must ship a POSIX `sh`. Pin by digest
299
+ * (`name@sha256:…`) where possible; cruxy never builds or substitutes one.
300
+ */
301
+ image: z.ZodDefault<z.ZodString>;
302
+ /**
303
+ * Egress policy. `none` denies all network (default deny); `host-loopback`
304
+ * permits reaching services on the host; `full` allows outbound. Anything
305
+ * other than `none` is a deliberate widening the user must opt into.
306
+ */
307
+ network: z.ZodDefault<z.ZodEnum<["none", "host-loopback", "full"]>>;
308
+ /** Memory cap (docker `--memory` syntax, e.g. `512m`, `2g`). */
309
+ memory: z.ZodDefault<z.ZodString>;
310
+ /** Process/thread cap (`--pids-limit`) — a fork-bomb guard. */
311
+ pids: z.ZodDefault<z.ZodNumber>;
312
+ /** CPU cap (docker `--cpus`, fractional allowed). */
313
+ cpus: z.ZodDefault<z.ZodNumber>;
314
+ /** Wall-clock timeout in ms; falls back to `shell.timeoutMs` when unset. */
315
+ timeout: z.ZodOptional<z.ZodNumber>;
316
+ /**
317
+ * Extra bind mounts beyond the workdir + tmp, each `src:dst[:ro|:rw]`.
318
+ * Explicit by construction — the default exposes ONLY the workdir. The
319
+ * docker socket and the cruxy home are rejected here (see policy.ts).
320
+ */
321
+ mounts: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
322
+ }, "strict", z.ZodTypeAny, {
323
+ image: string;
324
+ enabled: boolean;
325
+ memory: string;
326
+ network: "none" | "host-loopback" | "full";
327
+ pids: number;
328
+ cpus: number;
329
+ mounts: string[];
330
+ timeout?: number | undefined;
331
+ }, {
332
+ image?: string | undefined;
333
+ enabled?: boolean | undefined;
334
+ memory?: string | undefined;
335
+ network?: "none" | "host-loopback" | "full" | undefined;
336
+ pids?: number | undefined;
337
+ cpus?: number | undefined;
338
+ timeout?: number | undefined;
339
+ mounts?: string[] | undefined;
340
+ }>;
341
+ export type SandboxConfig = z.infer<typeof SandboxConfigSchema>;
286
342
  /** MCP server entry — stdio or URL transport (wired up in a later phase). */
287
343
  export declare const McpServerSchema: z.ZodObject<{
288
344
  command: z.ZodOptional<z.ZodString>;
@@ -551,6 +607,53 @@ export declare const CruxyConfigSchema: z.ZodObject<{
551
607
  command?: string | undefined;
552
608
  captureBytes?: number | undefined;
553
609
  }>>;
610
+ sandbox: z.ZodDefault<z.ZodObject<{
611
+ /** Master switch. When true, shell + test commands run in the sandbox. */
612
+ enabled: z.ZodDefault<z.ZodBoolean>;
613
+ /**
614
+ * Base image the container runs. Must ship a POSIX `sh`. Pin by digest
615
+ * (`name@sha256:…`) where possible; cruxy never builds or substitutes one.
616
+ */
617
+ image: z.ZodDefault<z.ZodString>;
618
+ /**
619
+ * Egress policy. `none` denies all network (default deny); `host-loopback`
620
+ * permits reaching services on the host; `full` allows outbound. Anything
621
+ * other than `none` is a deliberate widening the user must opt into.
622
+ */
623
+ network: z.ZodDefault<z.ZodEnum<["none", "host-loopback", "full"]>>;
624
+ /** Memory cap (docker `--memory` syntax, e.g. `512m`, `2g`). */
625
+ memory: z.ZodDefault<z.ZodString>;
626
+ /** Process/thread cap (`--pids-limit`) — a fork-bomb guard. */
627
+ pids: z.ZodDefault<z.ZodNumber>;
628
+ /** CPU cap (docker `--cpus`, fractional allowed). */
629
+ cpus: z.ZodDefault<z.ZodNumber>;
630
+ /** Wall-clock timeout in ms; falls back to `shell.timeoutMs` when unset. */
631
+ timeout: z.ZodOptional<z.ZodNumber>;
632
+ /**
633
+ * Extra bind mounts beyond the workdir + tmp, each `src:dst[:ro|:rw]`.
634
+ * Explicit by construction — the default exposes ONLY the workdir. The
635
+ * docker socket and the cruxy home are rejected here (see policy.ts).
636
+ */
637
+ mounts: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
638
+ }, "strict", z.ZodTypeAny, {
639
+ image: string;
640
+ enabled: boolean;
641
+ memory: string;
642
+ network: "none" | "host-loopback" | "full";
643
+ pids: number;
644
+ cpus: number;
645
+ mounts: string[];
646
+ timeout?: number | undefined;
647
+ }, {
648
+ image?: string | undefined;
649
+ enabled?: boolean | undefined;
650
+ memory?: string | undefined;
651
+ network?: "none" | "host-loopback" | "full" | undefined;
652
+ pids?: number | undefined;
653
+ cpus?: number | undefined;
654
+ timeout?: number | undefined;
655
+ mounts?: string[] | undefined;
656
+ }>>;
554
657
  mcpServers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
555
658
  command: z.ZodOptional<z.ZodString>;
556
659
  args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
@@ -634,6 +737,16 @@ export declare const CruxyConfigSchema: z.ZodObject<{
634
737
  captureBytes: number;
635
738
  command?: string | undefined;
636
739
  };
740
+ sandbox: {
741
+ image: string;
742
+ enabled: boolean;
743
+ memory: string;
744
+ network: "none" | "host-loopback" | "full";
745
+ pids: number;
746
+ cpus: number;
747
+ mounts: string[];
748
+ timeout?: number | undefined;
749
+ };
637
750
  mcpServers: Record<string, {
638
751
  command?: string | undefined;
639
752
  args?: string[] | undefined;
@@ -709,6 +822,16 @@ export declare const CruxyConfigSchema: z.ZodObject<{
709
822
  command?: string | undefined;
710
823
  captureBytes?: number | undefined;
711
824
  } | undefined;
825
+ sandbox?: {
826
+ image?: string | undefined;
827
+ enabled?: boolean | undefined;
828
+ memory?: string | undefined;
829
+ network?: "none" | "host-loopback" | "full" | undefined;
830
+ pids?: number | undefined;
831
+ cpus?: number | undefined;
832
+ timeout?: number | undefined;
833
+ mounts?: string[] | undefined;
834
+ } | undefined;
712
835
  mcpServers?: Record<string, {
713
836
  command?: string | undefined;
714
837
  args?: string[] | undefined;
@@ -194,6 +194,45 @@ export const SubagentConfigSchema = z
194
194
  .default({}),
195
195
  })
196
196
  .strict();
197
+ /**
198
+ * Sandbox / container execution (C.16): defense-in-depth beneath the U.3 gate.
199
+ * When enabled, `run_command` and `run_tests` execute inside an isolated,
200
+ * network-denied, resource-capped, non-root container with only the project
201
+ * workdir mounted — never on the host directly. OFF by default (a container
202
+ * runtime isn't universal); turning it on is a hard promise, so if the runtime
203
+ * is missing execution FAILS LOUD rather than silently falling back to the host.
204
+ */
205
+ export const SandboxConfigSchema = z
206
+ .object({
207
+ /** Master switch. When true, shell + test commands run in the sandbox. */
208
+ enabled: z.boolean().default(false),
209
+ /**
210
+ * Base image the container runs. Must ship a POSIX `sh`. Pin by digest
211
+ * (`name@sha256:…`) where possible; cruxy never builds or substitutes one.
212
+ */
213
+ image: z.string().min(1).default("node:20-bookworm-slim"),
214
+ /**
215
+ * Egress policy. `none` denies all network (default deny); `host-loopback`
216
+ * permits reaching services on the host; `full` allows outbound. Anything
217
+ * other than `none` is a deliberate widening the user must opt into.
218
+ */
219
+ network: z.enum(["none", "host-loopback", "full"]).default("none"),
220
+ /** Memory cap (docker `--memory` syntax, e.g. `512m`, `2g`). */
221
+ memory: z.string().min(1).default("512m"),
222
+ /** Process/thread cap (`--pids-limit`) — a fork-bomb guard. */
223
+ pids: z.number().int().positive().default(512),
224
+ /** CPU cap (docker `--cpus`, fractional allowed). */
225
+ cpus: z.number().positive().default(1),
226
+ /** Wall-clock timeout in ms; falls back to `shell.timeoutMs` when unset. */
227
+ timeout: z.number().int().positive().optional(),
228
+ /**
229
+ * Extra bind mounts beyond the workdir + tmp, each `src:dst[:ro|:rw]`.
230
+ * Explicit by construction — the default exposes ONLY the workdir. The
231
+ * docker socket and the cruxy home are rejected here (see policy.ts).
232
+ */
233
+ mounts: z.array(z.string().min(1)).default([]),
234
+ })
235
+ .strict();
197
236
  /** MCP server entry — stdio or URL transport (wired up in a later phase). */
198
237
  export const McpServerSchema = z
199
238
  .object({
@@ -216,6 +255,7 @@ export const CruxyConfigSchema = z
216
255
  checkpoint: CheckpointConfigSchema.default({}),
217
256
  subagent: SubagentConfigSchema.default({}),
218
257
  test: TestConfigSchema.default({}),
258
+ sandbox: SandboxConfigSchema.default({}),
219
259
  mcpServers: z.record(z.string(), McpServerSchema).default({}),
220
260
  logLevel: z.enum(LOG_LEVELS).default("info"),
221
261
  })
@@ -25,6 +25,27 @@ export declare function permissionDenied(path: string, underlying?: unknown): Cr
25
25
  export declare function indexEmbedderUnavailable(underlying?: unknown): CruxyError;
26
26
  export declare function indexStoreUnavailable(underlying?: unknown): CruxyError;
27
27
  export declare function indexFailed(underlying?: unknown): CruxyError;
28
+ /**
29
+ * Sandboxing was requested (`--sandbox` / `sandbox.enabled`) but no container
30
+ * runtime is available — docker isn't installed, or its daemon isn't running.
31
+ * THE CRITICAL RULE: this is fatal. A user who turned on the sandbox must never
32
+ * be silently dropped back onto un-sandboxed host execution, so we fail loud
33
+ * here rather than run the command anyway.
34
+ */
35
+ export declare function sandboxUnavailable(runtime?: string, underlying?: unknown): CruxyError;
36
+ /**
37
+ * The pinned sandbox base image could not be made available (the first-run
38
+ * pull failed, or the image does not exist). Fatal — without the image there
39
+ * is nothing to execute inside, and we never substitute another image.
40
+ */
41
+ export declare function sandboxImage(image: string, underlying?: unknown): CruxyError;
42
+ /**
43
+ * The container itself failed to start or run (a docker-level failure — bad
44
+ * flags, daemon error, `docker run` exit 125), as opposed to the command
45
+ * inside exiting non-zero (which is an ordinary result). Fatal: a sandbox that
46
+ * can't launch is never papered over with a host run.
47
+ */
48
+ export declare function sandboxExec(underlying?: unknown): CruxyError;
28
49
  /**
29
50
  * A side-effecting action needs approval but cruxy can't ask (non-interactive,
30
51
  * no policy). Default-deny — never auto-approve. A distinct exit code (10) so CI
@@ -224,6 +224,64 @@ export function indexFailed(underlying) {
224
224
  underlying,
225
225
  });
226
226
  }
227
+ // ── sandbox (exit 12) ─────────────────────────────────────────────────────────
228
+ /**
229
+ * Sandboxing was requested (`--sandbox` / `sandbox.enabled`) but no container
230
+ * runtime is available — docker isn't installed, or its daemon isn't running.
231
+ * THE CRITICAL RULE: this is fatal. A user who turned on the sandbox must never
232
+ * be silently dropped back onto un-sandboxed host execution, so we fail loud
233
+ * here rather than run the command anyway.
234
+ */
235
+ export function sandboxUnavailable(runtime = "docker", underlying) {
236
+ return new CruxyError({
237
+ code: ErrorCode.SandboxUnavailable,
238
+ title: `sandbox is enabled but the ${runtime} runtime is unavailable`,
239
+ cause: messageOf(underlying) ??
240
+ `${runtime} is not installed or its daemon is not reachable`,
241
+ nextSteps: [
242
+ `install ${runtime} and make sure its daemon is running`,
243
+ "or disable the sandbox: `cruxy config set sandbox.enabled false` (or drop --sandbox)",
244
+ ],
245
+ underlying,
246
+ meta: { runtime },
247
+ });
248
+ }
249
+ /**
250
+ * The pinned sandbox base image could not be made available (the first-run
251
+ * pull failed, or the image does not exist). Fatal — without the image there
252
+ * is nothing to execute inside, and we never substitute another image.
253
+ */
254
+ export function sandboxImage(image, underlying) {
255
+ return new CruxyError({
256
+ code: ErrorCode.SandboxImage,
257
+ title: `could not pull the sandbox image "${image}"`,
258
+ cause: messageOf(underlying),
259
+ nextSteps: [
260
+ "check the image name/tag and your network access to the registry",
261
+ 'set a reachable image, e.g. `cruxy config set sandbox.image "node:20-bookworm-slim"`',
262
+ ],
263
+ underlying,
264
+ meta: { image },
265
+ });
266
+ }
267
+ /**
268
+ * The container itself failed to start or run (a docker-level failure — bad
269
+ * flags, daemon error, `docker run` exit 125), as opposed to the command
270
+ * inside exiting non-zero (which is an ordinary result). Fatal: a sandbox that
271
+ * can't launch is never papered over with a host run.
272
+ */
273
+ export function sandboxExec(underlying) {
274
+ return new CruxyError({
275
+ code: ErrorCode.SandboxExec,
276
+ title: "the sandbox container failed to start",
277
+ cause: messageOf(underlying),
278
+ nextSteps: [
279
+ "re-run with --verbose for the underlying docker error",
280
+ "verify the docker daemon is healthy (`docker info`)",
281
+ ],
282
+ underlying,
283
+ });
284
+ }
227
285
  // ── approval (exit 10) ────────────────────────────────────────────────────────
228
286
  /**
229
287
  * A side-effecting action needs approval but cruxy can't ask (non-interactive,
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { themeForColor } from "../theme/index.js";
2
2
  /**
3
3
  * Decide whether to colorize: honor `NO_COLOR` (disable) and `FORCE_COLOR`
4
4
  * (enable), otherwise color only when writing to a TTY.
@@ -13,27 +13,27 @@ export function shouldUseColor(stream = process.stderr, env = process.env) {
13
13
  /** The default terminal formatter: title, cause, next steps, code (+ verbose). */
14
14
  export class TerminalFormatter {
15
15
  format(err, opts) {
16
- const c = pc.createColors(opts.color);
16
+ const t = themeForColor(opts.color);
17
17
  const lines = [];
18
18
  // 1. Title — one plain line, what failed.
19
- lines.push(c.red(c.bold(err.title)));
19
+ lines.push(t.danger(t.strong(err.title)));
20
20
  // 2. Cause — the specific reason, when known.
21
21
  if (err.cause)
22
- lines.push(`${c.dim("Cause:")} ${err.cause}`);
22
+ lines.push(`${t.muted("Cause:")} ${err.cause}`);
23
23
  // 3. Next step(s) — the concrete action(s) to take.
24
24
  if (err.nextSteps.length > 0) {
25
25
  lines.push("");
26
- lines.push(c.bold("Next steps:"));
26
+ lines.push(t.heading("Next steps:"));
27
27
  for (const step of err.nextSteps)
28
- lines.push(` ${c.cyan("→")} ${step}`);
28
+ lines.push(` ${t.accent(t.glyph.arrow)} ${step}`);
29
29
  }
30
30
  // 4. Code — the stable, greppable id.
31
31
  lines.push("");
32
- lines.push(c.dim(`[${err.code}]`));
32
+ lines.push(t.muted(`[${err.code}]`));
33
33
  // Verbose-only: the preserved underlying error.
34
34
  if (opts.verbose && err.underlying !== undefined) {
35
35
  lines.push("");
36
- lines.push(c.dim("Underlying error:"));
36
+ lines.push(t.muted("Underlying error:"));
37
37
  lines.push(indent(stackOf(err.underlying)));
38
38
  }
39
39
  return lines.join("\n");
@@ -52,6 +52,11 @@ export declare const ErrorCode: {
52
52
  readonly TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND";
53
53
  /** Carried inside a run_tests result (informational) — never fatal by itself. */
54
54
  readonly TestIterationLimit: "CRUXY_E_TEST_ITERATION_LIMIT";
55
+ /** Sandbox was enabled but no container runtime is available — fail loud,
56
+ * NEVER fall back to un-sandboxed host execution. */
57
+ readonly SandboxUnavailable: "CRUXY_E_SANDBOX_UNAVAILABLE";
58
+ readonly SandboxImage: "CRUXY_E_SANDBOX_IMAGE";
59
+ readonly SandboxExec: "CRUXY_E_SANDBOX_EXEC";
55
60
  };
56
61
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
57
62
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -64,6 +64,12 @@ export const ErrorCode = {
64
64
  TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND",
65
65
  /** Carried inside a run_tests result (informational) — never fatal by itself. */
66
66
  TestIterationLimit: "CRUXY_E_TEST_ITERATION_LIMIT",
67
+ // sandbox (exit 12)
68
+ /** Sandbox was enabled but no container runtime is available — fail loud,
69
+ * NEVER fall back to un-sandboxed host execution. */
70
+ SandboxUnavailable: "CRUXY_E_SANDBOX_UNAVAILABLE",
71
+ SandboxImage: "CRUXY_E_SANDBOX_IMAGE",
72
+ SandboxExec: "CRUXY_E_SANDBOX_EXEC",
67
73
  };
68
74
  /**
69
75
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -112,6 +118,11 @@ const EXIT_CODES = {
112
118
  // surfaces inside a run_tests result and is never fatal by itself.
113
119
  [ErrorCode.TestCommandNotFound]: 2,
114
120
  [ErrorCode.TestIterationLimit]: 11,
121
+ // A requested sandbox that can't be honored is fatal (fail loud, no host
122
+ // fallback) — its own exit code so CI/scripts can tell it apart.
123
+ [ErrorCode.SandboxUnavailable]: 12,
124
+ [ErrorCode.SandboxImage]: 12,
125
+ [ErrorCode.SandboxExec]: 12,
115
126
  };
116
127
  /** The process exit code for an error code (defaults to 1 for safety). */
117
128
  export function exitCodeFor(code) {
@@ -1,5 +1,5 @@
1
1
  import { AuthError, NetworkError, createProvider } from "@cruxy/sdk";
2
- import pc from "picocolors";
2
+ import { themeForColor } from "../theme/index.js";
3
3
  import { resolveApiKey, writeCredential } from "../config/index.js";
4
4
  import { newOnboardingState, readOnboardingState, writeOnboardingState, } from "./detect.js";
5
5
  import { acquireKeyStep, firstWinStep, scaffoldStep } from "./steps.js";
@@ -11,8 +11,8 @@ import { acquireKeyStep, firstWinStep, scaffoldStep } from "./steps.js";
11
11
  */
12
12
  export async function runOnboarding(opts) {
13
13
  const { io, deps, provider } = opts;
14
- const col = pc.createColors(io.color);
15
- io.write(`${col.cyan(col.bold("Welcome to cruxy"))} — let's get you set up.\n`);
14
+ const t = themeForColor(io.color);
15
+ io.write(`${t.accent(t.strong("Welcome to cruxy"))} — let's get you set up.\n`);
16
16
  let state = deps.readState() ?? newOnboardingState();
17
17
  let apiKey = deps.resolveApiKey(provider);
18
18
  // ── key (mandatory; skipped if already resolvable unless forceKey) ─────────
@@ -24,7 +24,7 @@ export async function runOnboarding(opts) {
24
24
  if (result.status !== "ok") {
25
25
  // Failed (unreachable / rejected) — surface guidance, no marker.
26
26
  if (result.message)
27
- io.write(`${col.dim(result.message)}\n`);
27
+ io.write(`${t.muted(result.message)}\n`);
28
28
  return { completed: false, aborted: false };
29
29
  }
30
30
  apiKey = result.apiKey;
@@ -32,7 +32,7 @@ export async function runOnboarding(opts) {
32
32
  deps.writeState(state);
33
33
  }
34
34
  else {
35
- io.write(`${col.green("✓")} using your existing API key.\n`);
35
+ io.write(`${t.success(t.glyph.success)} using your existing API key.\n`);
36
36
  state = { ...state, keyConfigured: true };
37
37
  }
38
38
  // ── optional steps (Ctrl-C here just skips them; the key is already safe) ───
@@ -43,7 +43,7 @@ export async function runOnboarding(opts) {
43
43
  // ── complete ───────────────────────────────────────────────────────────────
44
44
  state = { ...state, completedAt: deps.now() };
45
45
  deps.writeState(state);
46
- io.write(`${col.green(col.bold("✓ all set"))} — happy hacking.\n`);
46
+ io.write(`${t.success(t.strong(`${t.glyph.success} all set`))} — happy hacking.\n`);
47
47
  return { completed: true, aborted: false, apiKey };
48
48
  }
49
49
  /**