@cruxy/cli 0.10.0 → 0.12.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 (70) hide show
  1. package/dist/approval/classify.js +21 -0
  2. package/dist/approval/policy.js +6 -0
  3. package/dist/approval/prompt.js +21 -17
  4. package/dist/approval/types.d.ts +5 -0
  5. package/dist/cli/commands/checkpoint.js +6 -4
  6. package/dist/cli/commands/config.js +10 -7
  7. package/dist/cli/commands/index.js +16 -15
  8. package/dist/cli/commands/init.js +5 -3
  9. package/dist/cli/commands/login.js +5 -3
  10. package/dist/cli/commands/pr.js +8 -7
  11. package/dist/cli/commands/rollback.js +7 -6
  12. package/dist/cli/commands/run.js +7 -6
  13. package/dist/cli/commands/skills.js +12 -10
  14. package/dist/cli/commands/test.d.ts +9 -0
  15. package/dist/cli/commands/test.js +47 -0
  16. package/dist/cli/program.js +9 -6
  17. package/dist/cli/repl.js +11 -9
  18. package/dist/cli/session-factory.js +6 -2
  19. package/dist/components/frame.js +3 -1
  20. package/dist/components/fuzzy.d.ts +4 -4
  21. package/dist/components/fuzzy.js +14 -13
  22. package/dist/components/select.js +8 -7
  23. package/dist/config/schema.d.ts +47 -0
  24. package/dist/config/schema.js +20 -0
  25. package/dist/errors/constructors.d.ts +5 -0
  26. package/dist/errors/constructors.js +16 -0
  27. package/dist/errors/format.js +8 -8
  28. package/dist/errors/types.d.ts +3 -0
  29. package/dist/errors/types.js +8 -0
  30. package/dist/onboarding/flow.js +6 -6
  31. package/dist/onboarding/steps.js +11 -11
  32. package/dist/plan/approve.js +6 -6
  33. package/dist/plan/render.js +26 -18
  34. package/dist/render/capabilities.js +4 -0
  35. package/dist/render/diff.d.ts +6 -7
  36. package/dist/render/diff.js +33 -22
  37. package/dist/render/highlight.d.ts +3 -3
  38. package/dist/render/highlight.js +15 -15
  39. package/dist/render/index.d.ts +1 -1
  40. package/dist/render/plain-renderer.d.ts +2 -1
  41. package/dist/render/plain-renderer.js +7 -6
  42. package/dist/render/state.d.ts +7 -2
  43. package/dist/render/state.js +16 -10
  44. package/dist/render/tty-renderer.d.ts +2 -1
  45. package/dist/render/tty-renderer.js +20 -17
  46. package/dist/render/types.d.ts +7 -0
  47. package/dist/subagent/orchestrator.js +21 -6
  48. package/dist/testing/detect.d.ts +3 -0
  49. package/dist/testing/detect.js +44 -0
  50. package/dist/testing/index.d.ts +5 -0
  51. package/dist/testing/index.js +5 -0
  52. package/dist/testing/parse.d.ts +33 -0
  53. package/dist/testing/parse.js +137 -0
  54. package/dist/testing/run-tests-tool.d.ts +42 -0
  55. package/dist/testing/run-tests-tool.js +128 -0
  56. package/dist/testing/runner.d.ts +26 -0
  57. package/dist/testing/runner.js +124 -0
  58. package/dist/testing/types.d.ts +61 -0
  59. package/dist/testing/types.js +7 -0
  60. package/dist/theme/index.d.ts +2 -0
  61. package/dist/theme/index.js +2 -0
  62. package/dist/theme/resolve.d.ts +32 -0
  63. package/dist/theme/resolve.js +73 -0
  64. package/dist/theme/tokens.d.ts +104 -0
  65. package/dist/theme/tokens.js +52 -0
  66. package/dist/tools/registry.js +3 -0
  67. package/dist/tools/types.d.ts +2 -2
  68. package/dist/utils/logger.d.ts +2 -0
  69. package/dist/utils/logger.js +7 -4
  70. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  import { Command } from "commander";
2
- import pc from "picocolors";
3
2
  import { APP_NAME, APP_VERSION, APP_DESCRIPTION } from "../constants.js";
