@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.
- package/dist/approval/classify.js +21 -0
- package/dist/approval/policy.js +6 -0
- package/dist/approval/prompt.js +21 -17
- package/dist/approval/types.d.ts +5 -0
- package/dist/cli/commands/checkpoint.js +6 -4
- package/dist/cli/commands/config.js +10 -7
- package/dist/cli/commands/index.js +16 -15
- package/dist/cli/commands/init.js +5 -3
- package/dist/cli/commands/login.js +5 -3
- package/dist/cli/commands/pr.js +8 -7
- package/dist/cli/commands/rollback.js +7 -6
- package/dist/cli/commands/run.js +7 -6
- package/dist/cli/commands/skills.js +12 -10
- package/dist/cli/commands/test.d.ts +9 -0
- package/dist/cli/commands/test.js +47 -0
- package/dist/cli/program.js +9 -6
- package/dist/cli/repl.js +11 -9
- package/dist/cli/session-factory.js +6 -2
- package/dist/components/frame.js +3 -1
- package/dist/components/fuzzy.d.ts +4 -4
- package/dist/components/fuzzy.js +14 -13
- package/dist/components/select.js +8 -7
- package/dist/config/schema.d.ts +47 -0
- package/dist/config/schema.js +20 -0
- package/dist/errors/constructors.d.ts +5 -0
- package/dist/errors/constructors.js +16 -0
- package/dist/errors/format.js +8 -8
- package/dist/errors/types.d.ts +3 -0
- package/dist/errors/types.js +8 -0
- package/dist/onboarding/flow.js +6 -6
- package/dist/onboarding/steps.js +11 -11
- package/dist/plan/approve.js +6 -6
- package/dist/plan/render.js +26 -18
- package/dist/render/capabilities.js +4 -0
- package/dist/render/diff.d.ts +6 -7
- package/dist/render/diff.js +33 -22
- package/dist/render/highlight.d.ts +3 -3
- package/dist/render/highlight.js +15 -15
- package/dist/render/index.d.ts +1 -1
- package/dist/render/plain-renderer.d.ts +2 -1
- package/dist/render/plain-renderer.js +7 -6
- package/dist/render/state.d.ts +7 -2
- package/dist/render/state.js +16 -10
- package/dist/render/tty-renderer.d.ts +2 -1
- package/dist/render/tty-renderer.js +20 -17
- package/dist/render/types.d.ts +7 -0
- package/dist/subagent/orchestrator.js +21 -6
- package/dist/testing/detect.d.ts +3 -0
- package/dist/testing/detect.js +44 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/parse.d.ts +33 -0
- package/dist/testing/parse.js +137 -0
- package/dist/testing/run-tests-tool.d.ts +42 -0
- package/dist/testing/run-tests-tool.js +128 -0
- package/dist/testing/runner.d.ts +26 -0
- package/dist/testing/runner.js +124 -0
- package/dist/testing/types.d.ts +61 -0
- package/dist/testing/types.js +7 -0
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/resolve.d.ts +32 -0
- package/dist/theme/resolve.js +73 -0
- package/dist/theme/tokens.d.ts +104 -0
- package/dist/theme/tokens.js +52 -0
- package/dist/tools/registry.js +3 -0
- package/dist/tools/types.d.ts +2 -2
- package/dist/utils/logger.d.ts +2 -0
- package/dist/utils/logger.js +7 -4
- package/package.json +1 -1
package/dist/cli/program.js
CHANGED
|
@@ -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
|
-
|
|
65
|
-
logger.print(
|
|
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: ${
|
|
68
|
-
logger.print(`See all commands: ${
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
117
|
+
logger.print(theme.muted("bye"));
|
|
116
118
|
return;
|
|
117
119
|
}
|
|
118
120
|
if (trimmed === "/clear") {
|
|
119
121
|
session.clear();
|
|
120
|
-
logger.print(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
73
76
|
await checkpoints.recordTouched([...request.targets]);
|
|
77
|
+
}
|
|
74
78
|
return decision;
|
|
75
79
|
};
|
|
76
80
|
}
|
package/dist/components/frame.js
CHANGED
|
@@ -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
|
|
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
|
|
40
|
-
*
|
|
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[],
|
|
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;
|
package/dist/components/fuzzy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
|
73
|
-
*
|
|
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,
|
|
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) ?
|
|
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
|
|
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(
|
|
106
|
-
lines.push(`${
|
|
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(
|
|
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 ?
|
|
117
|
-
const label = highlightMatch(row.label, row.match.positions,
|
|
118
|
-
lines.push(`${marker} ${selected ? 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(
|
|
123
|
+
lines.push(t.muted(` ${g.ellipsis} ${hidden} more`));
|
|
123
124
|
}
|
|
124
125
|
frame.render(lines);
|
|
125
126
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
|
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(
|
|
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 ?
|
|
31
|
+
const marker = selected ? t.accent(g.pointer) : " ";
|
|
31
32
|
const label = toLabel(item);
|
|
32
|
-
lines.push(`${marker} ${selected ? 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(
|
|
37
|
-
lines.push(
|
|
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();
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/schema.js
CHANGED
|
@@ -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({
|
package/dist/errors/format.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
|
16
|
+
const t = themeForColor(opts.color);
|
|
17
17
|
const lines = [];
|
|
18
18
|
// 1. Title — one plain line, what failed.
|
|
19
|
-
lines.push(
|
|
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(`${
|
|
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(
|
|
26
|
+
lines.push(t.heading("Next steps:"));
|
|
27
27
|
for (const step of err.nextSteps)
|
|
28
|
-
lines.push(` ${
|
|
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(
|
|
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(
|
|
36
|
+
lines.push(t.muted("Underlying error:"));
|
|
37
37
|
lines.push(indent(stackOf(err.underlying)));
|
|
38
38
|
}
|
|
39
39
|
return lines.join("\n");
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -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). */
|
package/dist/errors/types.js
CHANGED
|
@@ -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) {
|
package/dist/onboarding/flow.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AuthError, NetworkError, createProvider } from "@cruxy/sdk";
|
|
2
|
-
import
|
|
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
|
|
15
|
-
io.write(`${
|
|
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(`${
|
|
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(`${
|
|
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(`${
|
|
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
|
/**
|
package/dist/onboarding/steps.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import
|
|
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) =>
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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")
|