@cruxy/cli 0.22.1 → 0.24.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/loop.d.ts +21 -2
- package/dist/agent/loop.js +21 -5
- package/dist/approval/classify.js +18 -0
- package/dist/approval/index.d.ts +1 -0
- package/dist/approval/index.js +1 -0
- package/dist/approval/mutex.d.ts +45 -0
- package/dist/approval/mutex.js +57 -0
- package/dist/approval/prompt.js +11 -0
- package/dist/checkpoint/gate.d.ts +65 -0
- package/dist/checkpoint/gate.js +86 -0
- package/dist/checkpoint/index.d.ts +2 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/service.d.ts +9 -0
- package/dist/checkpoint/service.js +20 -0
- package/dist/checkpoint/set-rollback.d.ts +51 -0
- package/dist/checkpoint/set-rollback.js +74 -0
- package/dist/cli/commands/rollback.d.ts +11 -6
- package/dist/cli/commands/rollback.js +93 -33
- package/dist/cli/commands/run.js +104 -21
- package/dist/cli/onboard.js +4 -1
- package/dist/cli/repl.d.ts +2 -2
- package/dist/cli/repl.js +39 -0
- package/dist/cli/session-factory.d.ts +27 -4
- package/dist/cli/session-factory.js +234 -58
- package/dist/config/schema.d.ts +24 -0
- package/dist/config/schema.js +9 -0
- package/dist/errors/constructors.d.ts +51 -0
- package/dist/errors/constructors.js +97 -0
- package/dist/errors/types.d.ts +28 -0
- package/dist/errors/types.js +38 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/router.d.ts +58 -0
- package/dist/hooks/router.js +136 -0
- package/dist/hooks/runner.d.ts +12 -0
- package/dist/hooks/runner.js +23 -1
- package/dist/indexing/retriever.d.ts +29 -0
- package/dist/indexing/retriever.js +26 -0
- package/dist/indexing/service.js +3 -1
- package/dist/indexing/types.d.ts +7 -0
- package/dist/lsp/tools/common.d.ts +34 -7
- package/dist/lsp/tools/common.js +33 -11
- package/dist/lsp/tools/find-definition.js +2 -2
- package/dist/lsp/tools/find-references.js +10 -4
- package/dist/lsp/tools/get-diagnostics.js +6 -4
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/sibling-banner.d.ts +25 -0
- package/dist/mcp/sibling-banner.js +34 -0
- package/dist/memory/recall.d.ts +24 -0
- package/dist/memory/recall.js +54 -0
- package/dist/memory/remember-tool.d.ts +3 -0
- package/dist/memory/remember-tool.js +11 -1
- package/dist/render/diff.js +42 -5
- package/dist/sandbox/policy.js +14 -5
- package/dist/sandbox/service.d.ts +8 -1
- package/dist/sandbox/service.js +4 -1
- package/dist/subagent/index.d.ts +1 -0
- package/dist/subagent/index.js +1 -0
- package/dist/subagent/orchestrator.d.ts +81 -1
- package/dist/subagent/orchestrator.js +204 -17
- package/dist/subagent/registry-scope.d.ts +13 -0
- package/dist/subagent/registry-scope.js +28 -2
- package/dist/subagent/semaphore.d.ts +27 -0
- package/dist/subagent/semaphore.js +56 -0
- package/dist/subagent/spawn-tool.d.ts +57 -0
- package/dist/subagent/spawn-tool.js +104 -9
- package/dist/subagent/types.d.ts +17 -2
- package/dist/testing/run-tests-tool.js +3 -0
- package/dist/tools/create-pull-request.d.ts +3 -0
- package/dist/tools/create-pull-request.js +50 -4
- package/dist/tools/file/apply-patch.js +2 -2
- package/dist/tools/file/edit-file.js +2 -2
- package/dist/tools/file/glob.d.ts +9 -2
- package/dist/tools/file/glob.js +73 -19
- package/dist/tools/file/grep-files.d.ts +12 -2
- package/dist/tools/file/grep-files.js +113 -38
- package/dist/tools/file/paths.d.ts +121 -9
- package/dist/tools/file/paths.js +164 -10
- package/dist/tools/file/read-file.js +2 -2
- package/dist/tools/file/write-file.js +2 -2
- package/dist/tools/git-status.d.ts +8 -1
- package/dist/tools/git-status.js +43 -11
- package/dist/tools/list-files.d.ts +9 -3
- package/dist/tools/list-files.js +48 -13
- package/dist/tools/search-codebase.d.ts +10 -0
- package/dist/tools/search-codebase.js +117 -14
- package/dist/tools/shell/exec.js +43 -4
- package/dist/tools/types.d.ts +74 -1
- package/dist/vcs/git.d.ts +8 -0
- package/dist/vcs/git.js +14 -0
- package/dist/vcs/github.d.ts +7 -1
- package/dist/vcs/github.js +10 -1
- package/dist/vcs/service.d.ts +8 -0
- package/dist/vcs/service.js +33 -1
- package/dist/vcs/types.d.ts +18 -2
- package/dist/workspace/add-root.d.ts +27 -0
- package/dist/workspace/add-root.js +16 -0
- package/dist/workspace/index.d.ts +2 -1
- package/dist/workspace/index.js +2 -1
- package/dist/workspace/workspace.d.ts +9 -4
- package/dist/workspace/workspace.js +9 -4
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { Command } from "commander";
|
|
2
3
|
import { themeForColor } from "../../theme/index.js";
|
|
3
4
|
import { loadConfig } from "../../config/index.js";
|
|
4
|
-
import { CheckpointService } from "../../checkpoint/index.js";
|
|
5
|
+
import { CheckpointService, applySet, buildSetPreview, listSets, setIsNoop, validateSet, } from "../../checkpoint/index.js";
|
|
5
6
|
import { ApprovalService, defaultPromptIO } from "../../approval/index.js";
|
|
6
7
|
import { fuzzyFind, selectList } from "../../components/index.js";
|
|
7
8
|
import { rollbackApprovalRequired, shouldUseColor, } from "../../errors/index.js";
|
|
@@ -41,17 +42,88 @@ async function pickCheckpoint(service) {
|
|
|
41
42
|
return result.kind === "selected" ? result.value : null;
|
|
42
43
|
}
|
|
43
44
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
* Legacy single-root rollback (C.32): restore one root's checkpoint. Reached for
|
|
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) and as
|
|
48
|
+
* the JC-F back-compat fallback for a pre-set-manifest run.
|
|
49
|
+
*/
|
|
50
|
+
async function legacyRollback(root, config, approval, interactive, id, t) {
|
|
51
|
+
const service = new CheckpointService({ root, config });
|
|
52
|
+
if (id === undefined) {
|
|
53
|
+
const picked = await pickCheckpoint(service);
|
|
54
|
+
if (picked === null) {
|
|
55
|
+
logger.print(t.muted("rollback cancelled — nothing was changed"));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
id = picked?.id;
|
|
59
|
+
}
|
|
60
|
+
const result = await service.rollback(id, {
|
|
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(primaryRoot, config, approval, t) {
|
|
85
|
+
const sets = await listSets(primaryRoot); // newest first
|
|
86
|
+
const set = sets[0];
|
|
87
|
+
// Validate-all BEFORE any apply — a missing/corrupt member throws here.
|
|
88
|
+
const members = await validateSet(set, config);
|
|
89
|
+
if (setIsNoop(members)) {
|
|
90
|
+
logger.print(t.muted(`working tree already matches run ${set.runId} — nothing to roll back`));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const decision = await approval.requestApproval({
|
|
94
|
+
kind: "rollback",
|
|
95
|
+
preview: buildSetPreview(set, members),
|
|
96
|
+
});
|
|
97
|
+
if (!decision.allow) {
|
|
98
|
+
logger.print(t.muted("rollback declined — nothing was changed"));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const applied = await applySet(set, members);
|
|
102
|
+
const parts = applied.restored.map((name) => {
|
|
103
|
+
const counts = applied.perRoot[name];
|
|
104
|
+
return `${name} (${counts.reverted} reverted, ${counts.recreated} recreated, ${counts.deleted} deleted)`;
|
|
105
|
+
});
|
|
106
|
+
logger.print(`${t.success(t.glyph.success)} restored run ${t.accent(set.runId)} across ` +
|
|
107
|
+
`${applied.restored.length} root${applied.restored.length === 1 ? "" : "s"} — ${parts.join("; ")}`);
|
|
108
|
+
logger.print(t.muted("note: commits, pushes, and PRs made during the run are not undone"));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `cruxy rollback [id]` (C.32/C.26) — restore the working tree to a run's
|
|
112
|
+
* checkpoint(s), undoing everything an agent run changed in one operation.
|
|
113
|
+
* Destructive by definition, so it is preview-first and gated through U.3 at the
|
|
114
|
+
* destructive tier, ungrantable; non-TTY is refused with a coded error before
|
|
115
|
+
* anything is computed. Out of scope, stated in the preview: commits, pushes, and
|
|
116
|
+
* PRs made during the run are not undone.
|
|
117
|
+
*
|
|
118
|
+
* Routing:
|
|
119
|
+
* • an explicit `<id>` → single-root rollback of that checkpoint (escape hatch);
|
|
120
|
+
* • no id, a set manifest exists → set-based rollback of the latest run;
|
|
121
|
+
* • no id, no set manifest → JC-F fallback to legacy single-root, logged.
|
|
50
122
|
*/
|
|
51
123
|
export function rollbackCommand() {
|
|
52
124
|
return new Command("rollback")
|
|
53
125
|
.description("restore the working tree to a checkpoint, undoing a run's file changes")
|
|
54
|
-
.argument("[id]", "checkpoint id (defaults to the most recent)")
|
|
126
|
+
.argument("[id]", "checkpoint id (defaults to the most recent run)")
|
|
55
127
|
.action(async (id) => {
|
|
56
128
|
const interactive = Boolean(process.stdin.isTTY);
|
|
57
129
|
// Refuse before touching anything: rollback is a deliberate, interactive
|
|
@@ -60,38 +132,26 @@ export function rollbackCommand() {
|
|
|
60
132
|
throw rollbackApprovalRequired();
|
|
61
133
|
const t = themeForColor(shouldUseColor(process.stdout));
|
|
62
134
|
const { config } = loadConfig();
|
|
63
|
-
const
|
|
64
|
-
const service = new CheckpointService({ root, config });
|
|
135
|
+
const primaryRoot = process.cwd();
|
|
65
136
|
const approval = new ApprovalService({
|
|
66
|
-
cwd:
|
|
137
|
+
cwd: primaryRoot,
|
|
67
138
|
interactive,
|
|
68
139
|
io: defaultPromptIO(shouldUseColor()),
|
|
69
140
|
});
|
|
70
|
-
//
|
|
71
|
-
// the
|
|
72
|
-
if (id
|
|
73
|
-
|
|
74
|
-
if (picked === null) {
|
|
75
|
-
logger.print(t.muted("rollback cancelled — nothing was changed"));
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
id = picked?.id;
|
|
79
|
-
}
|
|
80
|
-
const result = await service.rollback(id, {
|
|
81
|
-
requestApproval: (action) => approval.requestApproval(action),
|
|
82
|
-
interactive,
|
|
83
|
-
});
|
|
84
|
-
if (result.kind === "noop") {
|
|
85
|
-
logger.print(t.muted(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
|
|
141
|
+
// Explicit id → single-root path (the per-member escape hatch; also the
|
|
142
|
+
// recovery route if the primary root — and its set index — was removed).
|
|
143
|
+
if (id !== undefined) {
|
|
144
|
+
await legacyRollback(primaryRoot, config, approval, interactive, id, t);
|
|
86
145
|
return;
|
|
87
146
|
}
|
|
88
|
-
|
|
89
|
-
|
|
147
|
+
// No set manifest → JC-F: fall back to legacy single-root, logged (never
|
|
148
|
+
// silent). The primary root name matches single-root workspace naming.
|
|
149
|
+
const sets = await listSets(primaryRoot);
|
|
150
|
+
if (sets.length === 0) {
|
|
151
|
+
logger.info(`no set manifest — single-root rollback against ${path.basename(primaryRoot)}`);
|
|
152
|
+
await legacyRollback(primaryRoot, config, approval, interactive, undefined, t);
|
|
90
153
|
return;
|
|
91
154
|
}
|
|
92
|
-
|
|
93
|
-
logger.print(`${t.success(t.glyph.success)} restored checkpoint ${t.accent(result.checkpoint.id)} — ` +
|
|
94
|
-
`${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
|
|
95
|
-
logger.print(t.muted("note: commits, pushes, and PRs made during the run are not undone"));
|
|
155
|
+
await setRollback(primaryRoot, config, approval, t);
|
|
96
156
|
});
|
|
97
157
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -5,21 +5,36 @@ import { authMissingKey, shouldUseColor, usageError, } from "../../errors/index.
|
|
|
5
5
|
import { createRenderer } from "../../render/index.js";
|
|
6
6
|
import { themeForColor } from "../../theme/index.js";
|
|
7
7
|
import { summarizeRuns, renderSummary, } from "../../usage/index.js";
|
|
8
|
-
import {
|
|
8
|
+
import { CheckpointGate } from "../../checkpoint/index.js";
|
|
9
9
|
import { SandboxService } from "../../sandbox/index.js";
|
|
10
|
-
import { buildHooksService } from "../../hooks/index.js";
|
|
10
|
+
import { buildHooksService, buildHooksRouter } from "../../hooks/index.js";
|
|
11
11
|
import { runInteractive } from "../repl.js";
|
|
12
12
|
import { buildAgentSession } from "../session-factory.js";
|
|
13
13
|
import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
|
|
14
14
|
import { resetLspServices } from "../../lsp/index.js";
|
|
15
|
-
import { connectMcpTools, resetMcpServices } from "../../mcp/index.js";
|
|
15
|
+
import { connectMcpTools, deferredSiblingServers, resetMcpServices, } from "../../mcp/index.js";
|
|
16
16
|
import { defaultPromptIO } from "../../approval/index.js";
|
|
17
|
+
import { buildWorkspace, sessionWorkspace, } from "../../workspace/index.js";
|
|
18
|
+
/**
|
|
19
|
+
* Parse one repeatable `--root` value into a {@link RootSpec} and append it. Form
|
|
20
|
+
* `name=path` (explicit name) or a bare `path` (basename-named by buildWorkspace).
|
|
21
|
+
* All existence/dir/overlap/name validation is delegated to buildWorkspace, which
|
|
22
|
+
* fails loud — this only splits the flag.
|
|
23
|
+
*/
|
|
24
|
+
function collectRoot(value, acc) {
|
|
25
|
+
const eq = value.indexOf("=");
|
|
26
|
+
const spec = eq === -1
|
|
27
|
+
? { path: value }
|
|
28
|
+
: { name: value.slice(0, eq), path: value.slice(eq + 1) };
|
|
29
|
+
return [...acc, spec];
|
|
30
|
+
}
|
|
17
31
|
export function runCommand() {
|
|
18
32
|
return new Command("run")
|
|
19
33
|
.description("run a task once, or start an interactive session")
|
|
20
34
|
.argument("[prompt...]", "the task for cruxy to perform (omit for interactive)")
|
|
21
35
|
.option("--plan", "plan mode: propose a step-by-step plan for approval before executing")
|
|
22
36
|
.option("--sandbox", "run shell + test commands inside an isolated container (fails loud if no runtime)")
|
|
37
|
+
.option("--root <spec>", "declare a workspace root (repeatable): name=path or path; the first is primary", collectRoot, [])
|
|
23
38
|
.action(async (promptParts, opts) => {
|
|
24
39
|
const prompt = promptParts.join(" ").trim();
|
|
25
40
|
const t = themeForColor(shouldUseColor(process.stdout));
|
|
@@ -31,8 +46,40 @@ export function runCommand() {
|
|
|
31
46
|
}
|
|
32
47
|
const { config, sources } = loadConfig();
|
|
33
48
|
let apiKey = resolveApiKey(config.model.provider);
|
|
49
|
+
// Declared workspace roots (C.26). This is the ONE place `run` reads the
|
|
50
|
+
// process working directory — the invocation directory is the base for
|
|
51
|
+
// resolving `--root` paths and the sole root when none are declared. Every
|
|
52
|
+
// subsystem below derives its cwd from the WORKSPACE (`primaryRoot`), never
|
|
53
|
+
// re-reads the invocation directory, so nothing can silently split-brain to
|
|
54
|
+
// a different dir than the roots the tools see (the guard test pins this).
|
|
55
|
+
const invocationCwd = process.cwd();
|
|
56
|
+
// No --root → a trivial single-root workspace (byte-identical to pre-C.26).
|
|
57
|
+
// --root builds a genuine multi-root Workspace and fails fast HERE — a
|
|
58
|
+
// missing / non-dir / overlapping root (CRUXY_E_ROOT_OVERLAP) throws before
|
|
59
|
+
// onboarding or the session starts, never a half-built session.
|
|
60
|
+
const workspace = opts.root.length
|
|
61
|
+
? await buildWorkspace(opts.root, { cwd: invocationCwd })
|
|
62
|
+
: sessionWorkspace(invocationCwd);
|
|
63
|
+
const primaryRoot = workspace.primary().absPath;
|
|
34
64
|
logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
|
|
35
65
|
logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
|
|
66
|
+
// Multi-root honesty (JC-6/C.26): reads fan every root. With per-root
|
|
67
|
+
// checkpoints active (C.26 step 3), writes fan every root too — each is
|
|
68
|
+
// checkpointed and rollback-able. With checkpoints DISABLED, a non-primary
|
|
69
|
+
// write is still refused (CRUXY_E_MULTIROOT_WRITE_DEFERRED) rather than left
|
|
70
|
+
// un-restorable. Hooks + MCP still scope to the primary this release. Named
|
|
71
|
+
// at start, never a silent primary-default.
|
|
72
|
+
if (workspace.isMultiRoot) {
|
|
73
|
+
const names = workspace
|
|
74
|
+
.roots()
|
|
75
|
+
.map((r) => r.name)
|
|
76
|
+
.join(", ");
|
|
77
|
+
const writes = config.checkpoint.enabled
|
|
78
|
+
? "writes fan all roots (each checkpointed)"
|
|
79
|
+
: "writes scope to primary (checkpoints disabled)";
|
|
80
|
+
logger.info(t.muted(`roots: ${workspace.roots().length} (${names}); primary ${workspace.primary().name} — ` +
|
|
81
|
+
`reads fan all roots; ${writes}; hooks fan per trusted root; MCP loads from primary only this release`));
|
|
82
|
+
}
|
|
36
83
|
// First-run with no key (and a TTY) → guided onboarding instead of the
|
|
37
84
|
// dead-end auth error. The first-win demo is offered only in the no-prompt
|
|
38
85
|
// (REPL) path; with a real prompt, that prompt IS the first win.
|
|
@@ -40,7 +87,7 @@ export function runCommand() {
|
|
|
40
87
|
const onboarding = maybeRunOnboarding(config, {
|
|
41
88
|
ttyInteractive: Boolean(process.stdin.isTTY),
|
|
42
89
|
offerFirstWin: interactive,
|
|
43
|
-
cwd:
|
|
90
|
+
cwd: primaryRoot,
|
|
44
91
|
});
|
|
45
92
|
if (onboarding === null) {
|
|
46
93
|
// Not a first run (non-TTY, or onboarded then key removed) → fail loud.
|
|
@@ -62,11 +109,13 @@ export function runCommand() {
|
|
|
62
109
|
// One renderer for the whole run (U.2): the streaming path and the
|
|
63
110
|
// approval prompt's status-suspend hook must share the same live region.
|
|
64
111
|
const renderer = createRenderer();
|
|
65
|
-
// Checkpoint-before-first-mutation (C.32): the
|
|
66
|
-
//
|
|
67
|
-
//
|
|
112
|
+
// Checkpoint-before-first-mutation (C.32/C.26): the gate owns one
|
|
113
|
+
// CheckpointService PER touched root (lazy) plus the run's CheckpointSet,
|
|
114
|
+
// and fires from the approval seam inside the session — so one instance
|
|
115
|
+
// covers the one-shot path, every REPL turn, plan-mode execution, and
|
|
116
|
+
// subagents (they share this gate, so their writes join the run's set).
|
|
68
117
|
const checkpoints = config.checkpoint.enabled
|
|
69
|
-
? new
|
|
118
|
+
? new CheckpointGate({ config, primaryRoot })
|
|
70
119
|
: undefined;
|
|
71
120
|
// Sandbox (C.16): opt-in via --sandbox or sandbox.enabled. Resolving the
|
|
72
121
|
// service probes the runtime and THROWS CRUXY_E_SANDBOX_UNAVAILABLE if it
|
|
@@ -77,37 +126,71 @@ export function runCommand() {
|
|
|
77
126
|
const sandbox = sandboxEnabled
|
|
78
127
|
? await SandboxService.create({
|
|
79
128
|
config,
|
|
80
|
-
cwd:
|
|
129
|
+
cwd: primaryRoot,
|
|
130
|
+
// R5 (C.26): the command's root (primary — shell/test attribute to
|
|
131
|
+
// it this release) mounts read-write; every OTHER declared root
|
|
132
|
+
// mounts read-only. Cross-root write is only ever granted per-root
|
|
133
|
+
// via a named escalation, never blanket.
|
|
134
|
+
siblingRoots: workspace
|
|
135
|
+
.roots()
|
|
136
|
+
.filter((r) => !r.primary)
|
|
137
|
+
.map((r) => r.absPath),
|
|
81
138
|
reporter: renderer,
|
|
82
139
|
})
|
|
83
140
|
: undefined;
|
|
84
141
|
if (sandbox) {
|
|
85
142
|
logger.info(t.muted(`sandbox: ${sandbox.runtimeName} (network ${config.sandbox.network})`));
|
|
86
143
|
}
|
|
87
|
-
// Hooks + custom slash commands (C.19).
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
144
|
+
// Hooks + custom slash commands (C.19). Single-root uses the unchanged
|
|
145
|
+
// service (byte-identical: one runner, lazy trust prompt). Multi-root
|
|
146
|
+
// (C.26 step 5) builds ONE runner per root behind a HookRouter — tool
|
|
147
|
+
// events fire the acting root's hooks, lifecycle events fan every trusted
|
|
148
|
+
// root. Untrusted roots are skipped and NAMED here (never a silent loss).
|
|
149
|
+
let hooksRunner;
|
|
150
|
+
let hookCommands;
|
|
151
|
+
if (workspace.isMultiRoot) {
|
|
152
|
+
const router = await buildHooksRouter({ workspace, config, logger });
|
|
153
|
+
hooksRunner = router.runner;
|
|
154
|
+
hookCommands = router.commands;
|
|
155
|
+
for (const u of router.untrustedHookRoots) {
|
|
156
|
+
logger.info(t.muted(`hooks: root "${u.name}" has ${u.count} untrusted hook${u.count === 1 ? "" : "s"} — ` +
|
|
157
|
+
`not firing (run \`cruxy hooks trust ${u.absPath}\`)`));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const hooksService = await buildHooksService({
|
|
162
|
+
cwd: primaryRoot,
|
|
163
|
+
config,
|
|
164
|
+
interactive: Boolean(process.stdin.isTTY),
|
|
165
|
+
logger,
|
|
166
|
+
});
|
|
167
|
+
hooksRunner = hooksService.runner;
|
|
168
|
+
hookCommands = hooksService.commands;
|
|
169
|
+
}
|
|
96
170
|
// MCP servers (C.27): connect + trust-gate BEFORE building the session so
|
|
97
171
|
// the tool catalogue is complete when the model first runs. Off by
|
|
98
172
|
// default (no servers connect). A non-interactive run with an untrusted
|
|
99
173
|
// config THROWS CRUXY_E_MCP_UNTRUSTED here — before any server spawns.
|
|
100
174
|
const mcp = await connectMcpTools({
|
|
101
|
-
cwd:
|
|
175
|
+
cwd: primaryRoot,
|
|
102
176
|
config,
|
|
103
177
|
logger,
|
|
104
178
|
interactive: Boolean(process.stdin.isTTY),
|
|
105
179
|
io: defaultPromptIO(shouldUseColor()),
|
|
106
180
|
});
|
|
107
|
-
|
|
181
|
+
// MCP is primary-root only this release (JC-D). Name each sibling-root
|
|
182
|
+
// server individually — never one generic line, never a silent drop —
|
|
183
|
+
// so a user who declared `github` in a sibling knows exactly why its
|
|
184
|
+
// tools aren't present (and that root-qualified names are a follow-up).
|
|
185
|
+
if (workspace.isMultiRoot) {
|
|
186
|
+
for (const d of deferredSiblingServers(workspace)) {
|
|
187
|
+
logger.info(t.muted(`mcp: server "${d.server}" declared in ${d.root} not loaded (primary-root MCP only this release)`));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const session = buildAgentSession(config, apiKey, workspace, Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksRunner, mcp.tools);
|
|
108
191
|
if (interactive) {
|
|
109
192
|
try {
|
|
110
|
-
await runInteractive(session, undefined, renderer, checkpoints,
|
|
193
|
+
await runInteractive(session, undefined, renderer, checkpoints, hookCommands);
|
|
111
194
|
}
|
|
112
195
|
finally {
|
|
113
196
|
// LSP (C.12) + MCP (C.27): gracefully shut down any external server
|
package/dist/cli/onboard.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { sessionWorkspace } from "../workspace/index.js";
|
|
4
5
|
import { buildAgentSession } from "./session-factory.js";
|
|
5
6
|
/**
|
|
6
7
|
* CLI-layer glue between the entry points and the onboarding module (U.6). Keeps
|
|
@@ -24,7 +25,9 @@ export async function runFirstWinTask(config, cwd, prompt) {
|
|
|
24
25
|
if (!apiKey)
|
|
25
26
|
return; // defensive — the key was just persisted
|
|
26
27
|
const renderer = createRenderer();
|
|
27
|
-
|
|
28
|
+
// The onboarding first-win is inherently single-root (it runs before any
|
|
29
|
+
// `--root` is parsed), so it acts over a trivial workspace on its cwd.
|
|
30
|
+
const session = buildAgentSession(config, apiKey, sessionWorkspace(cwd), true, false, renderer);
|
|
28
31
|
try {
|
|
29
32
|
await session.send(prompt, renderer);
|
|
30
33
|
}
|
package/dist/cli/repl.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Readable, Writable } from "node:stream";
|
|
2
2
|
import type { Session } from "../agent/index.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { CheckpointGate } from "../checkpoint/index.js";
|
|
4
4
|
import { type SlashCommandSpec } from "../hooks/index.js";
|
|
5
5
|
import { type StreamRenderer } from "../render/index.js";
|
|
6
6
|
/**
|
|
@@ -24,4 +24,4 @@ export interface ReplIO {
|
|
|
24
24
|
* live region, anything else the plain append-only renderer); `cruxy run`
|
|
25
25
|
* passes its own so the approval prompt's status-suspend hook shares it.
|
|
26
26
|
*/
|
|
27
|
-
export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?:
|
|
27
|
+
export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?: CheckpointGate, slashCommands?: readonly SlashCommandSpec[]): Promise<void>;
|
package/dist/cli/repl.js
CHANGED
|
@@ -2,6 +2,7 @@ import readline from "node:readline";
|
|
|
2
2
|
import { makeReplCompleter } from "../components/index.js";
|
|
3
3
|
import { resolveSlash } from "../hooks/index.js";
|
|
4
4
|
import { runGatedShell } from "../tools/shell/exec.js";
|
|
5
|
+
import { addRootToWorkspace } from "../workspace/index.js";
|
|
5
6
|
import { themeForColor } from "../theme/index.js";
|
|
6
7
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
7
8
|
import { createRenderer } from "../render/index.js";
|
|
@@ -113,6 +114,40 @@ function printReplError(err) {
|
|
|
113
114
|
color: shouldUseColor(process.stdout),
|
|
114
115
|
}));
|
|
115
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* `/add-root <name> <path>` (C.26 step 5) — an explicit, human-only way to
|
|
119
|
+
* declare another workspace root. This is a REPL command, NOT a model tool: the
|
|
120
|
+
* model can only reach the tool registry, and nothing named `add_root` is
|
|
121
|
+
* registered there, so the allowlist argument is unchanged. It validates the
|
|
122
|
+
* addition (TTY-only; same existence/name/overlap refusal as `--root`) and, on
|
|
123
|
+
* success, tells the user to relaunch with `--root` to activate it — a
|
|
124
|
+
* mid-session hot-swap would leave the checkpoint gate + hook router (both wired
|
|
125
|
+
* from the session-start workspace) half-attributed, so activation is deferred
|
|
126
|
+
* to a clean relaunch. A newly declared root always starts untrusted.
|
|
127
|
+
*/
|
|
128
|
+
async function handleAddRoot(input, session) {
|
|
129
|
+
const parts = input
|
|
130
|
+
.slice("/add-root".length)
|
|
131
|
+
.trim()
|
|
132
|
+
.split(/\s+/)
|
|
133
|
+
.filter(Boolean);
|
|
134
|
+
if (parts.length !== 2) {
|
|
135
|
+
logger.print(theme.muted("usage: /add-root <name> <path>"));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const [name, rootPath] = parts;
|
|
139
|
+
const ctx = session.toolContext;
|
|
140
|
+
const current = ctx.workspace;
|
|
141
|
+
try {
|
|
142
|
+
const next = await addRootToWorkspace(current, { name, path: rootPath }, { cwd: ctx.cwd, tty: Boolean(process.stdin.isTTY) });
|
|
143
|
+
const abs = next.rootByName(name).absPath;
|
|
144
|
+
logger.print(theme.muted(`validated root "${name}" (${abs}). relaunch with \`--root ${name}=${rootPath}\` to activate it — ` +
|
|
145
|
+
`it starts untrusted (its hooks and project memory stay inert until you run \`cruxy hooks/memory trust\`).`));
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
printReplError(err);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
116
151
|
/**
|
|
117
152
|
* Drive an interactive multi-turn session: prompt, read a line, dispatch slash
|
|
118
153
|
* commands or run a turn, repeat. Assistant text and tool-call progress stream
|
|
@@ -182,6 +217,10 @@ async function replLoop(session, io, renderer, checkpoints, slashCommands = [])
|
|
|
182
217
|
logger.print(HELP);
|
|
183
218
|
continue;
|
|
184
219
|
}
|
|
220
|
+
if (trimmed === "/add-root" || trimmed.startsWith("/add-root ")) {
|
|
221
|
+
await handleAddRoot(trimmed, session);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
185
224
|
// Custom slash commands (C.19) — consulted AFTER builtins, so a custom
|
|
186
225
|
// command can never shadow /help, /exit, etc. A `prompt` command expands to
|
|
187
226
|
// text fed to the agent (safe); a `shell` command runs through the SAME
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { CruxyConfig } from "../config/index.js";
|
|
2
2
|
import type { ApprovalDecision } from "../approval/index.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { CheckpointGate } from "../checkpoint/index.js";
|
|
4
4
|
import type { SandboxService } from "../sandbox/index.js";
|
|
5
5
|
import type { StreamRenderer } from "../render/index.js";
|
|
6
|
-
import { type ApproveAction, type Tool } from "../tools/index.js";
|
|
6
|
+
import { ToolRegistry, type ApproveAction, type Tool } from "../tools/index.js";
|
|
7
7
|
import { Session, type LifecycleHookRunner } from "../agent/index.js";
|
|
8
|
+
import type { Workspace } from "../workspace/index.js";
|
|
8
9
|
/**
|
|
9
10
|
* Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
|
|
10
11
|
* whole point: a tool mutates only *after* `requestApproval` resolves, so
|
|
@@ -14,7 +15,29 @@ import { Session, type LifecycleHookRunner } from "../agent/index.js";
|
|
|
14
15
|
* (file actions) or that attribution is lost (shell), for rollback's
|
|
15
16
|
* external-change detection.
|
|
16
17
|
*/
|
|
17
|
-
export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>,
|
|
18
|
+
export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>, gate: CheckpointGate | undefined, ws: Workspace): (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
19
|
+
/**
|
|
20
|
+
* Register every CONDITIONALLY-enabled runtime tool onto `registry`, in the fixed
|
|
21
|
+
* order the model sees them: `remember` (memory), the four LSP tools, the two web
|
|
22
|
+
* tools, the trusted MCP tools, then `spawn_subagent`. Factored out of
|
|
23
|
+
* {@link buildAgentSession} for ONE reason beyond tidiness: it makes the *complete
|
|
24
|
+
* runtime* tool set enumerable by a test (JC-B). The default registry only covers
|
|
25
|
+
* the 14 always-on tools; the allowlist test that pins "no tool can add a workspace
|
|
26
|
+
* root" is only sound if it runs against the SAME set the session ships — so the
|
|
27
|
+
* session factory and that test both build the surface through this one seam. A new
|
|
28
|
+
* conditionally-registered tool added here fails the allowlist test until it is
|
|
29
|
+
* consciously listed and audited.
|
|
30
|
+
*
|
|
31
|
+
* Each family is opt-in and inert when its feature flag is off, exactly as before —
|
|
32
|
+
* this function is a pure move of the inline registration, same guards, same order.
|
|
33
|
+
* `spawnTool` is passed in (already depth-bound) because it needs the orchestrator;
|
|
34
|
+
* it registers only when nesting is allowed (`subagent.maxDepth > 0`).
|
|
35
|
+
*/
|
|
36
|
+
export declare function registerRuntimeTools(registry: ToolRegistry, config: CruxyConfig, opts?: {
|
|
37
|
+
mcpTools?: Tool[];
|
|
38
|
+
spawnTool?: Tool;
|
|
39
|
+
spawnManyTool?: Tool;
|
|
40
|
+
}): void;
|
|
18
41
|
/**
|
|
19
42
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
20
43
|
* shared by `cruxy run` and the onboarding first-win task (so they can't drift).
|
|
@@ -24,4 +47,4 @@ export declare function withCheckpointGate(requestApproval: (action: ApproveActi
|
|
|
24
47
|
* over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
|
|
25
48
|
* approves → executes. Plan mode is fully opt-in; the default path is unchanged.
|
|
26
49
|
*/
|
|
27
|
-
export declare function buildAgentSession(config: CruxyConfig, apiKey: string,
|
|
50
|
+
export declare function buildAgentSession(config: CruxyConfig, apiKey: string, workspace: Workspace, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointGate, sandbox?: SandboxService, hooks?: LifecycleHookRunner, mcpTools?: Tool[]): Session;
|