@cruxy/cli 0.9.0 → 0.11.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 +8 -29
- package/dist/approval/types.d.ts +5 -0
- package/dist/cli/commands/rollback.js +45 -0
- package/dist/cli/commands/test.d.ts +9 -0
- package/dist/cli/commands/test.js +47 -0
- package/dist/cli/program.js +2 -0
- package/dist/cli/repl.d.ts +5 -0
- package/dist/cli/repl.js +17 -0
- package/dist/cli/session-factory.js +6 -2
- package/dist/components/autocomplete.d.ts +32 -0
- package/dist/components/autocomplete.js +50 -0
- package/dist/components/frame.d.ts +25 -0
- package/dist/components/frame.js +49 -0
- package/dist/components/fuzzy.d.ts +61 -0
- package/dist/components/fuzzy.js +174 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +6 -0
- package/dist/components/input.d.ts +78 -0
- package/dist/components/input.js +111 -0
- package/dist/components/keys.d.ts +48 -0
- package/dist/components/keys.js +105 -0
- package/dist/components/select.d.ts +28 -0
- package/dist/components/select.js +69 -0
- package/dist/config/schema.d.ts +47 -0
- package/dist/config/schema.js +20 -0
- package/dist/errors/constructors.d.ts +12 -0
- package/dist/errors/constructors.js +31 -0
- package/dist/errors/types.d.ts +4 -0
- package/dist/errors/types.js +10 -0
- package/dist/onboarding/io.d.ts +3 -2
- package/dist/onboarding/io.js +35 -81
- 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/tools/registry.js +3 -0
- package/dist/tools/types.d.ts +2 -2
- package/package.json +1 -1
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
|
})
|
|
@@ -2,6 +2,13 @@ import { CruxyError } from "./types.js";
|
|
|
2
2
|
/** Best-effort human message for an arbitrary thrown value. */
|
|
3
3
|
export declare function messageOf(underlying: unknown): string | undefined;
|
|
4
4
|
export declare function usageError(title: string, nextSteps?: string[]): CruxyError;
|
|
5
|
+
/**
|
|
6
|
+
* An interactive component (picker, fuzzy finder) was needed but stdin is not
|
|
7
|
+
* an interactive terminal and the caller supplied no default (U.7). Never
|
|
8
|
+
* silently picks an option, never blocks on a pipe — same discipline as the
|
|
9
|
+
* approval/onboarding layers.
|
|
10
|
+
*/
|
|
11
|
+
export declare function interactiveRequired(what: string, alternatives?: string[]): CruxyError;
|
|
5
12
|
export declare function configKeyUnknown(key: string): CruxyError;
|
|
6
13
|
export declare function providerUnsupported(provider: string): CruxyError;
|
|
7
14
|
export declare function configParse(path: string, underlying?: unknown): CruxyError;
|
|
@@ -78,6 +85,11 @@ export declare function subagentDepthExceeded(depth: number, maxDepth: number):
|
|
|
78
85
|
* reasons over; thrown only when the orchestrator itself cannot proceed.
|
|
79
86
|
*/
|
|
80
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;
|
|
81
93
|
export declare function internal(underlying?: unknown): CruxyError;
|
|
82
94
|
/**
|
|
83
95
|
* Map a known provider/transport error (from `@cruxy/sdk`) to a typed
|
|
@@ -23,6 +23,21 @@ export function usageError(title, nextSteps) {
|
|
|
23
23
|
nextSteps: nextSteps ?? ["run `cruxy --help` for usage"],
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* An interactive component (picker, fuzzy finder) was needed but stdin is not
|
|
28
|
+
* an interactive terminal and the caller supplied no default (U.7). Never
|
|
29
|
+
* silently picks an option, never blocks on a pipe — same discipline as the
|
|
30
|
+
* approval/onboarding layers.
|
|
31
|
+
*/
|
|
32
|
+
export function interactiveRequired(what, alternatives = []) {
|
|
33
|
+
return new CruxyError({
|
|
34
|
+
code: ErrorCode.InteractiveRequired,
|
|
35
|
+
title: `${what} needs an interactive terminal`,
|
|
36
|
+
cause: "stdin is not a TTY (or the terminal cannot render an interactive picker)",
|
|
37
|
+
nextSteps: ["run cruxy in an interactive terminal", ...alternatives],
|
|
38
|
+
meta: { what },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
26
41
|
export function configKeyUnknown(key) {
|
|
27
42
|
return new CruxyError({
|
|
28
43
|
code: ErrorCode.ConfigKeyUnknown,
|
|
@@ -421,6 +436,22 @@ export function subagentFailed(underlying) {
|
|
|
421
436
|
underlying,
|
|
422
437
|
});
|
|
423
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
|
+
}
|
|
424
455
|
// ── internal (exit 1) ─────────────────────────────────────────────────────────
|
|
425
456
|
export function internal(underlying) {
|
|
426
457
|
return new CruxyError({
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
export declare const ErrorCode: {
|
|
15
15
|
readonly Internal: "CRUXY_E_INTERNAL";
|
|
16
16
|
readonly Usage: "CRUXY_E_USAGE";
|
|
17
|
+
readonly InteractiveRequired: "CRUXY_E_INTERACTIVE_REQUIRED";
|
|
17
18
|
readonly ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN";
|
|
18
19
|
readonly ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED";
|
|
19
20
|
readonly GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH";
|
|
@@ -48,6 +49,9 @@ export declare const ErrorCode: {
|
|
|
48
49
|
/** Carried inside a SubagentResult (informational) — never fatal by itself. */
|
|
49
50
|
readonly SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET";
|
|
50
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";
|
|
51
55
|
};
|
|
52
56
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
53
57
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -16,6 +16,7 @@ export const ErrorCode = {
|
|
|
16
16
|
Internal: "CRUXY_E_INTERNAL",
|
|
17
17
|
// usage (exit 2)
|
|
18
18
|
Usage: "CRUXY_E_USAGE",
|
|
19
|
+
InteractiveRequired: "CRUXY_E_INTERACTIVE_REQUIRED",
|
|
19
20
|
ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN",
|
|
20
21
|
ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED",
|
|
21
22
|
GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH",
|
|
@@ -59,6 +60,10 @@ export const ErrorCode = {
|
|
|
59
60
|
/** Carried inside a SubagentResult (informational) — never fatal by itself. */
|
|
60
61
|
SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET",
|
|
61
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",
|
|
62
67
|
};
|
|
63
68
|
/**
|
|
64
69
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -67,6 +72,7 @@ export const ErrorCode = {
|
|
|
67
72
|
const EXIT_CODES = {
|
|
68
73
|
[ErrorCode.Internal]: 1,
|
|
69
74
|
[ErrorCode.Usage]: 2,
|
|
75
|
+
[ErrorCode.InteractiveRequired]: 2,
|
|
70
76
|
[ErrorCode.ConfigKeyUnknown]: 2,
|
|
71
77
|
[ErrorCode.ProviderUnsupported]: 2,
|
|
72
78
|
[ErrorCode.GitProtectedBranch]: 2,
|
|
@@ -102,6 +108,10 @@ const EXIT_CODES = {
|
|
|
102
108
|
[ErrorCode.SubagentDepthExceeded]: 2,
|
|
103
109
|
[ErrorCode.SubagentBudget]: 11,
|
|
104
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,
|
|
105
115
|
};
|
|
106
116
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
107
117
|
export function exitCodeFor(code) {
|
package/dist/onboarding/io.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { OnboardingIO } from "./types.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
|
|
4
4
|
* stays clean for piping); the secret reader echoes `*` per keystroke and never
|
|
5
|
-
* the real character.
|
|
6
|
-
*
|
|
5
|
+
* the real character. Raw-mode key handling is the shared U.7 reader
|
|
6
|
+
* (`components/input.ts`) — the single owner of `setRawMode` — so cooked mode
|
|
7
|
+
* is restored on every exit path, Ctrl-C / EOF included.
|
|
7
8
|
*/
|
|
8
9
|
export declare function defaultOnboardingIO(color?: boolean): OnboardingIO;
|
package/dist/onboarding/io.js
CHANGED
|
@@ -1,54 +1,21 @@
|
|
|
1
|
+
import { createKeyReader, readSingleKey } from "../components/input.js";
|
|
1
2
|
import { shouldUseColor } from "../errors/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
|
|
4
5
|
* stays clean for piping); the secret reader echoes `*` per keystroke and never
|
|
5
|
-
* the real character.
|
|
6
|
-
*
|
|
6
|
+
* the real character. Raw-mode key handling is the shared U.7 reader
|
|
7
|
+
* (`components/input.ts`) — the single owner of `setRawMode` — so cooked mode
|
|
8
|
+
* is restored on every exit path, Ctrl-C / EOF included.
|
|
7
9
|
*/
|
|
8
10
|
export function defaultOnboardingIO(color = shouldUseColor()) {
|
|
9
11
|
return {
|
|
10
12
|
write: (text) => void process.stderr.write(text),
|
|
11
13
|
readLine: readLineFromStdin,
|
|
12
|
-
readKey:
|
|
14
|
+
readKey: () => readSingleKey(),
|
|
13
15
|
readSecret: readSecretFromStdin,
|
|
14
16
|
color,
|
|
15
17
|
};
|
|
16
18
|
}
|
|
17
|
-
const CTRL_C = 0x03;
|
|
18
|
-
const CTRL_D = 0x04;
|
|
19
|
-
const BACKSPACE = 0x08;
|
|
20
|
-
const DELETE = 0x7f;
|
|
21
|
-
const LF = 0x0a;
|
|
22
|
-
const CR = 0x0d;
|
|
23
|
-
/** Read one keypress in raw mode; "" on EOF / Ctrl-C / Ctrl-D. Restores cooked mode. */
|
|
24
|
-
function readKeyFromStdin() {
|
|
25
|
-
const stdin = process.stdin;
|
|
26
|
-
return new Promise((resolve) => {
|
|
27
|
-
const cleanup = () => {
|
|
28
|
-
stdin.removeListener("data", onData);
|
|
29
|
-
stdin.removeListener("end", onEnd);
|
|
30
|
-
if (stdin.isTTY)
|
|
31
|
-
stdin.setRawMode(false);
|
|
32
|
-
stdin.pause();
|
|
33
|
-
};
|
|
34
|
-
const onData = (buf) => {
|
|
35
|
-
cleanup();
|
|
36
|
-
const code = buf[0];
|
|
37
|
-
resolve(code === CTRL_C || code === CTRL_D
|
|
38
|
-
? ""
|
|
39
|
-
: buf.toString("utf8").slice(0, 1));
|
|
40
|
-
};
|
|
41
|
-
const onEnd = () => {
|
|
42
|
-
cleanup();
|
|
43
|
-
resolve("");
|
|
44
|
-
};
|
|
45
|
-
if (stdin.isTTY)
|
|
46
|
-
stdin.setRawMode(true);
|
|
47
|
-
stdin.resume();
|
|
48
|
-
stdin.once("data", onData);
|
|
49
|
-
stdin.once("end", onEnd);
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
19
|
/** Read one line in cooked mode; "" on EOF. */
|
|
53
20
|
function readLineFromStdin() {
|
|
54
21
|
const stdin = process.stdin;
|
|
@@ -80,54 +47,41 @@ function readLineFromStdin() {
|
|
|
80
47
|
}
|
|
81
48
|
/**
|
|
82
49
|
* Read a secret with no echo: each printable keystroke shows a `*`, backspace
|
|
83
|
-
* erases one, Enter submits, Ctrl-C / Ctrl-D / EOF resolve "" (abort). The
|
|
84
|
-
* characters are never written anywhere.
|
|
50
|
+
* erases one, Enter submits, Ctrl-C / Ctrl-D / EOF resolve "" (abort). The
|
|
51
|
+
* real characters are never written anywhere. Built on the shared key reader,
|
|
52
|
+
* which also keeps arrow/escape sequences from leaking into the secret.
|
|
85
53
|
*/
|
|
86
|
-
function readSecretFromStdin() {
|
|
87
|
-
const stdin = process.stdin;
|
|
54
|
+
async function readSecretFromStdin() {
|
|
88
55
|
const out = process.stderr;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
done = true;
|
|
103
|
-
cleanup();
|
|
104
|
-
out.write("\n");
|
|
105
|
-
resolve(value);
|
|
106
|
-
};
|
|
107
|
-
const onData = (chunk) => {
|
|
108
|
-
for (const byte of chunk) {
|
|
109
|
-
if (byte === CR || byte === LF)
|
|
110
|
-
return finish(buf); // Enter → submit
|
|
111
|
-
if (byte === CTRL_C || byte === CTRL_D)
|
|
112
|
-
return finish(""); // abort
|
|
113
|
-
if (byte === DELETE || byte === BACKSPACE) {
|
|
56
|
+
const keys = createKeyReader(process.stdin);
|
|
57
|
+
keys.begin();
|
|
58
|
+
let buf = "";
|
|
59
|
+
try {
|
|
60
|
+
for (;;) {
|
|
61
|
+
const key = await keys.read();
|
|
62
|
+
switch (key.kind) {
|
|
63
|
+
case "enter":
|
|
64
|
+
return buf;
|
|
65
|
+
case "ctrl-c":
|
|
66
|
+
case "eof":
|
|
67
|
+
return "";
|
|
68
|
+
case "backspace":
|
|
114
69
|
if (buf.length > 0) {
|
|
115
70
|
buf = buf.slice(0, -1);
|
|
116
71
|
out.write("\b \b"); // erase one star
|
|
117
72
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
73
|
+
break;
|
|
74
|
+
case "char":
|
|
75
|
+
buf += key.char;
|
|
76
|
+
out.write("*");
|
|
77
|
+
break;
|
|
78
|
+
default:
|
|
79
|
+
break; // arrows / tab / escape: ignored, never echoed
|
|
124
80
|
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
stdin.once("end", onEnd);
|
|
132
|
-
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
keys.restore();
|
|
85
|
+
out.write("\n");
|
|
86
|
+
}
|
|
133
87
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the project's test command (C.13). Order: explicit `test.command`
|
|
5
|
+
* config, then package.json `scripts.test`. Returns `null` when neither
|
|
6
|
+
* yields one — cruxy NEVER invents a test command; the caller surfaces the
|
|
7
|
+
* coded not-configured error instead.
|
|
8
|
+
*/
|
|
9
|
+
/** npm's scaffold placeholder is an error message, not a test suite. */
|
|
10
|
+
const NPM_PLACEHOLDER = /no test specified/i;
|
|
11
|
+
export function detectTestCommand(cwd, config) {
|
|
12
|
+
if (config.test.command) {
|
|
13
|
+
return { command: config.test.command, source: "config" };
|
|
14
|
+
}
|
|
15
|
+
const script = readTestScript(cwd);
|
|
16
|
+
if (script === null)
|
|
17
|
+
return null;
|
|
18
|
+
return { command: `${packageManager(cwd)} test`, source: "package-json" };
|
|
19
|
+
}
|
|
20
|
+
/** The package.json `scripts.test` value, or null if absent/placeholder/unreadable. */
|
|
21
|
+
function readTestScript(cwd) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = readFileSync(path.join(cwd, "package.json"), "utf8");
|
|
24
|
+
const pkg = JSON.parse(raw);
|
|
25
|
+
const script = pkg.scripts?.test;
|
|
26
|
+
if (typeof script !== "string" || script.trim() === "")
|
|
27
|
+
return null;
|
|
28
|
+
if (NPM_PLACEHOLDER.test(script))
|
|
29
|
+
return null;
|
|
30
|
+
return script;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// No package.json / unparseable → not detected (never a crash).
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Pick the package manager by lockfile; npm when nothing identifies one. */
|
|
38
|
+
function packageManager(cwd) {
|
|
39
|
+
if (existsSync(path.join(cwd, "pnpm-lock.yaml")))
|
|
40
|
+
return "pnpm";
|
|
41
|
+
if (existsSync(path.join(cwd, "yarn.lock")))
|
|
42
|
+
return "yarn";
|
|
43
|
+
return "npm";
|
|
44
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FailureParser, TestFailure } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort failure extraction (C.13). Two conservative parsers ship —
|
|
4
|
+
* vitest-style and jest-style — behind the pluggable {@link FailureParser}
|
|
5
|
+
* seam. The contract: extract only what a pattern positively recognizes;
|
|
6
|
+
* when nothing matches, return NOTHING (the caller falls back to the raw
|
|
7
|
+
* tail). Parsers never decide pass/fail and never invent counts — `message`
|
|
8
|
+
* fields are verbatim runner output, not summaries we authored.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Vitest: per-test failure lines (`FAIL src/x.test.ts > suite > name`, also
|
|
12
|
+
* `×`/`✗` markers) and the `Tests 2 failed | 570 passed (572)` summary.
|
|
13
|
+
* Plain file-level `FAIL <file>` lines are deliberately left to the jest
|
|
14
|
+
* parser, which owns the file+bullet association.
|
|
15
|
+
*/
|
|
16
|
+
export declare const parseVitest: FailureParser;
|
|
17
|
+
/**
|
|
18
|
+
* Jest: `FAIL <file>` headers with `● <name>` bullets underneath (the bullet's
|
|
19
|
+
* following indented lines are its message, verbatim), and the
|
|
20
|
+
* `Tests: …, N total` summary. A FAIL header with no bullets (e.g. a suite
|
|
21
|
+
* that failed to load) becomes one file-level failure.
|
|
22
|
+
*/
|
|
23
|
+
export declare const parseJest: FailureParser;
|
|
24
|
+
/** Parser order: most-specific first. The pluggable seam for new frameworks. */
|
|
25
|
+
export declare const defaultParsers: readonly FailureParser[];
|
|
26
|
+
/**
|
|
27
|
+
* Run the parser chain; the first parser that recognizes anything wins.
|
|
28
|
+
* Nothing recognized → empty failures, no total — the raw tail is the result.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseFailures(output: string): {
|
|
31
|
+
failures: TestFailure[];
|
|
32
|
+
total?: number;
|
|
33
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort failure extraction (C.13). Two conservative parsers ship —
|
|
3
|
+
* vitest-style and jest-style — behind the pluggable {@link FailureParser}
|
|
4
|
+
* seam. The contract: extract only what a pattern positively recognizes;
|
|
5
|
+
* when nothing matches, return NOTHING (the caller falls back to the raw
|
|
6
|
+
* tail). Parsers never decide pass/fail and never invent counts — `message`
|
|
7
|
+
* fields are verbatim runner output, not summaries we authored.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Vitest: per-test failure lines (`FAIL src/x.test.ts > suite > name`, also
|
|
11
|
+
* `×`/`✗` markers) and the `Tests 2 failed | 570 passed (572)` summary.
|
|
12
|
+
* Plain file-level `FAIL <file>` lines are deliberately left to the jest
|
|
13
|
+
* parser, which owns the file+bullet association.
|
|
14
|
+
*/
|
|
15
|
+
export const parseVitest = (output) => {
|
|
16
|
+
const failures = [];
|
|
17
|
+
for (const line of output.split("\n")) {
|
|
18
|
+
const match = /^\s*(?:FAIL|✗|×)\s+(\S+)\s+>\s+(.+?)\s*$/.exec(line);
|
|
19
|
+
if (match) {
|
|
20
|
+
failures.push({
|
|
21
|
+
name: match[2],
|
|
22
|
+
message: line.trim(),
|
|
23
|
+
file: match[1],
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
attachLines(failures, output);
|
|
28
|
+
const total = /^\s*Tests\s+.*\((\d+)\)\s*$/m.exec(output);
|
|
29
|
+
return {
|
|
30
|
+
failures,
|
|
31
|
+
...(total ? { total: Number(total[1]) } : {}),
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Jest: `FAIL <file>` headers with `● <name>` bullets underneath (the bullet's
|
|
36
|
+
* following indented lines are its message, verbatim), and the
|
|
37
|
+
* `Tests: …, N total` summary. A FAIL header with no bullets (e.g. a suite
|
|
38
|
+
* that failed to load) becomes one file-level failure.
|
|
39
|
+
*/
|
|
40
|
+
export const parseJest = (output) => {
|
|
41
|
+
const failures = [];
|
|
42
|
+
const lines = output.split("\n");
|
|
43
|
+
let currentFile;
|
|
44
|
+
const filesWithBullets = new Set();
|
|
45
|
+
const bareFiles = [];
|
|
46
|
+
for (let i = 0; i < lines.length; i++) {
|
|
47
|
+
const fail = /^\s*FAIL\s+(\S+)\s*$/.exec(lines[i]);
|
|
48
|
+
if (fail) {
|
|
49
|
+
currentFile = fail[1];
|
|
50
|
+
bareFiles.push(fail[1]);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const bullet = /^\s*●\s+(.+?)\s*$/.exec(lines[i]);
|
|
54
|
+
if (bullet) {
|
|
55
|
+
failures.push({
|
|
56
|
+
name: bullet[1],
|
|
57
|
+
message: bulletMessage(lines, i),
|
|
58
|
+
...(currentFile ? { file: currentFile } : {}),
|
|
59
|
+
});
|
|
60
|
+
if (currentFile)
|
|
61
|
+
filesWithBullets.add(currentFile);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// A failed suite with no per-test bullets is still one honest failure.
|
|
65
|
+
for (const file of bareFiles) {
|
|
66
|
+
if (!filesWithBullets.has(file)) {
|
|
67
|
+
failures.push({ name: file, message: `FAIL ${file}`, file });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
attachLines(failures, output);
|
|
71
|
+
const total = /^Tests:.*?(\d+)\s+total\s*$/m.exec(output);
|
|
72
|
+
return {
|
|
73
|
+
failures,
|
|
74
|
+
...(total ? { total: Number(total[1]) } : {}),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Up to three non-blank lines under a jest bullet — its verbatim message.
|
|
79
|
+
* Jest separates the bullet from its detail with a blank line, so leading
|
|
80
|
+
* blanks are skipped; collection stops at the next blank, the next bullet,
|
|
81
|
+
* or the cap.
|
|
82
|
+
*/
|
|
83
|
+
function bulletMessage(lines, bulletIndex) {
|
|
84
|
+
const body = [];
|
|
85
|
+
for (let j = bulletIndex + 1; j < lines.length && body.length < 3; j++) {
|
|
86
|
+
const text = lines[j].trim();
|
|
87
|
+
if (text === "") {
|
|
88
|
+
if (body.length === 0)
|
|
89
|
+
continue; // the separator blank under the bullet
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (/^\s*●\s+/.test(lines[j]))
|
|
93
|
+
break;
|
|
94
|
+
body.push(text);
|
|
95
|
+
}
|
|
96
|
+
return body.length > 0 ? body.join("\n") : lines[bulletIndex].trim();
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Attach `line` to failures whose file appears in a `file:line:col` stack
|
|
100
|
+
* reference anywhere in the output (vitest `❯ file:39:5`, jest
|
|
101
|
+
* `at … (file:12:15)`). First reference per file wins; no reference → no line.
|
|
102
|
+
*/
|
|
103
|
+
function attachLines(failures, output) {
|
|
104
|
+
if (failures.length === 0)
|
|
105
|
+
return;
|
|
106
|
+
const firstLineFor = new Map();
|
|
107
|
+
for (const match of output.matchAll(/([^\s():]+):(\d+):\d+/g)) {
|
|
108
|
+
if (!firstLineFor.has(match[1])) {
|
|
109
|
+
firstLineFor.set(match[1], Number(match[2]));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const failure of failures) {
|
|
113
|
+
if (failure.file === undefined || failure.line !== undefined)
|
|
114
|
+
continue;
|
|
115
|
+
const line = firstLineFor.get(failure.file);
|
|
116
|
+
if (line !== undefined)
|
|
117
|
+
failure.line = line;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Parser order: most-specific first. The pluggable seam for new frameworks. */
|
|
121
|
+
export const defaultParsers = [
|
|
122
|
+
parseVitest,
|
|
123
|
+
parseJest,
|
|
124
|
+
];
|
|
125
|
+
/**
|
|
126
|
+
* Run the parser chain; the first parser that recognizes anything wins.
|
|
127
|
+
* Nothing recognized → empty failures, no total — the raw tail is the result.
|
|
128
|
+
*/
|
|
129
|
+
export function parseFailures(output) {
|
|
130
|
+
for (const parser of defaultParsers) {
|
|
131
|
+
const result = parser(output);
|
|
132
|
+
if (result.failures.length > 0 || result.total !== undefined) {
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { failures: [] };
|
|
137
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Tool, ToolContext } from "../tools/types.js";
|
|
3
|
+
import type { TestCommand, TestRunner } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `run_tests` tool (C.13): execute the project's test suite and return a
|
|
6
|
+
* structured result the model can iterate on (edit → re-run → repeat). The
|
|
7
|
+
* iteration loop itself is the ordinary agent loop; this module contributes
|
|
8
|
+
* the two guarantees that make it trustworthy — honest green (exit-code-only)
|
|
9
|
+
* and a hard cap on consecutive failing runs.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Counts consecutive FAILING test executions; a green run resets it. When the
|
|
13
|
+
* count reaches the cap, the next attempt is refused with the coded
|
|
14
|
+
* CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes), and the counter
|
|
15
|
+
* resets — so the episode ends loudly, but a deliberate later attempt (a new
|
|
16
|
+
* user instruction) starts fresh rather than finding a permanently dead tool.
|
|
17
|
+
* Per-turn work stays bounded regardless via `agent.maxIterations`.
|
|
18
|
+
*/
|
|
19
|
+
export declare class TestIterationBudget {
|
|
20
|
+
private failedRuns;
|
|
21
|
+
/** Runs already spent in the current failing streak. */
|
|
22
|
+
get spent(): number;
|
|
23
|
+
/** True when the next run must be refused; resets the streak as it trips. */
|
|
24
|
+
trip(maxIterations: number): boolean;
|
|
25
|
+
record(passed: boolean): void;
|
|
26
|
+
}
|
|
27
|
+
declare const parameters: z.ZodObject<{
|
|
28
|
+
command: z.ZodOptional<z.ZodString>;
|
|
29
|
+
}, "strip", z.ZodTypeAny, {
|
|
30
|
+
command?: string | undefined;
|
|
31
|
+
}, {
|
|
32
|
+
command?: string | undefined;
|
|
33
|
+
}>;
|
|
34
|
+
export interface RunTestsToolDeps {
|
|
35
|
+
/** Execution seam (tests inject a fake; default spawns the real command). */
|
|
36
|
+
runner?: TestRunner;
|
|
37
|
+
/** Detection seam (defaults to config + package.json detection). */
|
|
38
|
+
detect?: (ctx: ToolContext) => TestCommand | null;
|
|
39
|
+
}
|
|
40
|
+
/** Build the `run_tests` tool. One instance = one session's iteration budget. */
|
|
41
|
+
export declare function makeRunTestsTool(deps?: RunTestsToolDeps): Tool<typeof parameters>;
|
|
42
|
+
export {};
|