@cruxy/cli 1.2.0 → 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 +41 -2
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +185 -72
- 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 +21 -3
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/store.js +7 -1
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +73 -4
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +50 -4
- package/package.json +2 -2
- package/dist/usage/cost.js +0 -29
|
@@ -3,13 +3,20 @@ import { loadConfig } from "../../config/index.js";
|
|
|
3
3
|
import { shouldUseColor, usageError } from "../../errors/index.js";
|
|
4
4
|
import { themeForColor } from "../../theme/index.js";
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
|
-
import { loadUsage,
|
|
6
|
+
import { loadUsage, runCountLabel, selectRuns, usageReport, } from "../../usage/index.js";
|
|
7
7
|
/**
|
|
8
|
-
* `cruxy usage` (C.22) — show LOCAL token usage
|
|
8
|
+
* `cruxy usage` (C.22) — show LOCAL token usage, weighted tokens and cost, read
|
|
9
9
|
* back from `~/.cruxy/usage`. Read-only and local: it prints your own accounting
|
|
10
|
-
* and transmits nothing
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* and transmits nothing (this command makes no request of its own; it only reads
|
|
11
|
+
* figures the gateway already sent on chat responses). Every figure is real — a
|
|
12
|
+
* request the provider never reported usage for is shown as unreported, never a
|
|
13
|
+
* fabricated number.
|
|
14
|
+
*
|
|
15
|
+
* The report itself lives in `usage/report.ts` (P6 track 2). This file is now
|
|
16
|
+
* only the commander half: flags in, scope out, lines printed. `/usage` renders
|
|
17
|
+
* the SAME report from inside a session, so the two surfaces cannot drift — and
|
|
18
|
+
* in particular cannot end up disagreeing about what the weighted figure means,
|
|
19
|
+
* which is the part that has to be said identically every time.
|
|
13
20
|
*/
|
|
14
21
|
export function usageCommand() {
|
|
15
22
|
return new Command("usage")
|
|
@@ -24,44 +31,25 @@ export function usageCommand() {
|
|
|
24
31
|
const { data, error } = loadUsage();
|
|
25
32
|
if (error)
|
|
26
33
|
throw error;
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
prices: config.usage.prices,
|
|
37
|
-
currency: config.usage.currency,
|
|
38
|
-
});
|
|
39
|
-
const scopeLabel = opts.session
|
|
40
|
-
? "current session"
|
|
41
|
-
: opts.last
|
|
42
|
-
? `last ${scoped.length} run${scoped.length === 1 ? "" : "s"}`
|
|
43
|
-
: `all ${scoped.length} run${scoped.length === 1 ? "" : "s"}`;
|
|
44
|
-
logger.print(t.heading(`usage — ${scopeLabel}`));
|
|
45
|
-
logger.print(renderSummary(summary, t));
|
|
46
|
-
// State when NO price is configured, so an absent cost never reads as $0.
|
|
47
|
-
// Self-contained: set a number, see cost — copy-pasteable commands, no
|
|
48
|
-
// docs lookup and no jargon needed.
|
|
49
|
-
if (!summary.priced) {
|
|
50
|
-
logger.print(t.muted([
|
|
51
|
-
"cost omitted — no prices set. To see cost, set your per-million-token",
|
|
52
|
-
"rates for each tier (kavi, vaani, mira):",
|
|
53
|
-
" cruxy config set usage.prices.kavi.input 0.5",
|
|
54
|
-
" cruxy config set usage.prices.kavi.output 1.5",
|
|
55
|
-
].join("\n")));
|
|
34
|
+
const scope = resolveScope(data.runs, opts);
|
|
35
|
+
const scoped = selectRuns(data.runs, scope);
|
|
36
|
+
for (const line of usageReport(scoped, t, {
|
|
37
|
+
scopeLabel: scopeLabel(opts, scoped.length),
|
|
38
|
+
trackingEnabled: config.usage.enabled,
|
|
39
|
+
legacyPriceConfig: config.usage.currency !== undefined ||
|
|
40
|
+
config.usage.prices !== undefined,
|
|
41
|
+
})) {
|
|
42
|
+
logger.print(line);
|
|
56
43
|
}
|
|
57
44
|
});
|
|
58
45
|
}
|
|
59
46
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
47
|
+
* Turn the flags into a {@link UsageScope}. `--session` is INFERRED here — from
|
|
48
|
+
* the newest run's session id — because a command line has no session of its
|
|
49
|
+
* own to ask. (`/usage` inside a session does not infer: it knows its id.)
|
|
50
|
+
* `--session` and `--last` are mutually exclusive.
|
|
63
51
|
*/
|
|
64
|
-
function
|
|
52
|
+
function resolveScope(runs, opts) {
|
|
65
53
|
if (opts.session && opts.last !== undefined) {
|
|
66
54
|
throw usageError("pass only one of --session or --last", [
|
|
67
55
|
"cruxy usage --session (the most recent session)",
|
|
@@ -73,16 +61,28 @@ function selectRuns(runs, opts) {
|
|
|
73
61
|
if (!Number.isInteger(n) || n <= 0) {
|
|
74
62
|
throw usageError(`--last must be a positive integer (got "${opts.last}")`);
|
|
75
63
|
}
|
|
76
|
-
return
|
|
64
|
+
return { last: n };
|
|
77
65
|
}
|
|
78
66
|
if (opts.session) {
|
|
79
67
|
const latest = runs[runs.length - 1];
|
|
68
|
+
// Nothing stored: an empty scope over an empty store is already empty, and
|
|
69
|
+
// the report renders the "nothing yet" answer.
|
|
80
70
|
if (!latest)
|
|
81
|
-
return
|
|
82
|
-
// Runs without a session id can't be grouped; scope to the latest
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return
|
|
71
|
+
return {};
|
|
72
|
+
// Runs without a session id can't be grouped; scope to the latest alone by
|
|
73
|
+
// asking for the last 1 rather than for an `undefined` session, which would
|
|
74
|
+
// match every other ungrouped run in the store.
|
|
75
|
+
return latest.sessionId === undefined
|
|
76
|
+
? { last: 1 }
|
|
77
|
+
: { sessionId: latest.sessionId };
|
|
86
78
|
}
|
|
87
|
-
return
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
/** The heading's scope phrase, matching the flag the user actually passed. */
|
|
82
|
+
function scopeLabel(opts, count) {
|
|
83
|
+
if (opts.session)
|
|
84
|
+
return "current session";
|
|
85
|
+
if (opts.last)
|
|
86
|
+
return `last ${runCountLabel(count)}`;
|
|
87
|
+
return `all ${runCountLabel(count)}`;
|
|
88
88
|
}
|
package/dist/cli/onboard.js
CHANGED
|
@@ -2,6 +2,7 @@ import { resolveApiKey } from "../config/index.js";
|
|
|
2
2
|
import { createDefaultDeps, defaultOnboardingIO, isFirstRun, runOnboarding, } from "../onboarding/index.js";
|
|
3
3
|
import { createRenderer } from "../render/index.js";
|
|
4
4
|
import { sessionWorkspace } from "../workspace/index.js";
|
|
5
|
+
import { DEFAULT_MODE } from "../agent/index.js";
|
|
5
6
|
import { buildAgentSession } from "./session-factory.js";
|
|
6
7
|
/**
|
|
7
8
|
* CLI-layer glue between the entry points and the onboarding module (U.6). Keeps
|
|
@@ -25,7 +26,7 @@ export async function runFirstWinTask(config, cwd, prompt) {
|
|
|
25
26
|
const renderer = createRenderer();
|
|
26
27
|
// The onboarding first-win is inherently single-root (it runs before any
|
|
27
28
|
// `--root` is parsed), so it acts over a trivial workspace on its cwd.
|
|
28
|
-
const session = buildAgentSession(config, apiKey, sessionWorkspace(cwd), true,
|
|
29
|
+
const session = buildAgentSession(config, apiKey, sessionWorkspace(cwd), true, DEFAULT_MODE, renderer);
|
|
29
30
|
try {
|
|
30
31
|
await session.send(prompt, renderer);
|
|
31
32
|
}
|
package/dist/cli/program.js
CHANGED
|
@@ -4,7 +4,10 @@ import { PRODUCT_MASTHEAD } from "../brand/index.js";
|
|
|
4
4
|
import { logger } from "../utils/logger.js";
|
|
5
5
|
import { shouldUseColor, usageError } from "../errors/index.js";
|
|
6
6
|
import { themeForColor } from "../theme/index.js";
|
|
7
|
-
import {
|
|
7
|
+
import { detectCapabilities } from "../render/index.js";
|
|
8
|
+
import { supportsTui } from "../tui/index.js";
|
|
9
|
+
import { executeRun, runCommand } from "./commands/run.js";
|
|
10
|
+
import { suggestCommand } from "./suggest.js";
|
|
8
11
|
import { configCommand } from "./commands/config.js";
|
|
9
12
|
import { indexCommand } from "./commands/index.js";
|
|
10
13
|
import { skillsCommand } from "./commands/skills.js";
|
|
@@ -28,7 +31,8 @@ export function buildProgram() {
|
|
|
28
31
|
.version(APP_VERSION, "-v, --version", "print the cruxy version")
|
|
29
32
|
.option("-c, --config <path>", "use a specific config file")
|
|
30
33
|
.option("--log-level <level>", "debug | info | warn | error | silent")
|
|
31
|
-
.option("--verbose", "shorthand for --log-level debug")
|
|
34
|
+
.option("--verbose", "shorthand for --log-level debug")
|
|
35
|
+
.option("--resume [id]", "resume a saved session by id; omit the id to pick from recent sessions");
|
|
32
36
|
// Apply global options as early as possible.
|
|
33
37
|
program.hook("preAction", (thisCommand) => {
|
|
34
38
|
const opts = thisCommand.opts();
|
|
@@ -51,17 +55,40 @@ export function buildProgram() {
|
|
|
51
55
|
program.addCommand(memoryCommand());
|
|
52
56
|
program.addCommand(usageCommand());
|
|
53
57
|
program.addCommand(mcpCommand());
|
|
54
|
-
// Default action: bare `cruxy`
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// Default action: bare `cruxy` opens the TUI; `cruxy <message>` opens it and
|
|
59
|
+
// runs that message as the first turn. Operands reach here only when they
|
|
60
|
+
// matched no subcommand, so a bare token is a MESSAGE by default — only a
|
|
61
|
+
// near-miss for a real command name is refused (see `suggestCommand`).
|
|
62
|
+
program.arguments("[message...]");
|
|
63
|
+
program.action(async (messageParts, opts, command) => {
|
|
64
|
+
const operands = messageParts ?? command.args;
|
|
65
|
+
if (operands.length === 1) {
|
|
66
|
+
const known = command.commands.map((c) => c.name());
|
|
67
|
+
const meant = suggestCommand(operands[0], known);
|
|
68
|
+
if (meant !== null) {
|
|
69
|
+
throw usageError(`unknown command: ${operands[0]}`, [
|
|
70
|
+
`did you mean \`cruxy ${meant}\`?`,
|
|
71
|
+
"run `cruxy --help` to see available commands",
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
62
74
|
}
|
|
63
|
-
//
|
|
64
|
-
//
|
|
75
|
+
// Nothing to do and nowhere to do it: no message, and an environment that
|
|
76
|
+
// cannot host the TUI (a pipe, CI, TERM=dumb, a screen reader). Print
|
|
77
|
+
// guidance and exit 0 — a bare `cruxy` in a script has always succeeded,
|
|
78
|
+
// and a foundation change must not turn that into a failure.
|
|
79
|
+
//
|
|
80
|
+
// `--resume` is excluded: it names something specific to do, so it must
|
|
81
|
+
// reach the run pipeline (and fail loud on a bad id) rather than being
|
|
82
|
+
// answered with a banner.
|
|
83
|
+
if (operands.length === 0 &&
|
|
84
|
+
opts.resume === undefined &&
|
|
85
|
+
!supportsTui(detectCapabilities())) {
|
|
86
|
+
printGuidance();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// First-run with no key (and a TTY) → guided setup, with the first-win
|
|
90
|
+
// demo. `executeRun` runs the same check for the keyed path; doing it here
|
|
91
|
+
// first keeps a brand-new user out of a TUI they cannot yet use.
|
|
65
92
|
const { config } = loadConfig();
|
|
66
93
|
const onboarding = maybeRunOnboarding(config, {
|
|
67
94
|
ttyInteractive: Boolean(process.stdin.isTTY),
|
|
@@ -72,12 +99,15 @@ export function buildProgram() {
|
|
|
72
99
|
await onboarding;
|
|
73
100
|
return;
|
|
74
101
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
// The TUI is a REQUEST: on a pipe, a screen reader, or TERM=dumb the
|
|
103
|
+
// factory declines it and this is exactly the pre-existing REPL/one-shot
|
|
104
|
+
// path, with the pre-existing renderers.
|
|
105
|
+
await executeRun(operands, {
|
|
106
|
+
root: [],
|
|
107
|
+
tui: true,
|
|
108
|
+
commandName: "cruxy",
|
|
109
|
+
...(opts.resume !== undefined ? { resume: opts.resume } : {}),
|
|
110
|
+
});
|
|
81
111
|
});
|
|
82
112
|
// Throw CommanderError instead of calling process.exit, and suppress
|
|
83
113
|
// Commander's own "error:" line — so parse errors (unknown command/option,
|
|
@@ -87,6 +117,18 @@ export function buildProgram() {
|
|
|
87
117
|
routeErrorsToBoundary(program);
|
|
88
118
|
return program;
|
|
89
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* The banner for an environment that cannot open the TUI and was given nothing
|
|
122
|
+
* to run. Exits 0 — this is guidance, not a failure.
|
|
123
|
+
*/
|
|
124
|
+
function printGuidance() {
|
|
125
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
126
|
+
logger.print(t.accent(`${APP_NAME} v${APP_VERSION}`));
|
|
127
|
+
logger.print(t.muted("an agentic coding CLI\n"));
|
|
128
|
+
logger.print(`Start a session: ${t.strong("cruxy")} ${t.muted("(needs a terminal)")}`);
|
|
129
|
+
logger.print(`Run one task: ${t.strong('cruxy "<task>"')}`);
|
|
130
|
+
logger.print(`See all commands: ${t.strong("cruxy --help")}`);
|
|
131
|
+
}
|
|
90
132
|
/** Recursively make a command (and its subcommands) throw on error, silently. */
|
|
91
133
|
function routeErrorsToBoundary(command) {
|
|
92
134
|
command.exitOverride();
|
package/dist/cli/repl.js
CHANGED
|
@@ -1,12 +1,38 @@
|
|
|
1
1
|
import readline from "node:readline";
|
|
2
2
|
import { makeReplCompleter } from "../components/index.js";
|
|
3
|
-
import { resolveSlash } from "../hooks/index.js";
|
|
4
|
-
import { runGatedShell } from "../tools/shell/exec.js";
|
|
5
|
-
import { addRootToWorkspace } from "../workspace/index.js";
|
|
6
3
|
import { themeForColor } from "../theme/index.js";
|
|
7
|
-
import {
|
|
4
|
+
import { shouldUseColor } from "../errors/index.js";
|
|
8
5
|
import { createRenderer, fit, resolveColumns, } from "../render/index.js";
|
|
6
|
+
import { SHARED_COMMANDS, SHARED_HELP, dispatchCommand, printCommandError, } from "./session-commands.js";
|
|
7
|
+
import { defaultComponentIO, selectList } from "../components/index.js";
|
|
9
8
|
import { logger } from "../utils/logger.js";
|
|
9
|
+
/**
|
|
10
|
+
* The REPL's picker (P6 track 1): the standalone stderr frame every U.7
|
|
11
|
+
* component builds by default, over its own raw-mode reader.
|
|
12
|
+
*
|
|
13
|
+
* Safe here specifically because of how {@link readLine} works — the readline
|
|
14
|
+
* interface is created per line and CLOSED before dispatch runs, so nothing else
|
|
15
|
+
* holds stdin at this point. That is the same property the approval prompt has
|
|
16
|
+
* always relied on; this is not a new claim on the terminal.
|
|
17
|
+
*
|
|
18
|
+
* Declines on a non-interactive stdin rather than letting `resolveNonInteractive`
|
|
19
|
+
* throw: `/model` with no argument is a request to browse, and a piped session
|
|
20
|
+
* answering it with a fatal `CRUXY_E_INTERACTIVE_REQUIRED` would kill a REPL over
|
|
21
|
+
* a command that has a perfectly good text form.
|
|
22
|
+
*/
|
|
23
|
+
const replPicker = async (items, opts) => {
|
|
24
|
+
const io = defaultComponentIO();
|
|
25
|
+
if (!io.interactive)
|
|
26
|
+
return null;
|
|
27
|
+
const result = await selectList(items, {
|
|
28
|
+
title: opts.title,
|
|
29
|
+
toLabel: opts.toLabel,
|
|
30
|
+
...(opts.initialIndex === undefined
|
|
31
|
+
? {}
|
|
32
|
+
: { initialIndex: opts.initialIndex }),
|
|
33
|
+
}, io);
|
|
34
|
+
return result.kind === "selected" ? result.value : null;
|
|
35
|
+
};
|
|
10
36
|
/** The REPL prompts on stdout; its chrome resolves against stdout's color. */
|
|
11
37
|
const theme = themeForColor(shouldUseColor(process.stdout));
|
|
12
38
|
/** Fit a committed REPL line to stdout's current width (U.12), id/status-first. */
|
|
@@ -15,32 +41,26 @@ function fitOut(line) {
|
|
|
15
41
|
}
|
|
16
42
|
const PROMPT = `${theme.accent("cruxy")} ${theme.muted(theme.glyph.caret)} `;
|
|
17
43
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
44
|
+
* Where the REPL's command output goes: straight to the logger, fitted to
|
|
45
|
+
* stdout's width. The TUI supplies its own; everything else about a command is
|
|
46
|
+
* shared (see `session-commands.ts`).
|
|
47
|
+
*/
|
|
48
|
+
const replOutput = {
|
|
49
|
+
print: (line = "") => logger.print(line),
|
|
50
|
+
theme,
|
|
51
|
+
fit: fitOut,
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The REPL's slash commands — the autocomplete candidate set (U.7). Derived from
|
|
55
|
+
* the shared list so a command added there is completable here without a second
|
|
56
|
+
* edit; this shell adds nothing of its own.
|
|
20
57
|
*/
|
|
21
|
-
export const REPL_COMMANDS =
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
"/jobs",
|
|
28
|
-
"/logs",
|
|
29
|
-
"/cancel",
|
|
30
|
-
"/exit",
|
|
31
|
-
"/quit",
|
|
32
|
-
];
|
|
33
|
-
const HELP = `Commands:
|
|
34
|
-
/help show this help
|
|
35
|
-
/clear clear the conversation history (keep the session)
|
|
36
|
-
/compact summarize older history to free up context now
|
|
37
|
-
/reload re-read project instructions (CRUXY.md)
|
|
38
|
-
/plan toggle plan mode (propose a plan before executing)
|
|
39
|
-
/jobs list background jobs and their status
|
|
40
|
-
/logs <id> show a background job's log
|
|
41
|
-
/cancel <id> cancel a background job
|
|
42
|
-
/exit, /quit leave cruxy
|
|
43
|
-
Ctrl+D leave cruxy`;
|
|
58
|
+
export const REPL_COMMANDS = SHARED_COMMANDS;
|
|
59
|
+
const HELP = [
|
|
60
|
+
"Commands:",
|
|
61
|
+
...SHARED_HELP,
|
|
62
|
+
" Ctrl+D leave cruxy",
|
|
63
|
+
].join("\n");
|
|
44
64
|
const defaultIO = () => ({
|
|
45
65
|
input: process.stdin,
|
|
46
66
|
output: process.stdout,
|
|
@@ -83,86 +103,11 @@ function readLine(io, prompt) {
|
|
|
83
103
|
});
|
|
84
104
|
});
|
|
85
105
|
}
|
|
86
|
-
/**
|
|
87
|
-
* Run a shell-bound custom slash command (C.19) through the SAME gated +
|
|
88
|
-
* sandboxed path as `run_command` (`runGatedShell` over the session's tool
|
|
89
|
-
* context) — never a privileged route. Prints the result like a shell run; a
|
|
90
|
-
* gate rejection or a thrown coded error (e.g. sandbox) is surfaced, not fatal.
|
|
91
|
-
*/
|
|
92
|
-
async function runSlashShell(spec, session) {
|
|
93
|
-
logger.print(theme.muted(`running /${spec.name}${theme.glyph.ellipsis}`));
|
|
94
|
-
try {
|
|
95
|
-
const outcome = await runGatedShell(spec.command ?? "", session.toolContext);
|
|
96
|
-
if (!outcome.approved) {
|
|
97
|
-
logger.print(theme.muted(outcome.rejection ?? "command denied by the user"));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
const e = outcome.exec;
|
|
101
|
-
if (e.timedOut) {
|
|
102
|
-
logger.print(theme.warning("timed out"));
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
if (e.spawnError !== undefined) {
|
|
106
|
-
logger.print(theme.danger(e.spawnError));
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
logger.print(`exit code ${e.exitCode ?? e.signal ?? "unknown"}\n${e.output}`);
|
|
110
|
-
}
|
|
111
|
-
catch (err) {
|
|
112
|
-
printReplError(err);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Render a non-fatal error inline (classified + formatted, the same 4-part
|
|
117
|
-
* shape as the fatal boundary) and return to the prompt — the REPL must survive
|
|
118
|
-
* a failed turn rather than exit.
|
|
119
|
-
*/
|
|
120
|
-
function printReplError(err) {
|
|
121
|
-
const cruxy = fromUnknown(err);
|
|
122
|
-
logger.print(formatError(cruxy, {
|
|
123
|
-
verbose: isVerbose(),
|
|
124
|
-
color: shouldUseColor(process.stdout),
|
|
125
|
-
}));
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* `/add-root <name> <path>` (C.26 step 5) — an explicit, human-only way to
|
|
129
|
-
* declare another workspace root. This is a REPL command, NOT a model tool: the
|
|
130
|
-
* model can only reach the tool registry, and nothing named `add_root` is
|
|
131
|
-
* registered there, so the allowlist argument is unchanged. It validates the
|
|
132
|
-
* addition (TTY-only; same existence/name/overlap refusal as `--root`) and, on
|
|
133
|
-
* success, tells the user to relaunch with `--root` to activate it — a
|
|
134
|
-
* mid-session hot-swap would leave the checkpoint gate + hook router (both wired
|
|
135
|
-
* from the session-start workspace) half-attributed, so activation is deferred
|
|
136
|
-
* to a clean relaunch. A newly declared root always starts untrusted.
|
|
137
|
-
*/
|
|
138
|
-
async function handleAddRoot(input, session) {
|
|
139
|
-
const parts = input
|
|
140
|
-
.slice("/add-root".length)
|
|
141
|
-
.trim()
|
|
142
|
-
.split(/\s+/)
|
|
143
|
-
.filter(Boolean);
|
|
144
|
-
if (parts.length !== 2) {
|
|
145
|
-
logger.print(theme.muted("usage: /add-root <name> <path>"));
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
const [name, rootPath] = parts;
|
|
149
|
-
const ctx = session.toolContext;
|
|
150
|
-
const current = ctx.workspace;
|
|
151
|
-
try {
|
|
152
|
-
const next = await addRootToWorkspace(current, { name, path: rootPath }, { cwd: ctx.cwd, tty: Boolean(process.stdin.isTTY) });
|
|
153
|
-
const abs = next.rootByName(name).absPath;
|
|
154
|
-
logger.print(theme.muted(`validated root "${name}" (${abs}). relaunch with \`--root ${name}=${rootPath}\` to activate it — ` +
|
|
155
|
-
`it starts untrusted (its hooks and project memory stay inert until you run \`cruxy hooks/memory trust\`).`));
|
|
156
|
-
}
|
|
157
|
-
catch (err) {
|
|
158
|
-
printReplError(err);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
106
|
/**
|
|
162
107
|
* Service any background-job approvals that piled up (C.28) — the auto-surface
|
|
163
108
|
* point. Called when the foreground is idle (between turns), so a paused job's
|
|
164
|
-
* gated action is decided through the SAME U.3 prompt as a foreground one, one
|
|
165
|
-
* a time. No pending → a no-op (nothing printed). The job resumes on approval.
|
|
109
|
+
* gated action is decided through the SAME U.3 prompt as a foreground one, one
|
|
110
|
+
* at a time. No pending → a no-op (nothing printed). The job resumes on approval.
|
|
166
111
|
*/
|
|
167
112
|
async function drainJobApprovals(session) {
|
|
168
113
|
const jobs = session.jobs;
|
|
@@ -171,78 +116,6 @@ async function drainJobApprovals(session) {
|
|
|
171
116
|
logger.print(theme.muted(`\n${theme.glyph.bullet} a background job needs your approval:`));
|
|
172
117
|
await jobs.serviceApprovals();
|
|
173
118
|
}
|
|
174
|
-
/** Render the background-job list (`/jobs`). */
|
|
175
|
-
function handleJobsList(session) {
|
|
176
|
-
const jobs = session.jobs;
|
|
177
|
-
if (!jobs) {
|
|
178
|
-
logger.print(theme.muted("background jobs are disabled — enable with `cruxy config set jobs.enabled true`"));
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
const list = jobs.list();
|
|
182
|
-
if (list.length === 0) {
|
|
183
|
-
logger.print(theme.muted("no background jobs this session"));
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
for (const j of list) {
|
|
187
|
-
const status = j.status === "failed" ? theme.danger(j.status) : theme.accent(j.status);
|
|
188
|
-
const pending = j.pendingApproval
|
|
189
|
-
? theme.muted(` — needs approval: ${j.pendingApproval}`)
|
|
190
|
-
: "";
|
|
191
|
-
const err = j.error ? theme.muted(` (${j.error})`) : "";
|
|
192
|
-
// Fit id-first so the job id + status always survive; the label/notes tail
|
|
193
|
-
// truncates with an honest ellipsis at narrow width (U.12).
|
|
194
|
-
logger.print(fitOut(`${theme.strong(j.id)} ${status} ${j.label}${pending}${err}`));
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
/** Print one job's log (`/logs <id>`). */
|
|
198
|
-
function handleJobLogs(input, session) {
|
|
199
|
-
const jobs = session.jobs;
|
|
200
|
-
if (!jobs) {
|
|
201
|
-
logger.print(theme.muted("background jobs are disabled"));
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
const id = input.slice("/logs".length).trim();
|
|
205
|
-
if (!id) {
|
|
206
|
-
logger.print(theme.muted("usage: /logs <id>"));
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
try {
|
|
210
|
-
const log = jobs.logs(id);
|
|
211
|
-
if (log.dropped > 0) {
|
|
212
|
-
logger.print(theme.muted(`… ${log.dropped} earlier line(s) rolled off`));
|
|
213
|
-
}
|
|
214
|
-
for (const line of log.lines) {
|
|
215
|
-
const text = line.stream === "err" ? theme.danger(line.text) : line.text;
|
|
216
|
-
logger.print(fitOut(text));
|
|
217
|
-
}
|
|
218
|
-
logger.print(theme.muted(`(${log.status})`));
|
|
219
|
-
}
|
|
220
|
-
catch (err) {
|
|
221
|
-
printReplError(err);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
/** Cancel a job (`/cancel <id>`). */
|
|
225
|
-
async function handleJobCancel(input, session) {
|
|
226
|
-
const jobs = session.jobs;
|
|
227
|
-
if (!jobs) {
|
|
228
|
-
logger.print(theme.muted("background jobs are disabled"));
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
const id = input.slice("/cancel".length).trim();
|
|
232
|
-
if (!id) {
|
|
233
|
-
logger.print(theme.muted("usage: /cancel <id>"));
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
try {
|
|
237
|
-
const cancelled = jobs.cancel(id);
|
|
238
|
-
logger.print(theme.muted(cancelled
|
|
239
|
-
? `cancelling ${id} (its process tree is killed; any checkpoint survives for rollback)`
|
|
240
|
-
: `${id} is already finished`));
|
|
241
|
-
}
|
|
242
|
-
catch (err) {
|
|
243
|
-
printReplError(err);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
119
|
/**
|
|
247
120
|
* Drive an interactive multi-turn session: prompt, read a line, dispatch slash
|
|
248
121
|
* commands or run a turn, repeat. Assistant text and tool-call progress stream
|
|
@@ -279,80 +152,25 @@ async function replLoop(session, io, renderer, checkpoints, slashCommands = [])
|
|
|
279
152
|
const trimmed = line.trim();
|
|
280
153
|
if (trimmed === "")
|
|
281
154
|
continue; // empty line → reprompt, no model call
|
|
282
|
-
if (trimmed === "/exit" || trimmed === "/quit") {
|
|
283
|
-
logger.print(theme.muted("bye"));
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
if (trimmed === "/clear") {
|
|
287
|
-
session.clear();
|
|
288
|
-
logger.print(theme.muted("history cleared"));
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
if (trimmed === "/compact") {
|
|
292
|
-
try {
|
|
293
|
-
const n = await session.compact();
|
|
294
|
-
logger.print(theme.muted(n ? `compacted ${n} older messages` : "nothing to compact yet"));
|
|
295
|
-
}
|
|
296
|
-
catch (err) {
|
|
297
|
-
printReplError(err);
|
|
298
|
-
}
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
if (trimmed === "/reload") {
|
|
302
|
-
const loaded = session.reloadProjectInstructions();
|
|
303
|
-
logger.print(theme.muted(loaded
|
|
304
|
-
? "reloaded project instructions (CRUXY.md)"
|
|
305
|
-
: "no project instructions found"));
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
if (trimmed === "/plan") {
|
|
309
|
-
session.setPlanMode(!session.getPlanMode());
|
|
310
|
-
const on = session.getPlanMode();
|
|
311
|
-
logger.print(theme.muted(on
|
|
312
|
-
? "plan mode on — the next prompt proposes a plan for approval"
|
|
313
|
-
: "plan mode off"));
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
155
|
if (trimmed === "/help") {
|
|
317
156
|
logger.print(HELP);
|
|
318
157
|
continue;
|
|
319
158
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (trimmed === "/add-root" || trimmed.startsWith("/add-root ")) {
|
|
333
|
-
await handleAddRoot(trimmed, session);
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
// Custom slash commands (C.19) — consulted AFTER builtins, so a custom
|
|
337
|
-
// command can never shadow /help, /exit, etc. A `prompt` command expands to
|
|
338
|
-
// text fed to the agent (safe); a `shell` command runs through the SAME
|
|
339
|
-
// gate + sandbox as any command (never a bypass). Unknown "/…" input falls
|
|
340
|
-
// through to a normal turn, preserving prior behavior.
|
|
341
|
-
const slash = resolveSlash(trimmed, slashCommands);
|
|
342
|
-
if (slash.kind === "prompt") {
|
|
343
|
-
try {
|
|
344
|
-
checkpoints?.beginRun(slash.prompt);
|
|
345
|
-
await session.send(slash.prompt, renderer);
|
|
346
|
-
}
|
|
347
|
-
catch (err) {
|
|
348
|
-
printReplError(err);
|
|
349
|
-
}
|
|
350
|
-
continue;
|
|
159
|
+
// Every other command is the SHARED implementation (P5 track 5) — the same
|
|
160
|
+
// one the TUI dispatches through, so the two shells cannot drift again.
|
|
161
|
+
const outcome = await dispatchCommand(line, {
|
|
162
|
+
session,
|
|
163
|
+
out: replOutput,
|
|
164
|
+
slashCommands,
|
|
165
|
+
tty: Boolean(process.stdin.isTTY),
|
|
166
|
+
pick: replPicker,
|
|
167
|
+
});
|
|
168
|
+
if (outcome.kind === "exit") {
|
|
169
|
+
logger.print(theme.muted("bye"));
|
|
170
|
+
return;
|
|
351
171
|
}
|
|
352
|
-
if (
|
|
353
|
-
await runSlashShell(slash.spec, session);
|
|
172
|
+
if (outcome.kind === "handled")
|
|
354
173
|
continue;
|
|
355
|
-
}
|
|
356
174
|
// A real turn. Assistant text streams through the renderer delta by delta
|
|
357
175
|
// (leading blank lines trimmed, code fences highlighted); the agent loop
|
|
358
176
|
// closes each segment with one newline, so the next prompt lands on its own
|
|
@@ -361,11 +179,11 @@ async function replLoop(session, io, renderer, checkpoints, slashCommands = [])
|
|
|
361
179
|
try {
|
|
362
180
|
// Each REPL turn is its own undo unit (C.32): a fresh checkpoint latch,
|
|
363
181
|
// so `cruxy rollback` reverts exactly one turn's mutations.
|
|
364
|
-
checkpoints?.beginRun(
|
|
365
|
-
await session.send(
|
|
182
|
+
checkpoints?.beginRun(outcome.text);
|
|
183
|
+
await session.send(outcome.text, renderer);
|
|
366
184
|
}
|
|
367
185
|
catch (err) {
|
|
368
|
-
|
|
186
|
+
printCommandError(replOutput, err);
|
|
369
187
|
}
|
|
370
188
|
}
|
|
371
189
|
}
|