@cruxy/cli 1.2.1 → 1.3.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/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +20 -1
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +171 -69
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +374 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +755 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +27 -3
- package/dist/components/select.js +3 -3
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +198 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +385 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +63 -0
- package/dist/tui/index.js +10 -0
- package/dist/tui/layout.js +269 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +776 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/usage/collect.js +6 -6
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +5 -2
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +50 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import { MODE_LABELS, SESSION_MODES, modeDescription, modeFromFlags, parseMode, } from "../agent/index.js";
|
|
2
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { contextReport, readContext } from "../agent/context.js";
|
|
5
|
+
import { scaffoldProjectInstructions } from "../config/index.js";
|
|
6
|
+
import { resolveSlash } from "../hooks/index.js";
|
|
7
|
+
import { contextReportLines } from "../render/context-view.js";
|
|
8
|
+
import { renderUnifiedDiff } from "../render/index.js";
|
|
9
|
+
import { sessionStatusLines } from "../render/status-view.js";
|
|
10
|
+
import { defaultExportName, exportMarkdown } from "../session/index.js";
|
|
11
|
+
import { getGitInfo } from "../utils/git.js";
|
|
12
|
+
import { currentBranch, diffAgainst, hasChanges } from "../vcs/git.js";
|
|
13
|
+
import { MODEL_CHOICES, describeModelChoice, parseModelChoice, } from "../routing/index.js";
|
|
14
|
+
import { loadUsage, runCountLabel, selectRuns, usageReport, } from "../usage/index.js";
|
|
15
|
+
import { runGatedShell } from "../tools/shell/exec.js";
|
|
16
|
+
import { addRootToWorkspace } from "../workspace/index.js";
|
|
17
|
+
import { formatError, fromUnknown, isVerbose } from "../errors/index.js";
|
|
18
|
+
/**
|
|
19
|
+
* The commands shared by both shells, in the order `/help` lists them.
|
|
20
|
+
*
|
|
21
|
+
* ONE catalogue, three consumers: the help text, Tab completion, and the
|
|
22
|
+
* command palette. They used to be three hand-maintained lists — which is how
|
|
23
|
+
* the TUI ended up exporting a command list nothing consumed while advertising
|
|
24
|
+
* a help text that named commands it could not run.
|
|
25
|
+
*
|
|
26
|
+
* Panel commands (`/close`, `/open`) are the TUI's alone and live in
|
|
27
|
+
* `tui/app.ts`; they have no meaning in a shell with no panels.
|
|
28
|
+
*/
|
|
29
|
+
export const COMMAND_CATALOG = [
|
|
30
|
+
{ name: "/help", summary: "show this help" },
|
|
31
|
+
{
|
|
32
|
+
name: "/clear",
|
|
33
|
+
summary: "clear the conversation history (keep the session)",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "/compact",
|
|
37
|
+
summary: "summarize older history to free up context now",
|
|
38
|
+
},
|
|
39
|
+
{ name: "/init", summary: "scaffold a project CRUXY.md and load it" },
|
|
40
|
+
{ name: "/reload", summary: "re-read project instructions (CRUXY.md)" },
|
|
41
|
+
{ name: "/status", summary: "show what this session is set up to do" },
|
|
42
|
+
{
|
|
43
|
+
name: "/diff",
|
|
44
|
+
summary: "show uncommitted changes in the workspace",
|
|
45
|
+
args: "[ref]",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "/export",
|
|
49
|
+
summary: "write this conversation to a markdown file",
|
|
50
|
+
args: "[path]",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "/plan",
|
|
54
|
+
summary: "toggle plan mode (propose a plan before executing)",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "/mode",
|
|
58
|
+
summary: "show or set the session mode",
|
|
59
|
+
args: "[manual | auto-approve | plan | full-auto]",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "/model",
|
|
63
|
+
summary: "show or set the model for this session",
|
|
64
|
+
args: "[auto | kavi | vaani | mira]",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "/context",
|
|
68
|
+
summary: "show where the context budget is going, and what compaction would drop",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "/usage",
|
|
72
|
+
summary: "show token usage, weighted tokens and cost",
|
|
73
|
+
args: "[all | last <n>]",
|
|
74
|
+
},
|
|
75
|
+
{ name: "/jobs", summary: "list background jobs and their status" },
|
|
76
|
+
{ name: "/logs", summary: "show a background job's log", args: "<id>" },
|
|
77
|
+
{ name: "/cancel", summary: "cancel a background job", args: "<id>" },
|
|
78
|
+
{
|
|
79
|
+
name: "/add-root",
|
|
80
|
+
summary: "declare another workspace root",
|
|
81
|
+
args: "<name> <path>",
|
|
82
|
+
},
|
|
83
|
+
{ name: "/exit", summary: "leave cruxy" },
|
|
84
|
+
{ name: "/quit", summary: "leave cruxy" },
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Line cap for `/diff`. Generous compared to a preview block — this is a command
|
|
88
|
+
* whose entire purpose is the diff, not a block competing for space with a
|
|
89
|
+
* conversation — but still bounded, and the marker says how many were dropped.
|
|
90
|
+
*/
|
|
91
|
+
const DIFF_MAX_LINES = 400;
|
|
92
|
+
/** Just the names — the Tab-completion candidate set. */
|
|
93
|
+
export const SHARED_COMMANDS = COMMAND_CATALOG.map((c) => c.name);
|
|
94
|
+
/** Render one catalogue entry as a `/help` row, aligned on a fixed gutter. */
|
|
95
|
+
export function helpRow(command, gutter = 18) {
|
|
96
|
+
const left = command.args ? `${command.name} ${command.args}` : command.name;
|
|
97
|
+
return ` ${left.padEnd(gutter)} ${command.summary}`;
|
|
98
|
+
}
|
|
99
|
+
/** The shared half of `/help`, minus each shell's own additions. */
|
|
100
|
+
export const SHARED_HELP = COMMAND_CATALOG.map((c) => helpRow(c));
|
|
101
|
+
/**
|
|
102
|
+
* Dispatch one submitted line.
|
|
103
|
+
*
|
|
104
|
+
* Returns `{kind:"turn"}` for anything this does not own — including an unknown
|
|
105
|
+
* `/…` string, which falls through to the model exactly as it always has rather
|
|
106
|
+
* than being rejected. A user who types a command that does not exist gets an
|
|
107
|
+
* answer, not a scolding.
|
|
108
|
+
*/
|
|
109
|
+
export async function dispatchCommand(input, ctx) {
|
|
110
|
+
const trimmed = input.trim();
|
|
111
|
+
const { session, out } = ctx;
|
|
112
|
+
const t = out.theme;
|
|
113
|
+
if (trimmed === "")
|
|
114
|
+
return { kind: "handled" };
|
|
115
|
+
if (trimmed === "/exit" || trimmed === "/quit")
|
|
116
|
+
return { kind: "exit" };
|
|
117
|
+
if (trimmed === "/clear") {
|
|
118
|
+
session.clear();
|
|
119
|
+
out.print(t.muted("history cleared"));
|
|
120
|
+
return { kind: "handled" };
|
|
121
|
+
}
|
|
122
|
+
if (trimmed === "/compact") {
|
|
123
|
+
try {
|
|
124
|
+
const n = await session.compact();
|
|
125
|
+
out.print(t.muted(n ? `compacted ${n} older messages` : "nothing to compact yet"));
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
printCommandError(out, err);
|
|
129
|
+
}
|
|
130
|
+
return { kind: "handled" };
|
|
131
|
+
}
|
|
132
|
+
if (trimmed === "/reload") {
|
|
133
|
+
const loaded = session.reloadProjectInstructions();
|
|
134
|
+
out.print(t.muted(loaded
|
|
135
|
+
? "reloaded project instructions (CRUXY.md)"
|
|
136
|
+
: "no project instructions found"));
|
|
137
|
+
return { kind: "handled" };
|
|
138
|
+
}
|
|
139
|
+
if (trimmed === "/plan") {
|
|
140
|
+
// Toggling plan is a MOVE within the mode ring, not a boolean flip: it
|
|
141
|
+
// preserves whatever the auto-approve half was, so leaving plan mode from
|
|
142
|
+
// `full-auto` lands on `auto-approve` rather than silently re-arming every
|
|
143
|
+
// prompt the user had turned off.
|
|
144
|
+
announceMode(out, session.setMode(modeFromFlags(!session.getPlanMode(), session.getAutoApprove())));
|
|
145
|
+
return { kind: "handled" };
|
|
146
|
+
}
|
|
147
|
+
if (trimmed === "/mode" || trimmed.startsWith("/mode ")) {
|
|
148
|
+
handleMode(trimmed, ctx);
|
|
149
|
+
return { kind: "handled" };
|
|
150
|
+
}
|
|
151
|
+
if (trimmed === "/model" || trimmed.startsWith("/model ")) {
|
|
152
|
+
await handleModel(trimmed, ctx);
|
|
153
|
+
return { kind: "handled" };
|
|
154
|
+
}
|
|
155
|
+
if (trimmed === "/init") {
|
|
156
|
+
handleInit(ctx);
|
|
157
|
+
return { kind: "handled" };
|
|
158
|
+
}
|
|
159
|
+
if (trimmed === "/status") {
|
|
160
|
+
handleStatus(ctx);
|
|
161
|
+
return { kind: "handled" };
|
|
162
|
+
}
|
|
163
|
+
if (trimmed === "/diff" || trimmed.startsWith("/diff ")) {
|
|
164
|
+
handleDiff(trimmed, ctx);
|
|
165
|
+
return { kind: "handled" };
|
|
166
|
+
}
|
|
167
|
+
if (trimmed === "/export" || trimmed.startsWith("/export ")) {
|
|
168
|
+
handleExport(trimmed, ctx);
|
|
169
|
+
return { kind: "handled" };
|
|
170
|
+
}
|
|
171
|
+
if (trimmed === "/context") {
|
|
172
|
+
handleContext(ctx);
|
|
173
|
+
return { kind: "handled" };
|
|
174
|
+
}
|
|
175
|
+
if (trimmed === "/usage" || trimmed.startsWith("/usage ")) {
|
|
176
|
+
handleUsage(trimmed, ctx);
|
|
177
|
+
return { kind: "handled" };
|
|
178
|
+
}
|
|
179
|
+
if (trimmed === "/jobs") {
|
|
180
|
+
handleJobsList(ctx);
|
|
181
|
+
return { kind: "handled" };
|
|
182
|
+
}
|
|
183
|
+
if (trimmed === "/logs" || trimmed.startsWith("/logs ")) {
|
|
184
|
+
handleJobLogs(trimmed, ctx);
|
|
185
|
+
return { kind: "handled" };
|
|
186
|
+
}
|
|
187
|
+
if (trimmed === "/cancel" || trimmed.startsWith("/cancel ")) {
|
|
188
|
+
await handleJobCancel(trimmed, ctx);
|
|
189
|
+
return { kind: "handled" };
|
|
190
|
+
}
|
|
191
|
+
if (trimmed === "/add-root" || trimmed.startsWith("/add-root ")) {
|
|
192
|
+
await handleAddRoot(trimmed, ctx);
|
|
193
|
+
return { kind: "handled" };
|
|
194
|
+
}
|
|
195
|
+
// Custom slash commands (C.19) — consulted AFTER builtins, so a custom
|
|
196
|
+
// command can never shadow /help, /exit, etc. A `prompt` command expands to
|
|
197
|
+
// text fed to the agent (safe); a `shell` command runs through the SAME gate
|
|
198
|
+
// + sandbox as any command (never a bypass). Unknown "/…" input falls through
|
|
199
|
+
// to a normal turn, preserving prior behaviour.
|
|
200
|
+
const slash = resolveSlash(trimmed, ctx.slashCommands);
|
|
201
|
+
if (slash.kind === "prompt")
|
|
202
|
+
return { kind: "turn", text: slash.prompt };
|
|
203
|
+
if (slash.kind === "shell") {
|
|
204
|
+
await runSlashShell(slash.spec, ctx);
|
|
205
|
+
return { kind: "handled" };
|
|
206
|
+
}
|
|
207
|
+
return { kind: "turn", text: input };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Say what the mode now IS and what it will do.
|
|
211
|
+
*
|
|
212
|
+
* The description is not decoration. The objection to an auto-approve config
|
|
213
|
+
* flag was that it disarmed the gate with nothing on screen; a runtime mode
|
|
214
|
+
* announcing itself only as "auto-approve on" would reproduce that, slower.
|
|
215
|
+
*/
|
|
216
|
+
export function announceMode(out, mode) {
|
|
217
|
+
out.print(out.theme.muted(`mode: ${MODE_LABELS[mode]} — ${modeDescription(mode)}`));
|
|
218
|
+
}
|
|
219
|
+
/** `/mode` (show + list) and `/mode <name>` (set). */
|
|
220
|
+
function handleMode(input, ctx) {
|
|
221
|
+
const { out, session } = ctx;
|
|
222
|
+
const arg = input.slice("/mode".length).trim();
|
|
223
|
+
if (arg === "") {
|
|
224
|
+
announceMode(out, session.getMode());
|
|
225
|
+
out.print(out.theme.muted(` available: ${SESSION_MODES.join(" · ")}`));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const want = parseMode(arg);
|
|
229
|
+
if (want === null) {
|
|
230
|
+
// Never a guess: a typo must not silently arm auto-approve.
|
|
231
|
+
out.print(out.theme.muted(`unknown mode "${arg}" — try ${SESSION_MODES.join(" | ")}`));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const effective = session.setMode(want);
|
|
235
|
+
if (effective !== want) {
|
|
236
|
+
// Say so rather than reporting the request as if it took.
|
|
237
|
+
out.print(out.theme.muted(`plan mode is unavailable in this session — using ${effective}`));
|
|
238
|
+
}
|
|
239
|
+
announceMode(out, effective);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Say what the model now IS, and — once the choice has actually taken — what
|
|
243
|
+
* that means. `auto` gets its explanation every time rather than only on the
|
|
244
|
+
* first set: it is the one choice whose visible value will not match what runs,
|
|
245
|
+
* and a user who reads `model: auto` and then sees `mira` in the rail has to be
|
|
246
|
+
* able to connect the two without going looking.
|
|
247
|
+
*/
|
|
248
|
+
function announceModel(out, choice) {
|
|
249
|
+
out.print(out.theme.muted(`model: ${choice} — ${describeModelChoice(choice)}`));
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* `/model` (show, or pick), `/model <name>` (set).
|
|
253
|
+
*
|
|
254
|
+
* The choice is SESSION state, never written to config — the same rule P5
|
|
255
|
+
* track 3 applied to auto-approve, and for a weaker but real version of the
|
|
256
|
+
* same reason: a setting persisted from a file re-applies itself to every
|
|
257
|
+
* future session with nothing on screen saying it did. `config.model.model`
|
|
258
|
+
* stays the durable knob and the starting value.
|
|
259
|
+
*/
|
|
260
|
+
async function handleModel(input, ctx) {
|
|
261
|
+
const { out, session } = ctx;
|
|
262
|
+
const t = out.theme;
|
|
263
|
+
const model = session.model;
|
|
264
|
+
// No tiers to choose between. Said plainly rather than by offering a menu
|
|
265
|
+
// that could not take effect — and WITHOUT naming the configured upstream
|
|
266
|
+
// model, which is the one string the U.8 gag exists to keep off screen.
|
|
267
|
+
if (!model) {
|
|
268
|
+
out.print(t.muted("this session runs on a bring-your-own provider, which has no cruxy tiers to choose between — " +
|
|
269
|
+
"set `model.model` in your config to change it"));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const arg = input.slice("/model".length).trim();
|
|
273
|
+
if (arg === "") {
|
|
274
|
+
// No argument: offer the picker when the shell can host one, else say what
|
|
275
|
+
// is set and what else is available. Both paths end at the same place — a
|
|
276
|
+
// user who cannot get a picker can still type `/model kavi`.
|
|
277
|
+
const picked = await ctx.pick?.(MODEL_CHOICES, {
|
|
278
|
+
title: "model",
|
|
279
|
+
toLabel: (choice) => `${choice} — ${describeModelChoice(choice)}`,
|
|
280
|
+
initialIndex: MODEL_CHOICES.indexOf(model.current()),
|
|
281
|
+
});
|
|
282
|
+
if (picked == null) {
|
|
283
|
+
announceModel(out, model.current());
|
|
284
|
+
for (const choice of MODEL_CHOICES) {
|
|
285
|
+
if (choice === model.current())
|
|
286
|
+
continue;
|
|
287
|
+
out.print(out.fit(t.muted(` ${choice} — ${describeModelChoice(choice)}`)));
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
applyModel(ctx, picked);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const want = parseModelChoice(arg);
|
|
295
|
+
if (want === null) {
|
|
296
|
+
// Never a nearest match: silently routing a user to a tier they did not
|
|
297
|
+
// name is exactly the substitution `ConfigRouter` refuses to make.
|
|
298
|
+
out.print(t.muted(`unknown model "${arg}" — try ${MODEL_CHOICES.join(" | ")}`));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
applyModel(ctx, want);
|
|
302
|
+
}
|
|
303
|
+
/** Adopt a picked choice and report it — including the case where nothing moved. */
|
|
304
|
+
function applyModel(ctx, choice) {
|
|
305
|
+
const { out, session } = ctx;
|
|
306
|
+
const model = session.model;
|
|
307
|
+
if (!model.set(choice)) {
|
|
308
|
+
out.print(out.theme.muted(`model is already ${choice}`));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
announceModel(out, choice);
|
|
312
|
+
// A configured routing table is quietly bypassed by an explicit pick, so say
|
|
313
|
+
// so. The user asked for one model; the table would have sent some task
|
|
314
|
+
// classes elsewhere, and finding that out from a usage breakdown later is
|
|
315
|
+
// worse than one line here.
|
|
316
|
+
if (choice !== "auto" && model.hasRoutingTable) {
|
|
317
|
+
out.print(out.theme.muted(" (overrides your configured routing table for this session)"));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* `/init` (P6 track 4) — scaffold a project `CRUXY.md` and load it immediately.
|
|
322
|
+
*
|
|
323
|
+
* NOT a reuse of `cruxy init`. That command is an onboarding flow: it acquires
|
|
324
|
+
* an API key if one is missing, offers a scaffold, and offers a first-win demo
|
|
325
|
+
* run — none of which makes sense inside a session that is already
|
|
326
|
+
* authenticated and already running. The one genuinely shared piece is the
|
|
327
|
+
* template and the write, which now live in `config/project.ts`.
|
|
328
|
+
*
|
|
329
|
+
* The `/reload` afterwards is what makes this useful rather than ceremonial:
|
|
330
|
+
* project instructions are folded into every turn's system prompt and read once
|
|
331
|
+
* at session start, so a `CRUXY.md` written mid-session would otherwise sit
|
|
332
|
+
* there doing nothing until the next launch.
|
|
333
|
+
*/
|
|
334
|
+
function handleInit(ctx) {
|
|
335
|
+
const { out, session } = ctx;
|
|
336
|
+
const t = out.theme;
|
|
337
|
+
const outcome = scaffoldProjectInstructions(session.toolContext.cwd);
|
|
338
|
+
if (outcome.kind === "exists") {
|
|
339
|
+
// Never overwrites — and says which file already covers it, since the
|
|
340
|
+
// loader honours CRUXY.md or AGENTS.md and the user may have forgotten
|
|
341
|
+
// which one this project uses.
|
|
342
|
+
out.print(t.muted("project instructions already exist here — edit them and run /reload"));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (outcome.kind === "failed") {
|
|
346
|
+
out.print(t.danger(`could not write CRUXY.md — ${outcome.message}`));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
out.print(t.muted(`wrote ${outcome.file}`));
|
|
350
|
+
session.reloadProjectInstructions();
|
|
351
|
+
out.print(t.muted("loaded into this session — it is a skeleton, so fill it in and /reload"));
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* `/status` (P6 track 4) — what this session is set up to do, in one screen.
|
|
355
|
+
*
|
|
356
|
+
* The facts exist; they are just scattered. The mode is a chip on the input row,
|
|
357
|
+
* the model and git live in rail panels the REPL does not have, the multi-root
|
|
358
|
+
* banner is printed once at startup and then gone, and whether a sandbox is
|
|
359
|
+
* actually engaged is visible nowhere after the first line of output. The two
|
|
360
|
+
* that matter most for safety are exactly the two with no persistent surface in
|
|
361
|
+
* the REPL at all.
|
|
362
|
+
*
|
|
363
|
+
* Every value is read from the object that owns it rather than re-derived, so
|
|
364
|
+
* this can never disagree with the surface it summarises.
|
|
365
|
+
*/
|
|
366
|
+
function handleStatus(ctx) {
|
|
367
|
+
const { out, session } = ctx;
|
|
368
|
+
const toolCtx = session.toolContext;
|
|
369
|
+
const config = toolCtx.config;
|
|
370
|
+
const mode = session.getMode();
|
|
371
|
+
// A real user turn is `role: "user"` with STRING content — tool results are
|
|
372
|
+
// also role "user" but carry blocks, so this counts what the human said.
|
|
373
|
+
const turns = session.messages.filter((m) => m.role === "user" && typeof m.content === "string").length;
|
|
374
|
+
const roots = toolCtx.workspace.roots().map((r) => {
|
|
375
|
+
// A synchronous probe, on an explicitly-requested command. The rail's rule
|
|
376
|
+
// (never probe on the paint path) is about frames, not about a user who
|
|
377
|
+
// just asked; there is no frame budget to blow here.
|
|
378
|
+
const git = getGitInfo(r.absPath);
|
|
379
|
+
return {
|
|
380
|
+
name: r.name,
|
|
381
|
+
path: r.absPath,
|
|
382
|
+
primary: r.primary,
|
|
383
|
+
...(git === null ? {} : { branch: git.branch, changed: git.changed }),
|
|
384
|
+
};
|
|
385
|
+
});
|
|
386
|
+
const jobs = session.jobs?.list();
|
|
387
|
+
for (const line of sessionStatusLines({
|
|
388
|
+
sessionId: session.sessionId,
|
|
389
|
+
turns,
|
|
390
|
+
mode,
|
|
391
|
+
modeDescription: modeDescription(mode),
|
|
392
|
+
...(session.model ? { model: session.model.current() } : {}),
|
|
393
|
+
provider: config.model.provider,
|
|
394
|
+
roots,
|
|
395
|
+
context: readContext(session.messages, config.context),
|
|
396
|
+
sandboxEnabled: Boolean(toolCtx.sandbox),
|
|
397
|
+
...(toolCtx.sandbox
|
|
398
|
+
? { sandboxRuntime: toolCtx.sandbox.runtimeName }
|
|
399
|
+
: {}),
|
|
400
|
+
checkpoints: Boolean(toolCtx.checkpointsActive),
|
|
401
|
+
...(jobs
|
|
402
|
+
? {
|
|
403
|
+
jobs: {
|
|
404
|
+
total: jobs.length,
|
|
405
|
+
running: jobs.filter((j) => j.status === "running").length,
|
|
406
|
+
needingApproval: jobs.filter((j) => j.pendingApproval).length,
|
|
407
|
+
},
|
|
408
|
+
}
|
|
409
|
+
: {}),
|
|
410
|
+
tools: toolCount(ctx),
|
|
411
|
+
}, out.theme)) {
|
|
412
|
+
out.print(out.fit(line));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/** Tools advertised to the model this session — the registry the loop dispatches against. */
|
|
416
|
+
function toolCount(ctx) {
|
|
417
|
+
return ctx.session.toolRegistry.list().length;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* `/diff` (P6 track 4) — what has changed on disk, without leaving the session.
|
|
421
|
+
*
|
|
422
|
+
* Defaults to the working tree against `HEAD`: mid-session, the question is
|
|
423
|
+
* almost always "what has this run actually done to my files", and uncommitted
|
|
424
|
+
* changes are that answer. `/diff <ref>` widens it to any commit-ish, which is
|
|
425
|
+
* how you see a whole branch's worth.
|
|
426
|
+
*
|
|
427
|
+
* EVERY ROOT, not just the primary (C.26). Writes fan every declared root when
|
|
428
|
+
* checkpoints are on, so a diff that showed only the primary would under-report
|
|
429
|
+
* exactly the multi-root case that is hardest to keep track of by hand.
|
|
430
|
+
*/
|
|
431
|
+
function handleDiff(input, ctx) {
|
|
432
|
+
const { out, session } = ctx;
|
|
433
|
+
const t = out.theme;
|
|
434
|
+
const ref = input.slice("/diff".length).trim() || "HEAD";
|
|
435
|
+
const roots = session.toolContext.workspace.roots();
|
|
436
|
+
let any = false;
|
|
437
|
+
for (const root of roots) {
|
|
438
|
+
if (roots.length > 1)
|
|
439
|
+
out.print(t.strong(`${root.name}`));
|
|
440
|
+
// `null` branch means not a repo (or detached) — either way there is
|
|
441
|
+
// nothing to diff against, and saying so beats an empty block.
|
|
442
|
+
if (currentBranch(root.absPath) === null && !hasChanges(root.absPath)) {
|
|
443
|
+
out.print(t.muted(" not a git repo (or nothing to compare)"));
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const diff = diffAgainst(root.absPath, ref);
|
|
447
|
+
if (diff.trim() === "") {
|
|
448
|
+
out.print(t.muted(` no changes against ${ref}`));
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
any = true;
|
|
452
|
+
for (const line of renderUnifiedDiff(diff, t, {
|
|
453
|
+
maxLines: DIFF_MAX_LINES,
|
|
454
|
+
})) {
|
|
455
|
+
out.print(out.fit(line));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (any) {
|
|
459
|
+
// A working-tree diff says nothing about what has been committed or pushed,
|
|
460
|
+
// and a reader who has just watched an agent work may assume otherwise.
|
|
461
|
+
out.print(t.muted(`(working tree vs ${ref} — commits, pushes and PRs are not shown)`));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* `/export` (P6 track 4) — write this conversation to a Markdown file.
|
|
466
|
+
*
|
|
467
|
+
* REFUSES TO OVERWRITE, always, with no `--force`. The whole value of an export
|
|
468
|
+
* is that it is a record; a command that can silently replace one is a command
|
|
469
|
+
* that can destroy the thing it exists to produce. Picking a new name is one
|
|
470
|
+
* keystroke; recovering a clobbered transcript is not possible.
|
|
471
|
+
*
|
|
472
|
+
* This writes a file WITHOUT going through the approval gate, and that is
|
|
473
|
+
* correct rather than an omission: the gate exists to put a human between the
|
|
474
|
+
* MODEL and the filesystem, and the model cannot reach slash commands — they are
|
|
475
|
+
* dispatched from the input loop, never from the tool registry. `/add-root`
|
|
476
|
+
* makes the same argument for the same reason.
|
|
477
|
+
*/
|
|
478
|
+
function handleExport(input, ctx) {
|
|
479
|
+
const { out, session } = ctx;
|
|
480
|
+
const t = out.theme;
|
|
481
|
+
const cwd = session.toolContext.cwd;
|
|
482
|
+
const arg = input.slice("/export".length).trim();
|
|
483
|
+
const target = resolve(cwd, arg === "" ? defaultExportName(session.sessionId) : arg);
|
|
484
|
+
if (existsSync(target)) {
|
|
485
|
+
out.print(t.muted(`${target} already exists — pass a different path to /export`));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const document = exportMarkdown(session.messages, {
|
|
489
|
+
sessionId: session.sessionId,
|
|
490
|
+
exportedAt: new Date().toISOString(),
|
|
491
|
+
cwd,
|
|
492
|
+
provider: session.toolContext.config.model.provider,
|
|
493
|
+
...(session.model ? { model: session.model.current() } : {}),
|
|
494
|
+
});
|
|
495
|
+
try {
|
|
496
|
+
writeFileSync(target, document, "utf8");
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
out.print(t.danger(`could not write ${target} — ${err.message}`));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
out.print(t.muted(`wrote ${target} (${session.messages.length} message${session.messages.length === 1 ? "" : "s"})`));
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* `/context` (P6 track 3) — the detail view behind the rail's two numbers.
|
|
506
|
+
*
|
|
507
|
+
* Deliberately NOT a text copy of the panel. The panel's doc comment argues
|
|
508
|
+
* that two plain numbers are the honest presentation and a progress bar is not,
|
|
509
|
+
* and reproducing it here in a wider space would be the same claim with more
|
|
510
|
+
* room to look authoritative. What this adds is the two things a 24-column strip
|
|
511
|
+
* genuinely cannot carry: where the tokens are, and what compaction would drop —
|
|
512
|
+
* the latter computed by the same `findCut` the seam uses.
|
|
513
|
+
*/
|
|
514
|
+
function handleContext(ctx) {
|
|
515
|
+
const { out, session } = ctx;
|
|
516
|
+
const report = contextReport(session.messages, session.toolContext.config.context);
|
|
517
|
+
for (const line of contextReportLines(report, out.theme)) {
|
|
518
|
+
out.print(out.fit(line));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* `/usage` (P6 track 2) — the richest data cruxy holds, and until now the only
|
|
523
|
+
* way to see it was to leave the session and run a second process.
|
|
524
|
+
*
|
|
525
|
+
* `/usage` scopes to THIS session, `/usage all` to everything retained, and
|
|
526
|
+
* `/usage last <n>` to the newest N runs. The session scope is exact rather than
|
|
527
|
+
* inferred: `cruxy usage --session` has to guess the session from the newest run
|
|
528
|
+
* in the store, because a command line has none of its own — which quietly
|
|
529
|
+
* reports someone else's runs as soon as two sessions interleave. Here the id is
|
|
530
|
+
* known, so it is used.
|
|
531
|
+
*
|
|
532
|
+
* The report is the SAME renderer `cruxy usage` prints, deliberately including
|
|
533
|
+
* the paragraph explaining what the weighted figure is and is not. That copy is
|
|
534
|
+
* load-bearing — a weighted total read as a remaining balance is a correct
|
|
535
|
+
* number that has misled someone — so it is not abbreviated for being in-session.
|
|
536
|
+
*/
|
|
537
|
+
function handleUsage(input, ctx) {
|
|
538
|
+
const { out, session } = ctx;
|
|
539
|
+
const t = out.theme;
|
|
540
|
+
const arg = input.slice("/usage".length).trim().toLowerCase();
|
|
541
|
+
let scope;
|
|
542
|
+
let scopeLabel;
|
|
543
|
+
if (arg === "" || arg === "session") {
|
|
544
|
+
scope = { sessionId: session.sessionId };
|
|
545
|
+
scopeLabel = "this session";
|
|
546
|
+
}
|
|
547
|
+
else if (arg === "all") {
|
|
548
|
+
scope = {};
|
|
549
|
+
scopeLabel = "all retained runs";
|
|
550
|
+
}
|
|
551
|
+
else if (arg.startsWith("last")) {
|
|
552
|
+
const n = Number(arg.slice("last".length).trim());
|
|
553
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
554
|
+
out.print(t.muted("usage: /usage last <n> (n a positive integer)"));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
scope = { last: n };
|
|
558
|
+
scopeLabel = `last ${runCountLabel(n)}`;
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
out.print(t.muted("usage: /usage [all | last <n>]"));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const { data, error } = loadUsage();
|
|
565
|
+
if (error) {
|
|
566
|
+
// A corrupt store is a warning here, never fatal: `cruxy usage` can exit on
|
|
567
|
+
// it, but a shell must survive a failed command and return to the prompt.
|
|
568
|
+
printCommandError(out, error);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const runs = selectRuns(data.runs, scope);
|
|
572
|
+
const enabled = session.toolContext.config.usage.enabled;
|
|
573
|
+
// Nothing on disk for this session, but the run that just finished is still
|
|
574
|
+
// in memory. Showing it beats the literal-but-useless "no usage recorded yet"
|
|
575
|
+
// — that answer is only the whole truth when tracking is ON.
|
|
576
|
+
if (runs.length === 0 && !enabled && session.lastRun) {
|
|
577
|
+
for (const line of usageReport([session.lastRun], t, {
|
|
578
|
+
scopeLabel: "the last run (not being recorded)",
|
|
579
|
+
trackingEnabled: enabled,
|
|
580
|
+
})) {
|
|
581
|
+
for (const row of line.split("\n"))
|
|
582
|
+
out.print(out.fit(row));
|
|
583
|
+
}
|
|
584
|
+
out.print(t.muted("usage tracking is off (usage.enabled = false) — nothing is saved"));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const config = session.toolContext.config.usage;
|
|
588
|
+
for (const line of usageReport(runs, t, {
|
|
589
|
+
scopeLabel,
|
|
590
|
+
trackingEnabled: enabled,
|
|
591
|
+
legacyPriceConfig: config.currency !== undefined || config.prices !== undefined,
|
|
592
|
+
})) {
|
|
593
|
+
// The report's paragraphs carry their own newlines; each row is fitted
|
|
594
|
+
// separately so the REPL truncates per LINE rather than mangling a block.
|
|
595
|
+
for (const row of line.split("\n"))
|
|
596
|
+
out.print(out.fit(row));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/** Render the background-job list (`/jobs`). */
|
|
600
|
+
function handleJobsList(ctx) {
|
|
601
|
+
const { out, session } = ctx;
|
|
602
|
+
const t = out.theme;
|
|
603
|
+
const jobs = session.jobs;
|
|
604
|
+
if (!jobs) {
|
|
605
|
+
out.print(t.muted("background jobs are disabled — enable with `cruxy config set jobs.enabled true`"));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const list = jobs.list();
|
|
609
|
+
if (list.length === 0) {
|
|
610
|
+
out.print(t.muted("no background jobs this session"));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
for (const j of list) {
|
|
614
|
+
const status = j.status === "failed" ? t.danger(j.status) : t.accent(j.status);
|
|
615
|
+
const pending = j.pendingApproval
|
|
616
|
+
? t.muted(` — needs approval: ${j.pendingApproval}`)
|
|
617
|
+
: "";
|
|
618
|
+
const err = j.error ? t.muted(` (${j.error})`) : "";
|
|
619
|
+
// Fit id-first so the job id + status always survive; the label/notes tail
|
|
620
|
+
// truncates with an honest ellipsis at narrow width (U.12).
|
|
621
|
+
out.print(out.fit(`${t.strong(j.id)} ${status} ${j.label}${pending}${err}`));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/** Print one job's log (`/logs <id>`). */
|
|
625
|
+
function handleJobLogs(input, ctx) {
|
|
626
|
+
const { out, session } = ctx;
|
|
627
|
+
const t = out.theme;
|
|
628
|
+
const jobs = session.jobs;
|
|
629
|
+
if (!jobs) {
|
|
630
|
+
out.print(t.muted("background jobs are disabled"));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const id = input.slice("/logs".length).trim();
|
|
634
|
+
if (!id) {
|
|
635
|
+
out.print(t.muted("usage: /logs <id>"));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
const log = jobs.logs(id);
|
|
640
|
+
if (log.dropped > 0) {
|
|
641
|
+
out.print(t.muted(`… ${log.dropped} earlier line(s) rolled off`));
|
|
642
|
+
}
|
|
643
|
+
for (const line of log.lines) {
|
|
644
|
+
out.print(out.fit(line.stream === "err" ? t.danger(line.text) : line.text));
|
|
645
|
+
}
|
|
646
|
+
out.print(t.muted(`(${log.status})`));
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
printCommandError(out, err);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/** Cancel a job (`/cancel <id>`). */
|
|
653
|
+
async function handleJobCancel(input, ctx) {
|
|
654
|
+
const { out, session } = ctx;
|
|
655
|
+
const jobs = session.jobs;
|
|
656
|
+
if (!jobs) {
|
|
657
|
+
out.print(out.theme.muted("background jobs are disabled"));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const id = input.slice("/cancel".length).trim();
|
|
661
|
+
if (!id) {
|
|
662
|
+
out.print(out.theme.muted("usage: /cancel <id>"));
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
const cancelled = jobs.cancel(id);
|
|
667
|
+
out.print(out.theme.muted(cancelled
|
|
668
|
+
? `cancelling ${id} (its process tree is killed; any checkpoint survives for rollback)`
|
|
669
|
+
: `${id} is already finished`));
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
printCommandError(out, err);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* `/add-root <name> <path>` (C.26 step 5) — an explicit, human-only way to
|
|
677
|
+
* declare another workspace root. This is a shell command, NOT a model tool: the
|
|
678
|
+
* model can only reach the tool registry, and nothing named `add_root` is
|
|
679
|
+
* registered there, so the allowlist argument is unchanged. It validates the
|
|
680
|
+
* addition (TTY-only; same existence/name/overlap refusal as `--root`) and, on
|
|
681
|
+
* success, tells the user to relaunch with `--root` to activate it — a
|
|
682
|
+
* mid-session hot-swap would leave the checkpoint gate + hook router (both wired
|
|
683
|
+
* from the session-start workspace) half-attributed, so activation is deferred
|
|
684
|
+
* to a clean relaunch. A newly declared root always starts untrusted.
|
|
685
|
+
*/
|
|
686
|
+
async function handleAddRoot(input, ctx) {
|
|
687
|
+
const { out, session } = ctx;
|
|
688
|
+
const parts = input
|
|
689
|
+
.slice("/add-root".length)
|
|
690
|
+
.trim()
|
|
691
|
+
.split(/\s+/)
|
|
692
|
+
.filter(Boolean);
|
|
693
|
+
if (parts.length !== 2) {
|
|
694
|
+
out.print(out.theme.muted("usage: /add-root <name> <path>"));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const [name, rootPath] = parts;
|
|
698
|
+
const toolCtx = session.toolContext;
|
|
699
|
+
try {
|
|
700
|
+
const next = await addRootToWorkspace(toolCtx.workspace, { name, path: rootPath }, { cwd: toolCtx.cwd, tty: ctx.tty });
|
|
701
|
+
const abs = next.rootByName(name).absPath;
|
|
702
|
+
out.print(out.theme.muted(`validated root "${name}" (${abs}). relaunch with \`--root ${name}=${rootPath}\` to activate it — ` +
|
|
703
|
+
`it starts untrusted (its hooks and project memory stay inert until you run \`cruxy hooks/memory trust\`).`));
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
printCommandError(out, err);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Run a shell-bound custom slash command (C.19) through the SAME gated +
|
|
711
|
+
* sandboxed path as `run_command` (`runGatedShell` over the session's tool
|
|
712
|
+
* context) — never a privileged route. Prints the result like a shell run; a
|
|
713
|
+
* gate rejection or a thrown coded error (e.g. sandbox) is surfaced, not fatal.
|
|
714
|
+
*/
|
|
715
|
+
async function runSlashShell(spec, ctx) {
|
|
716
|
+
const { out, session } = ctx;
|
|
717
|
+
const t = out.theme;
|
|
718
|
+
out.print(t.muted(`running /${spec.name}${t.glyph.ellipsis}`));
|
|
719
|
+
try {
|
|
720
|
+
const outcome = await runGatedShell(spec.command ?? "", session.toolContext);
|
|
721
|
+
if (!outcome.approved) {
|
|
722
|
+
out.print(t.muted(outcome.rejection ?? "command denied by the user"));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const e = outcome.exec;
|
|
726
|
+
if (e.timedOut) {
|
|
727
|
+
out.print(t.warning("timed out"));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (e.spawnError !== undefined) {
|
|
731
|
+
out.print(t.danger(e.spawnError));
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
for (const line of `exit code ${e.exitCode ?? e.signal ?? "unknown"}\n${e.output}`.split("\n")) {
|
|
735
|
+
out.print(line);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
catch (err) {
|
|
739
|
+
printCommandError(out, err);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Render a non-fatal error inline (classified + formatted, the same 4-part
|
|
744
|
+
* shape as the fatal boundary) and return to the prompt — a shell must survive
|
|
745
|
+
* a failed command rather than exit.
|
|
746
|
+
*/
|
|
747
|
+
export function printCommandError(out, err) {
|
|
748
|
+
const cruxy = fromUnknown(err);
|
|
749
|
+
const text = formatError(cruxy, {
|
|
750
|
+
verbose: isVerbose(),
|
|
751
|
+
color: out.theme.color,
|
|
752
|
+
});
|
|
753
|
+
for (const line of text.split("\n"))
|
|
754
|
+
out.print(line);
|
|
755
|
+
}
|