@cruxy/cli 1.5.0 → 1.7.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/README.md +116 -0
- package/dist/agent/session.js +76 -7
- package/dist/budget/index.js +9 -0
- package/dist/budget/session-budget.js +223 -0
- package/dist/checkpoint/diff.js +130 -0
- package/dist/checkpoint/git-store.js +52 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/run-rollback.js +100 -0
- package/dist/cli/command-catalog.js +144 -0
- package/dist/cli/commands/hooks.js +1 -1
- package/dist/cli/commands/rollback.js +21 -57
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/test.js +28 -16
- package/dist/cli/session-commands.js +315 -69
- package/dist/cli/session-factory.js +13 -0
- package/dist/components/frame.js +39 -1
- package/dist/errors/constructors.js +43 -4
- package/dist/errors/types.js +15 -0
- package/dist/hooks/config.js +18 -0
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/router.js +1 -1
- package/dist/hooks/service.js +4 -4
- package/dist/hooks/slash.js +10 -26
- package/dist/lsp/index.js +1 -1
- package/dist/lsp/registry.js +28 -10
- package/dist/memory/secrets.js +43 -0
- package/dist/plan/service.js +26 -1
- package/dist/plan/submit-plan.js +11 -0
- package/dist/render/capabilities.js +9 -2
- package/dist/render/context-view.js +2 -2
- package/dist/render/index.js +6 -1
- package/dist/render/plan-view.js +1 -1
- package/dist/render/status-view.js +5 -5
- package/dist/render/units.js +22 -0
- package/dist/session/index.js +1 -0
- package/dist/session/log.js +19 -0
- package/dist/session/redact.js +74 -0
- package/dist/session/replay.js +16 -0
- package/dist/session/resume.js +8 -0
- package/dist/session/types.js +38 -0
- package/dist/subagent/orchestrator.js +82 -5
- package/dist/theme/resolve.js +1 -0
- package/dist/tui/app.js +25 -7
- package/dist/tui/approval-overlay.js +7 -1
- package/dist/tui/index.js +3 -2
- package/dist/tui/layout.js +7 -2
- package/dist/tui/limits-panel.js +6 -14
- package/dist/tui/mode-ring.js +84 -0
- package/dist/tui/palette.js +11 -19
- package/dist/tui/renderer.js +145 -4
- package/dist/tui/restore.js +137 -0
- package/dist/tui/supports.js +22 -0
- package/dist/tui/tool-versions.js +119 -18
- package/dist/usage/weighted.js +14 -0
- package/dist/utils/disk.js +11 -3
- package/package.json +2 -2
package/dist/checkpoint/index.js
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { CheckpointService } from "./service.js";
|
|
3
|
+
import { listSets } from "./set.js";
|
|
4
|
+
import { applySet, buildSetPreview, setIsNoop, validateSet, } from "./set-rollback.js";
|
|
5
|
+
/**
|
|
6
|
+
* Restore every touched root of one run's {@link CheckpointSet} as a single gated
|
|
7
|
+
* operation (C.26): validate-all up front, ONE combined preview behind ONE
|
|
8
|
+
* approval, then a sequential apply that stops and reports on first failure.
|
|
9
|
+
*/
|
|
10
|
+
export async function rollbackSet(set, deps) {
|
|
11
|
+
const { report } = deps;
|
|
12
|
+
const t = report.theme;
|
|
13
|
+
// Validate-all BEFORE any apply — a missing/corrupt member throws here.
|
|
14
|
+
const members = await validateSet(set, deps.config);
|
|
15
|
+
if (setIsNoop(members)) {
|
|
16
|
+
report.print(t.muted(`working tree already matches run ${set.runId} — nothing to roll back`));
|
|
17
|
+
return { kind: "noop" };
|
|
18
|
+
}
|
|
19
|
+
const decision = await deps.requestApproval({
|
|
20
|
+
kind: "rollback",
|
|
21
|
+
preview: buildSetPreview(set, members),
|
|
22
|
+
});
|
|
23
|
+
if (!decision.allow) {
|
|
24
|
+
report.print(t.muted("rollback declined — nothing was changed"));
|
|
25
|
+
return { kind: "declined" };
|
|
26
|
+
}
|
|
27
|
+
const applied = await applySet(set, members);
|
|
28
|
+
const parts = applied.restored.map((name) => {
|
|
29
|
+
const counts = applied.perRoot[name];
|
|
30
|
+
return `${name} (${counts.reverted} reverted, ${counts.recreated} recreated, ${counts.deleted} deleted)`;
|
|
31
|
+
});
|
|
32
|
+
report.print(`${t.success(t.glyph.success)} restored run ${t.accent(set.runId)} across ` +
|
|
33
|
+
`${applied.restored.length} root${applied.restored.length === 1 ? "" : "s"} — ${parts.join("; ")}`);
|
|
34
|
+
printCaveat(report);
|
|
35
|
+
return { kind: "applied", roots: applied.restored.length };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Single-root rollback (C.32): restore ONE root to one checkpoint. Reached for an
|
|
39
|
+
* explicit checkpoint id (the per-member escape hatch — including when the
|
|
40
|
+
* primary root was removed mid-session and its set index is gone, ⚖︎#7) and as
|
|
41
|
+
* the JC-F back-compat fallback for a run that predates set manifests.
|
|
42
|
+
*
|
|
43
|
+
* `id` omitted → the newest checkpoint for that root.
|
|
44
|
+
*/
|
|
45
|
+
export async function rollbackCheckpoint(root, id, deps) {
|
|
46
|
+
const { report } = deps;
|
|
47
|
+
const t = report.theme;
|
|
48
|
+
const service = new CheckpointService({ root, config: deps.config });
|
|
49
|
+
const result = await service.rollback(id, {
|
|
50
|
+
requestApproval: deps.requestApproval,
|
|
51
|
+
interactive: deps.interactive,
|
|
52
|
+
});
|
|
53
|
+
if (result.kind === "noop") {
|
|
54
|
+
report.print(t.muted(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
|
|
55
|
+
return { kind: "noop" };
|
|
56
|
+
}
|
|
57
|
+
if (result.kind === "rejected") {
|
|
58
|
+
report.print(t.muted("rollback declined — nothing was changed"));
|
|
59
|
+
return { kind: "declined" };
|
|
60
|
+
}
|
|
61
|
+
const { recreated, reverted, deleted } = result.applied;
|
|
62
|
+
report.print(`${t.success(t.glyph.success)} restored checkpoint ${t.accent(result.checkpoint.id)} — ` +
|
|
63
|
+
`${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
|
|
64
|
+
printCaveat(report);
|
|
65
|
+
return { kind: "applied", roots: 1 };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Roll back the MOST RECENT run under `primaryRoot`, with no picker and no id to
|
|
69
|
+
* supply — the shape `/undo-last` needs, and the shape `cruxy rollback` already
|
|
70
|
+
* had for its no-argument case once its picker declines to appear.
|
|
71
|
+
*
|
|
72
|
+
* Prefers the set manifest, which is the only record that knows how many roots a
|
|
73
|
+
* run touched; falls back to the newest single-root checkpoint (logged through
|
|
74
|
+
* the reporter, never silently) for a run that predates set manifests.
|
|
75
|
+
*
|
|
76
|
+
* Returns `null` when there is nothing recorded at all — a fact the caller
|
|
77
|
+
* phrases, because "no checkpoints yet" means something different at a shell
|
|
78
|
+
* prompt than it does three turns into a session.
|
|
79
|
+
*/
|
|
80
|
+
export async function rollbackLatestRun(primaryRoot, deps) {
|
|
81
|
+
const sets = await listSets(primaryRoot); // newest first
|
|
82
|
+
if (sets.length > 0)
|
|
83
|
+
return rollbackSet(sets[0], deps);
|
|
84
|
+
const service = new CheckpointService({
|
|
85
|
+
root: primaryRoot,
|
|
86
|
+
config: deps.config,
|
|
87
|
+
});
|
|
88
|
+
if ((await service.list()).length === 0)
|
|
89
|
+
return null;
|
|
90
|
+
deps.report.print(deps.report.theme.muted(`no set manifest — single-root rollback against ${path.basename(primaryRoot)}`));
|
|
91
|
+
return rollbackCheckpoint(primaryRoot, undefined, deps);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The one thing a successful rollback must always say. A user who has just
|
|
95
|
+
* watched a run be undone will reasonably assume it was undone; the parts that
|
|
96
|
+
* left the working tree were never in the checkpoint to begin with.
|
|
97
|
+
*/
|
|
98
|
+
function printCaveat(report) {
|
|
99
|
+
report.print(report.theme.muted("note: commits, pushes, and PRs made during the run are not undone"));
|
|
100
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalogue of commands the shells dispatch themselves, and the reserved
|
|
3
|
+
* name set derived from it (P10 track 0).
|
|
4
|
+
*
|
|
5
|
+
* A LEAF MODULE, deliberately: it imports nothing. The reserved set has to be
|
|
6
|
+
* readable from two places that cannot see each other — `cli/session-commands.ts`,
|
|
7
|
+
* which dispatches these names, and `hooks/config.ts`, which loads the custom
|
|
8
|
+
* commands that must not collide with them. Leaving the catalogue in
|
|
9
|
+
* `session-commands.ts` and importing it from `hooks/` would close a cycle
|
|
10
|
+
* (`session-commands` → `hooks/index` → `hooks/slash` → `session-commands`) and
|
|
11
|
+
* put a module-scope `const` in its own TDZ. So the data lives here and both
|
|
12
|
+
* sides import down into it.
|
|
13
|
+
*
|
|
14
|
+
* WHY THIS EXISTS AT ALL: `hooks/slash.ts` used to carry its own hand-written
|
|
15
|
+
* list of seven "builtins", while `dispatchCommand` intercepted nineteen names
|
|
16
|
+
* and the TUI three more. The other fifteen were reserved in practice and
|
|
17
|
+
* reserved nowhere in code: a `.cruxy/commands/status.md` loaded cleanly, listed
|
|
18
|
+
* itself in `cruxy hooks list` and in the palette, and then never ran, because
|
|
19
|
+
* `/status` was handled several `if`s before the custom catalogue was consulted.
|
|
20
|
+
* No warning at load, none at use, and nothing on screen to connect the two.
|
|
21
|
+
* Deriving the set from the dispatchers instead of restating it is what stops
|
|
22
|
+
* that gap reopening the next time a command is added.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* The commands shared by both shells, in the order `/help` lists them.
|
|
26
|
+
*
|
|
27
|
+
* ONE catalogue, four consumers: the help text, Tab completion, the command
|
|
28
|
+
* palette, and now the reserved set. They used to be three hand-maintained
|
|
29
|
+
* lists — which is how the TUI ended up exporting a command list nothing
|
|
30
|
+
* consumed while advertising a help text that named commands it could not run.
|
|
31
|
+
*/
|
|
32
|
+
export const COMMAND_CATALOG = [
|
|
33
|
+
{ name: "/help", summary: "show this help" },
|
|
34
|
+
{
|
|
35
|
+
name: "/clear",
|
|
36
|
+
summary: "clear the conversation history (keep the session)",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "/compact",
|
|
40
|
+
summary: "summarize older history to free up context now",
|
|
41
|
+
},
|
|
42
|
+
{ name: "/init", summary: "scaffold a project CRUXY.md and load it" },
|
|
43
|
+
{ name: "/reload", summary: "re-read project instructions (CRUXY.md)" },
|
|
44
|
+
{ name: "/status", summary: "show what this session is set up to do" },
|
|
45
|
+
{
|
|
46
|
+
name: "/diff",
|
|
47
|
+
summary: "show uncommitted changes in the workspace",
|
|
48
|
+
args: "[ref | --since <checkpoint>]",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "/undo-last",
|
|
52
|
+
summary: "roll back the most recent checkpointed set of file changes",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "/redact",
|
|
56
|
+
summary: "mask secrets in this conversation so the model stops seeing them",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "/export",
|
|
60
|
+
summary: "write this conversation to a markdown file",
|
|
61
|
+
args: "[path]",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "/plan",
|
|
65
|
+
summary: "toggle plan mode (propose a plan before executing)",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "/mode",
|
|
69
|
+
summary: "show or set the session mode",
|
|
70
|
+
args: "[manual | auto-approve | plan | full-auto]",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "/model",
|
|
74
|
+
summary: "show or set the model for this session",
|
|
75
|
+
args: "[auto | kavi | vaani | mira]",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "/context",
|
|
79
|
+
summary: "show where the context budget is going, and what compaction would drop",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "/usage",
|
|
83
|
+
summary: "show token usage, weighted tokens and cost",
|
|
84
|
+
args: "[all | last <n>]",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "/budget",
|
|
88
|
+
summary: "show or set this session's weighted-token budget",
|
|
89
|
+
args: "[<n> | off]",
|
|
90
|
+
},
|
|
91
|
+
{ name: "/jobs", summary: "list background jobs and their status" },
|
|
92
|
+
{ name: "/logs", summary: "show a background job's log", args: "<id>" },
|
|
93
|
+
{ name: "/cancel", summary: "cancel a background job", args: "<id>" },
|
|
94
|
+
{
|
|
95
|
+
name: "/add-root",
|
|
96
|
+
summary: "declare another workspace root",
|
|
97
|
+
args: "<name> <path>",
|
|
98
|
+
},
|
|
99
|
+
{ name: "/exit", summary: "leave cruxy" },
|
|
100
|
+
{ name: "/quit", summary: "leave cruxy" },
|
|
101
|
+
];
|
|
102
|
+
/**
|
|
103
|
+
* The three the TUI dispatches before the shared set, and the REPL has no
|
|
104
|
+
* meaning for. They live HERE rather than in `tui/app.ts` so there is one
|
|
105
|
+
* reserved set rather than two — a custom `/view` that worked in the REPL and
|
|
106
|
+
* was inert in the TUI is exactly the split this file exists to prevent, and
|
|
107
|
+
* the default shell is the one where it would be inert.
|
|
108
|
+
*/
|
|
109
|
+
export const TUI_ONLY_COMMANDS = [
|
|
110
|
+
{
|
|
111
|
+
name: "/close",
|
|
112
|
+
summary: "hide a panel or the whole rail",
|
|
113
|
+
args: "<sidebar | context | model | git | tools | rail>",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "/open",
|
|
117
|
+
summary: "show a hidden panel",
|
|
118
|
+
args: "<sidebar | context | model | git | tools | rail>",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "/view",
|
|
122
|
+
summary: "show a main-pane view, or list them",
|
|
123
|
+
args: "[name]",
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
/**
|
|
127
|
+
* Every name a shell dispatches itself, without its leading slash — sorted, so
|
|
128
|
+
* it reads as a list rather than as dispatch order.
|
|
129
|
+
*
|
|
130
|
+
* A custom command may not take any of these: it would load, list, and then
|
|
131
|
+
* never run. The loader rejects the collision by name (see `hooks/config.ts`),
|
|
132
|
+
* which is the only place a user finds out early enough to rename the file.
|
|
133
|
+
*/
|
|
134
|
+
export const RESERVED_SLASH_NAMES = [
|
|
135
|
+
...COMMAND_CATALOG,
|
|
136
|
+
...TUI_ONLY_COMMANDS,
|
|
137
|
+
]
|
|
138
|
+
.map((c) => c.name.slice(1))
|
|
139
|
+
.sort();
|
|
140
|
+
const RESERVED = new Set(RESERVED_SLASH_NAMES);
|
|
141
|
+
/** Is `name` (no leading slash) dispatched by a shell, and so unavailable? */
|
|
142
|
+
export function isReservedSlash(name) {
|
|
143
|
+
return RESERVED.has(name);
|
|
144
|
+
}
|
|
@@ -76,7 +76,7 @@ function printCommands(commands, t) {
|
|
|
76
76
|
function printErrors(errors, t) {
|
|
77
77
|
if (errors.length === 0)
|
|
78
78
|
return;
|
|
79
|
-
logger.print(`\n${t.danger(t.heading("
|
|
79
|
+
logger.print(`\n${t.danger(t.heading("excluded (never run):"))}`);
|
|
80
80
|
for (const e of errors) {
|
|
81
81
|
logger.print(` ${t.muted(`[${e.source}]`)} ${t.strong(e.name)} — ${e.message}`);
|
|
82
82
|
}
|
|
@@ -2,7 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { themeForColor } from "../../theme/index.js";
|
|
4
4
|
import { loadConfig } from "../../config/index.js";
|
|
5
|
-
import { CheckpointService,
|
|
5
|
+
import { CheckpointService, listSets, rollbackCheckpoint, rollbackSet, } from "../../checkpoint/index.js";
|
|
6
6
|
import { ApprovalService, defaultPromptIO } from "../../approval/index.js";
|
|
7
7
|
import { fuzzyFind, selectList } from "../../components/index.js";
|
|
8
8
|
import { rollbackApprovalRequired, shouldUseColor, } from "../../errors/index.js";
|
|
@@ -42,14 +42,18 @@ async function pickCheckpoint(service) {
|
|
|
42
42
|
return result.kind === "selected" ? result.value : null;
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
|
-
* Legacy single-root rollback (C.32)
|
|
45
|
+
* Legacy single-root rollback (C.32) with this surface's picker in front of it:
|
|
46
46
|
* an explicit `cruxy rollback <id>` (the per-member escape hatch — including when
|
|
47
|
-
* the primary root was removed mid-session and its set index is gone, ⚖︎#7)
|
|
48
|
-
* the JC-F back-compat fallback for a pre-set-manifest run
|
|
47
|
+
* the primary root was removed mid-session and its set index is gone, ⚖︎#7), or
|
|
48
|
+
* the JC-F back-compat fallback for a pre-set-manifest run, where a command line
|
|
49
|
+
* that named nothing gets to choose.
|
|
50
|
+
*
|
|
51
|
+
* The rollback ITSELF is `rollbackCheckpoint` (P10 track 1) — shared with
|
|
52
|
+
* `/undo-last`. Only the picker is this command's.
|
|
49
53
|
*/
|
|
50
|
-
async function
|
|
51
|
-
const service = new CheckpointService({ root, config });
|
|
54
|
+
async function pickAndRollback(root, id, deps, t) {
|
|
52
55
|
if (id === undefined) {
|
|
56
|
+
const service = new CheckpointService({ root, config: deps.config });
|
|
53
57
|
const picked = await pickCheckpoint(service);
|
|
54
58
|
if (picked === null) {
|
|
55
59
|
logger.print(t.muted("rollback cancelled — nothing was changed"));
|
|
@@ -57,53 +61,7 @@ async function legacyRollback(root, config, approval, interactive, id, t) {
|
|
|
57
61
|
}
|
|
58
62
|
id = picked?.id;
|
|
59
63
|
}
|
|
60
|
-
|
|
61
|
-
requestApproval: (action) => approval.requestApproval(action),
|
|
62
|
-
interactive,
|
|
63
|
-
});
|
|
64
|
-
if (result.kind === "noop") {
|
|
65
|
-
logger.print(t.muted(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
if (result.kind === "rejected") {
|
|
69
|
-
logger.print(t.muted("rollback declined — nothing was changed"));
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
const { recreated, reverted, deleted } = result.applied;
|
|
73
|
-
logger.print(`${t.success(t.glyph.success)} restored checkpoint ${t.accent(result.checkpoint.id)} — ` +
|
|
74
|
-
`${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
|
|
75
|
-
logger.print(t.muted("note: commits, pushes, and PRs made during the run are not undone"));
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Set-based rollback (C.26): restore every touched root of the latest run as one
|
|
79
|
-
* gated operation. Validate-ALL members up front (missing/corrupt →
|
|
80
|
-
* `CHECKPOINT_SET_INCOMPLETE`, nothing applied), one combined per-root preview and
|
|
81
|
-
* one U.3 approval, then a sequential apply that stops on first failure
|
|
82
|
-
* (`CHECKPOINT_SET_PARTIAL`, R3). Both coded errors propagate to the boundary.
|
|
83
|
-
*/
|
|
84
|
-
async function setRollback(config, approval, t, set) {
|
|
85
|
-
// Validate-all BEFORE any apply — a missing/corrupt member throws here.
|
|
86
|
-
const members = await validateSet(set, config);
|
|
87
|
-
if (setIsNoop(members)) {
|
|
88
|
-
logger.print(t.muted(`working tree already matches run ${set.runId} — nothing to roll back`));
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
const decision = await approval.requestApproval({
|
|
92
|
-
kind: "rollback",
|
|
93
|
-
preview: buildSetPreview(set, members),
|
|
94
|
-
});
|
|
95
|
-
if (!decision.allow) {
|
|
96
|
-
logger.print(t.muted("rollback declined — nothing was changed"));
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
const applied = await applySet(set, members);
|
|
100
|
-
const parts = applied.restored.map((name) => {
|
|
101
|
-
const counts = applied.perRoot[name];
|
|
102
|
-
return `${name} (${counts.reverted} reverted, ${counts.recreated} recreated, ${counts.deleted} deleted)`;
|
|
103
|
-
});
|
|
104
|
-
logger.print(`${t.success(t.glyph.success)} restored run ${t.accent(set.runId)} across ` +
|
|
105
|
-
`${applied.restored.length} root${applied.restored.length === 1 ? "" : "s"} — ${parts.join("; ")}`);
|
|
106
|
-
logger.print(t.muted("note: commits, pushes, and PRs made during the run are not undone"));
|
|
64
|
+
await rollbackCheckpoint(root, id, deps);
|
|
107
65
|
}
|
|
108
66
|
/**
|
|
109
67
|
* `cruxy rollback [id]` (C.32/C.26) — restore the working tree to a run's
|
|
@@ -139,6 +97,12 @@ export function rollbackCommand() {
|
|
|
139
97
|
interactive,
|
|
140
98
|
io: defaultPromptIO(shouldUseColor()),
|
|
141
99
|
});
|
|
100
|
+
const deps = {
|
|
101
|
+
config,
|
|
102
|
+
requestApproval: (action) => approval.requestApproval(action),
|
|
103
|
+
interactive,
|
|
104
|
+
report: { print: (line) => logger.print(line), theme: t },
|
|
105
|
+
};
|
|
142
106
|
const sets = await listSets(primaryRoot); // newest first
|
|
143
107
|
if (id !== undefined) {
|
|
144
108
|
// A job/run id first: if `<id>` names a set manifest (a background job's
|
|
@@ -146,19 +110,19 @@ export function rollbackCommand() {
|
|
|
146
110
|
// it as a single checkpoint id (the per-member escape hatch).
|
|
147
111
|
const jobSet = sets.find((s) => s.runId === id);
|
|
148
112
|
if (jobSet) {
|
|
149
|
-
await
|
|
113
|
+
await rollbackSet(jobSet, deps);
|
|
150
114
|
return;
|
|
151
115
|
}
|
|
152
|
-
await
|
|
116
|
+
await pickAndRollback(primaryRoot, id, deps, t);
|
|
153
117
|
return;
|
|
154
118
|
}
|
|
155
119
|
// No set manifest → JC-F: fall back to legacy single-root, logged (never
|
|
156
120
|
// silent). The primary root name matches single-root workspace naming.
|
|
157
121
|
if (sets.length === 0) {
|
|
158
122
|
logger.info(`no set manifest — single-root rollback against ${path.basename(primaryRoot)}`);
|
|
159
|
-
await
|
|
123
|
+
await pickAndRollback(primaryRoot, undefined, deps, t);
|
|
160
124
|
return;
|
|
161
125
|
}
|
|
162
|
-
await
|
|
126
|
+
await rollbackSet(sets[0], deps);
|
|
163
127
|
});
|
|
164
128
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -351,12 +351,19 @@ export async function executeRun(promptParts, opts) {
|
|
|
351
351
|
// The cache is still attached without a key so the panel can say "not signed
|
|
352
352
|
// in" — a fact worth stating, and one that costs no request to establish.
|
|
353
353
|
const limitsKey = config.model.provider === "cruxy" ? apiKey : undefined;
|
|
354
|
-
|
|
354
|
+
const limits = new LimitsCache(limitsKey === undefined
|
|
355
355
|
? undefined
|
|
356
356
|
: (signal) => new LimitsClient({
|
|
357
357
|
apiKey: limitsKey,
|
|
358
358
|
gatewayUrl: config.cruxy.gatewayUrl,
|
|
359
|
-
}).read(signal))
|
|
359
|
+
}).read(signal));
|
|
360
|
+
tui?.attachLimits(limits);
|
|
361
|
+
// The SAME cache backs the session's weighted-token budget (P10 track 3), so
|
|
362
|
+
// the rail's headroom bar and `/budget`'s server line are one reading rather
|
|
363
|
+
// than two probes that can disagree — and so admission control at the fan-out
|
|
364
|
+
// seam bounds against the window the user can actually see. Attached even
|
|
365
|
+
// without a TUI: the REPL has no rail, but it has `/budget` and it fans out.
|
|
366
|
+
session.budget?.attachLimits(limits);
|
|
360
367
|
// The main-pane views (P7). Registered here — the one place that has both the
|
|
361
368
|
// renderer and the session — and after the session exists, because a view
|
|
362
369
|
// reads its live state. Each root gets its own cached git probe: writes fan
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import pc from "picocolors";
|
|
3
2
|
import { loadConfig } from "../../config/index.js";
|
|
4
|
-
import { testCommandNotFound } from "../../errors/index.js";
|
|
3
|
+
import { shouldUseColor, testCommandNotFound } from "../../errors/index.js";
|
|
4
|
+
import { testResultLines } from "../../render/index.js";
|
|
5
|
+
import { themeForColor } from "../../theme/index.js";
|
|
5
6
|
import { CommandTestRunner, detectTestCommand } from "../../testing/index.js";
|
|
6
7
|
import { logger } from "../../utils/logger.js";
|
|
7
8
|
/**
|
|
@@ -10,38 +11,49 @@ import { logger } from "../../utils/logger.js";
|
|
|
10
11
|
* user-invoked, so there is no approval gate (typing the command IS the
|
|
11
12
|
* consent — same as running the suite by hand); the process exit code mirrors
|
|
12
13
|
* the suite's pass/fail so scripts and CI can branch on it.
|
|
14
|
+
*
|
|
15
|
+
* The RESULT is rendered by `render/test-view.ts` (P11 track 2), the same
|
|
16
|
+
* formatter behind `StreamRenderer.testResult`, rather than by a picocolors
|
|
17
|
+
* path of its own. This command used to print `pc.green("✓")` directly, which
|
|
18
|
+
* made it the one surface in the CLI that reported a test run differently from
|
|
19
|
+
* every other — and, because the marks were literals rather than theme tokens,
|
|
20
|
+
* the one that still printed `✓`/`✗` under `CRUXY_ASCII` and read them out as
|
|
21
|
+
* bare punctuation to a screen reader. Routing through the shared view fixes
|
|
22
|
+
* both at once and means the honesty rules encoded there (never infer a passed
|
|
23
|
+
* count, never imply an empty failure list is exhaustive) apply here too.
|
|
13
24
|
*/
|
|
14
25
|
export function testCommand() {
|
|
15
26
|
return new Command("test")
|
|
16
27
|
.description("run the project's test suite once and show the parsed result")
|
|
17
28
|
.action(async () => {
|
|
29
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
18
30
|
const { config } = loadConfig();
|
|
19
31
|
const cwd = process.cwd();
|
|
20
32
|
const detected = detectTestCommand(cwd, config);
|
|
21
33
|
if (detected === null)
|
|
22
34
|
throw testCommandNotFound();
|
|
23
|
-
logger.print(
|
|
35
|
+
logger.print(t.muted(`running: ${detected.command} [${detected.source}]`));
|
|
24
36
|
const result = await new CommandTestRunner().run(detected.command, {
|
|
25
37
|
cwd,
|
|
26
38
|
timeoutMs: config.shell.timeoutMs,
|
|
27
39
|
captureBytes: config.test.captureBytes,
|
|
28
40
|
shell: config.shell,
|
|
29
41
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
logger.print(`${pc.red("✗")} tests failed (exit ${result.exitCode ?? "signal"}) in ${seconds}s`);
|
|
36
|
-
for (const failure of result.failures) {
|
|
37
|
-
const where = failure.file !== undefined
|
|
38
|
-
? pc.dim(` ${failure.file}${failure.line !== undefined ? `:${failure.line}` : ""}`)
|
|
39
|
-
: "";
|
|
40
|
-
logger.print(` ${pc.red("✗")} ${failure.name}${where}`);
|
|
42
|
+
// The runner's result IS a TestReport plus execution detail (`exitCode`,
|
|
43
|
+
// the captured `output`) the view has no use for; the command supplies the
|
|
44
|
+
// one field the runner does not carry, which is what it ran.
|
|
45
|
+
for (const line of testResultLines({ ...result, command: detected.command }, t)) {
|
|
46
|
+
logger.print(line);
|
|
41
47
|
}
|
|
48
|
+
if (result.passed)
|
|
49
|
+
return;
|
|
50
|
+
// Nothing parseable — show the honest tail beneath the view's own "no
|
|
51
|
+
// individual failures were recognized" line. The view says WHAT is
|
|
52
|
+
// missing; a directly-invoked command can also afford to show the raw
|
|
53
|
+
// output it was missing from, which is the next thing the user would ask
|
|
54
|
+
// for and the reason to run `cruxy test` by hand rather than read a log.
|
|
42
55
|
if (result.failures.length === 0) {
|
|
43
|
-
|
|
44
|
-
logger.print(pc.dim(result.output.trimEnd()));
|
|
56
|
+
logger.print(t.muted(result.output.trimEnd()));
|
|
45
57
|
}
|
|
46
58
|
process.exitCode = 1;
|
|
47
59
|
});
|