4
3
  import { logger } from "../utils/logger.js";
5
- import { usageError } from "../errors/index.js";
4
+ import { shouldUseColor, usageError } from "../errors/index.js";
5
+ import { themeForColor } from "../theme/index.js";
6
6
  import { runCommand } from "./commands/run.js";
7
7
  import { configCommand } from "./commands/config.js";
8
8
  import { indexCommand } from "./commands/index.js";
@@ -12,6 +12,7 @@ import { loginCommand } from "./commands/login.js";
12
12
  import { initCommand } from "./commands/init.js";
13
13
  import { checkpointCommand } from "./commands/checkpoint.js";
14
14
  import { rollbackCommand } from "./commands/rollback.js";
15
+ import { testCommand } from "./commands/test.js";
15
16
  import { loadConfig } from "../config/index.js";
16
17
  import { maybeRunOnboarding } from "./onboard.js";
17
18
  export function buildProgram() {
@@ -40,6 +41,7 @@ export function buildProgram() {
40
41
  program.addCommand(initCommand());
41
42
  program.addCommand(checkpointCommand());
42
43
  program.addCommand(rollbackCommand());
44
+ program.addCommand(testCommand());
43
45
  // Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
44
46
  // means an unknown command (Commander runs the default action with it as an
45
47
  // operand rather than erroring), so reject it as a usage error.
@@ -61,11 +63,12 @@ export function buildProgram() {
61
63
  await onboarding;
62
64
  return;
63
65
  }
64
- logger.print(pc.cyan(`${APP_NAME} v${APP_VERSION}`));
65
- logger.print(pc.dim("an agentic coding CLI\n"));
66
+ const t = themeForColor(shouldUseColor(process.stdout));
67
+ logger.print(t.accent(`${APP_NAME} v${APP_VERSION}`));
68
+ logger.print(t.muted("an agentic coding CLI\n"));
66
69
  logger.print("The interactive REPL lands in C.3 (terminal UI).");
67
- logger.print(`For now try: ${pc.bold('cruxy run "<task>"')} or ${pc.bold("cruxy config path")}`);
68
- logger.print(`See all commands: ${pc.bold("cruxy --help")}`);
70
+ logger.print(`For now try: ${t.strong('cruxy run "<task>"')} or ${t.strong("cruxy config path")}`);
71
+ logger.print(`See all commands: ${t.strong("cruxy --help")}`);
69
72
  });
70
73
  // Throw CommanderError instead of calling process.exit, and suppress
71
74
  // Commander's own "error:" line — so parse errors (unknown command/option,
package/dist/cli/repl.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import readline from "node:readline";
2
- import pc from "picocolors";
3
2
  import { makeReplCompleter } from "../components/index.js";
3
+ import { themeForColor } from "../theme/index.js";
4
4
  import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
5
5
  import { createRenderer } from "../render/index.js";
6
6
  import { logger } from "../utils/logger.js";
7
- const PROMPT = `${pc.cyan("cruxy")} ${pc.dim("›")} `;
7
+ /** The REPL prompts on stdout; its chrome resolves against stdout's color. */
8
+ const theme = themeForColor(shouldUseColor(process.stdout));
9
+ const PROMPT = `${theme.accent("cruxy")} ${theme.muted(theme.glyph.caret)} `;
8
10
  /**
9
11
  * The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
10
12
  * sync with the dispatch below and the HELP text.
@@ -92,7 +94,7 @@ function printReplError(err) {
92
94
  * passes its own so the approval prompt's status-suspend hook shares it.
93
95
  */
94
96
  export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr), checkpoints) {
95
- logger.print(pc.dim("interactive session — /help for commands, /exit or Ctrl+D to quit"));
97
+ logger.print(theme.muted("interactive session — /help for commands, /exit or Ctrl+D to quit"));
96
98
  try {
97
99
  await replLoop(session, io, renderer, checkpoints);
98
100
  }
@@ -105,25 +107,25 @@ async function replLoop(session, io, renderer, checkpoints) {
105
107
  const line = await readLine(io, PROMPT);
106
108
  // EOF / Ctrl+D.
107
109
  if (line === null) {
108
- logger.print(pc.dim("\nbye"));
110
+ logger.print(theme.muted("\nbye"));
109
111
  return;
110
112
  }
111
113
  const trimmed = line.trim();
112
114
  if (trimmed === "")
113
115
  continue; // empty line → reprompt, no model call
114
116
  if (trimmed === "/exit" || trimmed === "/quit") {
115
- logger.print(pc.dim("bye"));
117
+ logger.print(theme.muted("bye"));
116
118
  return;
117
119
  }
118
120
  if (trimmed === "/clear") {
119
121
  session.clear();
120
- logger.print(pc.dim("history cleared"));
122
+ logger.print(theme.muted("history cleared"));
121
123
  continue;
122
124
  }
123
125
  if (trimmed === "/compact") {
124
126
  try {
125
127
  const n = await session.compact();
126
- logger.print(pc.dim(n ? `compacted ${n} older messages` : "nothing to compact yet"));
128
+ logger.print(theme.muted(n ? `compacted ${n} older messages` : "nothing to compact yet"));
127
129
  }
128
130
  catch (err) {
129
131
  printReplError(err);
@@ -132,7 +134,7 @@ async function replLoop(session, io, renderer, checkpoints) {
132
134
  }
133
135
  if (trimmed === "/reload") {
134
136
  const loaded = session.reloadProjectInstructions();
135
- logger.print(pc.dim(loaded
137
+ logger.print(theme.muted(loaded
136
138
  ? "reloaded project instructions (CRUXY.md)"
137
139
  : "no project instructions found"));
138
140
  continue;
@@ -140,7 +142,7 @@ async function replLoop(session, io, renderer, checkpoints) {
140
142
  if (trimmed === "/plan") {
141
143
  session.setPlanMode(!session.getPlanMode());
142
144
  const on = session.getPlanMode();
143
- logger.print(pc.dim(on
145
+ logger.print(theme.muted(on
144
146
  ? "plan mode on — the next prompt proposes a plan for approval"
145
147
  : "plan mode off"));
146
148
  continue;
@@ -67,10 +67,14 @@ export function withCheckpointGate(requestApproval, checkpoints, cwd) {
67
67
  if (request.tier === "read")
68
68
  return decision;
69
69
  await checkpoints.ensureCheckpoint();
70
- if (action.kind === "shell")
70
+ // Shell AND test executions (C.13) can mutate files we can't attribute
71
+ // (scripts, snapshot writers) — record the lost attribution the same way.
72
+ if (action.kind === "shell" || action.kind === "test") {
71
73
  await checkpoints.recordShellMutation();
72
- else
74
+ }
75
+ else {
73
76
  await checkpoints.recordTouched([...request.targets]);
77
+ }
74
78
  return decision;
75
79
  };
76
80
  }
@@ -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();
@@ -219,6 +219,27 @@ export declare const CheckpointConfigSchema: z.ZodObject<{
219
219
  enabled?: boolean | undefined;
220
220
  retention?: number | undefined;
221
221
  }>;
222
+ /**
223
+ * Test-execution loop (C.13): how the agent runs the project's test suite and
224
+ * iterates on failures. The command is detected from package.json when unset;
225
+ * cruxy never invents one.
226
+ */
227
+ export declare const TestConfigSchema: z.ZodObject<{
228
+ /** Explicit test command (overrides package.json detection). */
229
+ command: z.ZodOptional<z.ZodString>;
230
+ /** Consecutive failing runs before the edit→re-run loop trips its cap. */
231
+ maxIterations: z.ZodDefault<z.ZodNumber>;
232
+ /** Cap on captured test output bytes (tail-biased — failures come last). */
233
+ captureBytes: z.ZodDefault<z.ZodNumber>;
234
+ }, "strict", z.ZodTypeAny, {
235
+ maxIterations: number;
236
+ captureBytes: number;
237
+ command?: string | undefined;
238
+ }, {
239
+ maxIterations?: number | undefined;
240
+ command?: string | undefined;
241
+ captureBytes?: number | undefined;
242
+ }>;
222
243
  /**
223
244
  * Subagent orchestration (C.14): scoped child agents the main agent can spawn
224
245
  * for bounded subtasks. Every cap here is a hard bound — a subagent can narrow
@@ -514,6 +535,22 @@ export declare const CruxyConfigSchema: z.ZodObject<{
514
535
  timeoutMs?: number | undefined;
515
536
  } | undefined;
516
537
  }>>;
538
+ test: z.ZodDefault<z.ZodObject<{
539
+ /** Explicit test command (overrides package.json detection). */
540
+ command: z.ZodOptional<z.ZodString>;
541
+ /** Consecutive failing runs before the edit→re-run loop trips its cap. */
542
+ maxIterations: z.ZodDefault<z.ZodNumber>;
543
+ /** Cap on captured test output bytes (tail-biased — failures come last). */
544
+ captureBytes: z.ZodDefault<z.ZodNumber>;
545
+ }, "strict", z.ZodTypeAny, {
546
+ maxIterations: number;
547
+ captureBytes: number;
548
+ command?: string | undefined;
549
+ }, {
550
+ maxIterations?: number | undefined;
551
+ command?: string | undefined;
552
+ captureBytes?: number | undefined;
553
+ }>>;
517
554
  mcpServers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
518
555
  command: z.ZodOptional<z.ZodString>;
519
556
  args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
@@ -592,6 +629,11 @@ export declare const CruxyConfigSchema: z.ZodObject<{
592
629
  timeoutMs?: number | undefined;
593
630
  };
594
631
  };
632
+ test: {
633
+ maxIterations: number;
634
+ captureBytes: number;
635
+ command?: string | undefined;
636
+ };
595
637
  mcpServers: Record<string, {
596
638
  command?: string | undefined;
597
639
  args?: string[] | undefined;
@@ -662,6 +704,11 @@ export declare const CruxyConfigSchema: z.ZodObject<{
662
704
  timeoutMs?: number | undefined;
663
705
  } | undefined;
664
706
  } | undefined;
707
+ test?: {
708
+ maxIterations?: number | undefined;
709
+ command?: string | undefined;
710
+ captureBytes?: number | undefined;
711
+ } | undefined;
665
712
  mcpServers?: Record<string, {
666
713
  command?: string | undefined;
667
714
  args?: string[] | undefined;
@@ -149,6 +149,25 @@ export const CheckpointConfigSchema = z
149
149
  retention: z.number().int().positive().default(10),
150
150
  })
151
151
  .strict();
152
+ /**
153
+ * Test-execution loop (C.13): how the agent runs the project's test suite and
154
+ * iterates on failures. The command is detected from package.json when unset;
155
+ * cruxy never invents one.
156
+ */
157
+ export const TestConfigSchema = z
158
+ .object({
159
+ /** Explicit test command (overrides package.json detection). */
160
+ command: z.string().min(1).optional(),
161
+ /** Consecutive failing runs before the edit→re-run loop trips its cap. */
162
+ maxIterations: z.number().int().positive().default(4),
163
+ /** Cap on captured test output bytes (tail-biased — failures come last). */
164
+ captureBytes: z
165
+ .number()
166
+ .int()
167
+ .positive()
168
+ .default(64 * 1024),
169
+ })
170
+ .strict();
152
171
  /**
153
172
  * Subagent orchestration (C.14): scoped child agents the main agent can spawn
154
173
  * for bounded subtasks. Every cap here is a hard bound — a subagent can narrow
@@ -196,6 +215,7 @@ export const CruxyConfigSchema = z
196
215
  index: IndexConfigSchema.default({}),
197
216
  checkpoint: CheckpointConfigSchema.default({}),
198
217
  subagent: SubagentConfigSchema.default({}),
218
+ test: TestConfigSchema.default({}),
199
219
  mcpServers: z.record(z.string(), McpServerSchema).default({}),
200
220
  logLevel: z.enum(LOG_LEVELS).default("info"),
201
221
  })
@@ -85,6 +85,11 @@ export declare function subagentDepthExceeded(depth: number, maxDepth: number):
85
85
  * reasons over; thrown only when the orchestrator itself cannot proceed.
86
86
  */
87
87
  export declare function subagentFailed(underlying?: unknown): CruxyError;
88
+ /**
89
+ * No test command could be detected and none is configured (C.13). cruxy never
90
+ * invents a test command — the fix is always to declare one.
91
+ */
92
+ export declare function testCommandNotFound(): CruxyError;
88
93
  export declare function internal(underlying?: unknown): CruxyError;
89
94
  /**
90
95
  * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
@@ -436,6 +436,22 @@ export function subagentFailed(underlying) {
436
436
  underlying,
437
437
  });
438
438
  }
439
+ // ── testing (exit 2) ──────────────────────────────────────────────────────────
440
+ /**
441
+ * No test command could be detected and none is configured (C.13). cruxy never
442
+ * invents a test command — the fix is always to declare one.
443
+ */
444
+ export function testCommandNotFound() {
445
+ return new CruxyError({
446
+ code: ErrorCode.TestCommandNotFound,
447
+ title: "no test command found for this project",
448
+ cause: "package.json has no usable `scripts.test` and `test.command` is not configured",
449
+ nextSteps: [
450
+ 'set `test.command` in your cruxy config (e.g. `cruxy config set test.command "pnpm test"`)',
451
+ "or add a `test` script to package.json",
452
+ ],
453
+ });
454
+ }
439
455
  // ── internal (exit 1) ─────────────────────────────────────────────────────────
440
456
  export function internal(underlying) {
441
457
  return new CruxyError({
@@ -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");
@@ -49,6 +49,9 @@ export declare const ErrorCode: {
49
49
  /** Carried inside a SubagentResult (informational) — never fatal by itself. */
50
50
  readonly SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET";
51
51
  readonly SubagentFailed: "CRUXY_E_SUBAGENT_FAILED";
52
+ readonly TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND";
53
+ /** Carried inside a run_tests result (informational) — never fatal by itself. */
54
+ readonly TestIterationLimit: "CRUXY_E_TEST_ITERATION_LIMIT";
52
55
  };
53
56
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
54
57
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -60,6 +60,10 @@ export const ErrorCode = {
60
60
  /** Carried inside a SubagentResult (informational) — never fatal by itself. */
61
61
  SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET",
62
62
  SubagentFailed: "CRUXY_E_SUBAGENT_FAILED",
63
+ // testing (exit 2 / 11)
64
+ TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND",
65
+ /** Carried inside a run_tests result (informational) — never fatal by itself. */
66
+ TestIterationLimit: "CRUXY_E_TEST_ITERATION_LIMIT",
63
67
  };
64
68
  /**
65
69
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -104,6 +108,10 @@ const EXIT_CODES = {
104
108
  [ErrorCode.SubagentDepthExceeded]: 2,
105
109
  [ErrorCode.SubagentBudget]: 11,
106
110
  [ErrorCode.SubagentFailed]: 11,
111
+ // No test command is a configuration gap (usage); the iteration limit
112
+ // surfaces inside a run_tests result and is never fatal by itself.
113
+ [ErrorCode.TestCommandNotFound]: 2,
114
+ [ErrorCode.TestIterationLimit]: 11,
107
115
  };
108
116
  /** The process exit code for an error code (defaults to 1 for safety). */
109
117
  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
  /**
@@ -1,6 +1,6 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import pc from "picocolors";
3
+ import { themeForColor } from "../theme/index.js";
4
4
  import { CREATE_KEY_URL } from "../constants.js";
5
5
  import { loadProjectInstructions } from "../config/index.js";
6
6
  /**
@@ -9,7 +9,7 @@ import { loadProjectInstructions } from "../config/index.js";
9
9
  * or logged. Rendering is gated on `io.color` so output respects NO_COLOR / pipes.
10
10
  */
11
11
  const MAX_KEY_ATTEMPTS = 3;
12
- const c = (io) => pc.createColors(io.color);
12
+ const c = (io) => themeForColor(io.color);
13
13
  /**
14
14
  * Acquire and persist a provider key: print the create-key URL, read it masked,
15
15
  * validate it live, and **only then** write it to the credentials store. Loops on
@@ -17,23 +17,23 @@ const c = (io) => pc.createColors(io.color);
17
17
  */
18
18
  export async function acquireKeyStep(io, deps, provider) {
19
19
  const col = c(io);
20
- io.write(`\nYou'll need a Cruxy API key. Create one at ${col.cyan(CREATE_KEY_URL)}\n`);
20
+ io.write(`\nYou'll need a Cruxy API key. Create one at ${col.accent(CREATE_KEY_URL)}\n`);
21
21
  for (let attempt = 1; attempt <= MAX_KEY_ATTEMPTS; attempt++) {
22
- io.write(col.bold("Paste your API key: "));
22
+ io.write(col.strong("Paste your API key: "));
23
23
  const key = (await io.readSecret()).trim();
24
24
  if (key === "") {
25
25
  // Empty / Ctrl-C / EOF — treat as an abort of the mandatory step.
26
26
  return { status: "aborted" };
27
27
  }
28
- io.write(col.dim("validating…\n"));
28
+ io.write(col.muted("validating…\n"));
29
29
  const outcome = await deps.validateKey(provider, key);
30
30
  if (outcome === "valid") {
31
31
  deps.writeCredential(provider, key);
32
- io.write(`${col.green("✓")} key validated and saved to ~/.cruxy\n`);
32
+ io.write(`${col.success(col.glyph.success)} key validated and saved to ~/.cruxy\n`);
33
33
  return { status: "ok", apiKey: key };
34
34
  }
35
35
  if (outcome === "unreachable") {
36
- io.write(`${col.red("✗")} couldn't reach the gateway to validate the key.\n`);
36
+ io.write(`${col.danger(col.glyph.failure)} couldn't reach the gateway to validate the key.\n`);
37
37
  return {
38
38
  status: "failed",
39
39
  message: "network unreachable — try again with `cruxy login`",
@@ -41,7 +41,7 @@ export async function acquireKeyStep(io, deps, provider) {
41
41
  }
42
42
  // invalid
43
43
  const left = MAX_KEY_ATTEMPTS - attempt;
44
- io.write(`${col.red("✗")} that key was rejected${left > 0 ? ` (${left} ${left === 1 ? "try" : "tries"} left)` : ""}.\n`);
44
+ io.write(`${col.danger(col.glyph.failure)} that key was rejected${left > 0 ? ` (${left} ${left === 1 ? "try" : "tries"} left)` : ""}.\n`);
45
45
  }
46
46
  return { status: "failed", message: "key rejected after 3 attempts" };
47
47
  }
@@ -71,14 +71,14 @@ export async function scaffoldStep(io, cwd) {
71
71
  if (loadProjectInstructions(cwd) !== null) {
72
72
  return { status: "skipped" };
73
73
  }
74
- io.write(`\nScaffold a ${col.bold("CRUXY.md")} to guide cruxy in this project? ${col.dim("[y/N]")} `);
74
+ io.write(`\nScaffold a ${col.strong("CRUXY.md")} to guide cruxy in this project? ${col.muted("[y/N]")} `);
75
75
  const key = (await io.readKey()).toLowerCase();
76
76
  io.write("\n");
77
77
  if (key !== "y")
78
78
  return { status: "skipped" };
79
79
  const file = join(cwd, "CRUXY.md");
80
80
  writeFileSync(file, CRUXY_MD_TEMPLATE, "utf8");
81
- io.write(`${col.green("✓")} wrote ${col.bold("CRUXY.md")}\n`);
81
+ io.write(`${col.success(col.glyph.success)} wrote ${col.strong("CRUXY.md")}\n`);
82
82
  return { status: "ok" };
83
83
  }
84
84
  const FIRST_WIN_PROMPT = "Give me a concise 3-sentence summary of what this repository does, based on its README and structure.";
@@ -90,7 +90,7 @@ export async function firstWinStep(io, deps) {
90
90
  const col = c(io);
91
91
  if (!deps.runTask)
92
92
  return { status: "skipped" };
93
- io.write(`\nRun a quick demo now — let cruxy summarize this repo? ${col.dim("[Y/n]")} `);
93
+ io.write(`\nRun a quick demo now — let cruxy summarize this repo? ${col.muted("[Y/n]")} `);
94
94
  const key = (await io.readKey()).toLowerCase();
95
95
  io.write("\n");
96
96
  if (key === "n")