@cruxy/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -13
- package/dist/agent/loop.d.ts +28 -1
- package/dist/agent/loop.js +36 -4
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +8 -0
- package/dist/approval/classify.js +26 -0
- package/dist/approval/prompt.js +4 -27
- package/dist/checkpoint/capture.d.ts +17 -0
- package/dist/checkpoint/capture.js +73 -0
- package/dist/checkpoint/git-store.d.ts +61 -0
- package/dist/checkpoint/git-store.js +171 -0
- package/dist/checkpoint/index.d.ts +6 -0
- package/dist/checkpoint/index.js +6 -0
- package/dist/checkpoint/restore.d.ts +23 -0
- package/dist/checkpoint/restore.js +195 -0
- package/dist/checkpoint/service.d.ts +80 -0
- package/dist/checkpoint/service.js +276 -0
- package/dist/checkpoint/shadow-store.d.ts +23 -0
- package/dist/checkpoint/shadow-store.js +93 -0
- package/dist/checkpoint/types.d.ts +117 -0
- package/dist/checkpoint/types.js +18 -0
- package/dist/cli/commands/checkpoint.d.ts +7 -0
- package/dist/cli/commands/checkpoint.js +31 -0
- package/dist/cli/commands/rollback.d.ts +10 -0
- package/dist/cli/commands/rollback.js +96 -0
- package/dist/cli/commands/run.js +10 -2
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.d.ts +7 -1
- package/dist/cli/repl.js +23 -3
- package/dist/cli/session-factory.d.ts +14 -1
- package/dist/cli/session-factory.js +87 -22
- package/dist/components/autocomplete.d.ts +32 -0
- package/dist/components/autocomplete.js +50 -0
- package/dist/components/frame.d.ts +25 -0
- package/dist/components/frame.js +49 -0
- package/dist/components/fuzzy.d.ts +61 -0
- package/dist/components/fuzzy.js +174 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +6 -0
- package/dist/components/input.d.ts +78 -0
- package/dist/components/input.js +111 -0
- package/dist/components/keys.d.ts +48 -0
- package/dist/components/keys.js +105 -0
- package/dist/components/select.d.ts +28 -0
- package/dist/components/select.js +69 -0
- package/dist/config/schema.d.ts +133 -0
- package/dist/config/schema.js +40 -0
- package/dist/errors/constructors.d.ts +32 -0
- package/dist/errors/constructors.js +101 -0
- package/dist/errors/types.d.ts +8 -0
- package/dist/errors/types.js +18 -0
- package/dist/indexing/walker.d.ts +11 -0
- package/dist/indexing/walker.js +11 -6
- package/dist/onboarding/io.d.ts +3 -2
- package/dist/onboarding/io.js +35 -81
- package/dist/plan/execute.d.ts +8 -0
- package/dist/plan/execute.js +36 -22
- package/dist/plan/service.js +5 -1
- package/dist/plan/submit-plan.d.ts +4 -4
- package/dist/render/diff.js +27 -0
- package/dist/render/index.d.ts +2 -1
- package/dist/render/index.js +1 -0
- package/dist/render/plain-renderer.d.ts +7 -1
- package/dist/render/plain-renderer.js +26 -0
- package/dist/render/state.d.ts +31 -0
- package/dist/render/state.js +83 -0
- package/dist/render/tty-renderer.d.ts +41 -5
- package/dist/render/tty-renderer.js +150 -23
- package/dist/render/types.d.ts +85 -1
- package/dist/subagent/budget.d.ts +34 -0
- package/dist/subagent/budget.js +57 -0
- package/dist/subagent/index.d.ts +5 -0
- package/dist/subagent/index.js +5 -0
- package/dist/subagent/orchestrator.d.ts +67 -0
- package/dist/subagent/orchestrator.js +241 -0
- package/dist/subagent/registry-scope.d.ts +28 -0
- package/dist/subagent/registry-scope.js +63 -0
- package/dist/subagent/spawn-tool.d.ts +29 -0
- package/dist/subagent/spawn-tool.js +94 -0
- package/dist/subagent/types.d.ts +55 -0
- package/dist/subagent/types.js +1 -0
- package/dist/tools/types.d.ts +20 -2
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { promises as fsp } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { checkpointFailed } from "../errors/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* Shadow-copy checkpoint content store (C.32 fallback substrate): used outside
|
|
7
|
+
* a git repo, or when git plumbing fails mid-snapshot. File contents live in a
|
|
8
|
+
* content-addressed pool at `.cruxy/checkpoints/objects/<sha256>` — identical
|
|
9
|
+
* content across files or checkpoints is stored once, and pruning sweeps
|
|
10
|
+
* objects no longer referenced by any surviving manifest.
|
|
11
|
+
*
|
|
12
|
+
* Writes are temp-file-then-rename so a crash can never leave a torn object; a
|
|
13
|
+
* torn *read* is impossible because an object either exists complete or not at
|
|
14
|
+
* all, and a missing object fails loudly.
|
|
15
|
+
*/
|
|
16
|
+
export class ShadowCheckpointStore {
|
|
17
|
+
kind = "shadow";
|
|
18
|
+
objectsDir;
|
|
19
|
+
constructor(root) {
|
|
20
|
+
this.objectsDir = path.join(root, ".cruxy", "checkpoints", "objects");
|
|
21
|
+
}
|
|
22
|
+
hashContent(content) {
|
|
23
|
+
return createHash("sha256").update(content).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
async snapshot(files) {
|
|
26
|
+
await fsp.mkdir(this.objectsDir, { recursive: true });
|
|
27
|
+
const entries = [];
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
let content;
|
|
30
|
+
let stat;
|
|
31
|
+
try {
|
|
32
|
+
stat = await fsp.lstat(file.absPath);
|
|
33
|
+
if (!stat.isFile())
|
|
34
|
+
continue; // raced from regular file to something else
|
|
35
|
+
content = await fsp.readFile(file.absPath);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
throw checkpointFailed(`could not read ${file.path} for snapshot`, err);
|
|
39
|
+
}
|
|
40
|
+
const oid = this.hashContent(content);
|
|
41
|
+
await this.writeObject(oid, content);
|
|
42
|
+
entries.push({
|
|
43
|
+
path: file.path,
|
|
44
|
+
mode: stat.mode & 0o100 ? "100755" : "100644",
|
|
45
|
+
oid,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
async readContent(entry) {
|
|
51
|
+
try {
|
|
52
|
+
return await fsp.readFile(path.join(this.objectsDir, entry.oid));
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw checkpointFailed(`checkpoint content for ${entry.path} is missing from .cruxy/checkpoints/objects (${entry.oid})`, err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async collect(referenced) {
|
|
59
|
+
let names;
|
|
60
|
+
try {
|
|
61
|
+
names = await fsp.readdir(this.objectsDir);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return; // no pool yet — nothing to sweep
|
|
65
|
+
}
|
|
66
|
+
for (const name of names) {
|
|
67
|
+
if (referenced.has(name))
|
|
68
|
+
continue;
|
|
69
|
+
// Best-effort: a failed unlink just leaves an unreferenced object behind.
|
|
70
|
+
await fsp.rm(path.join(this.objectsDir, name), { force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Content-addressed write: skip if present, else temp-then-rename (atomic). */
|
|
74
|
+
async writeObject(oid, content) {
|
|
75
|
+
const dest = path.join(this.objectsDir, oid);
|
|
76
|
+
try {
|
|
77
|
+
await fsp.access(dest);
|
|
78
|
+
return; // already stored — content-addressing dedupes for free
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* not present — write it */
|
|
82
|
+
}
|
|
83
|
+
const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`;
|
|
84
|
+
try {
|
|
85
|
+
await fsp.writeFile(tmp, content);
|
|
86
|
+
await fsp.rename(tmp, dest);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
await fsp.rm(tmp, { force: true });
|
|
90
|
+
throw checkpointFailed(`could not store checkpoint object ${oid}`, err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working-tree checkpoints (C.32): a complete snapshot of everything the agent
|
|
3
|
+
* could touch, captured before a run's first mutation, so `cruxy rollback` can
|
|
4
|
+
* undo the entire run — creates, edits, deletes — in one gated operation.
|
|
5
|
+
*
|
|
6
|
+
* Two layers, deliberately separated:
|
|
7
|
+
* • {@link CheckpointStore} — the *content substrate* (where file bytes
|
|
8
|
+
* live), swappable like VectorStore: git-object-backed in a repo, a
|
|
9
|
+
* content-addressed shadow copy otherwise.
|
|
10
|
+
* • The manifest — uniform JSON under `.cruxy/checkpoints/`, owned by the
|
|
11
|
+
* service regardless of substrate, so list/prune/rollback never care which
|
|
12
|
+
* store wrote a checkpoint.
|
|
13
|
+
*
|
|
14
|
+
* Explicit boundary: checkpoints cover WORKING-TREE file state only. They can
|
|
15
|
+
* never undo git commits, pushes, or PRs made during a run (C.15) — that is
|
|
16
|
+
* stated in the rollback preview, not silently implied.
|
|
17
|
+
*/
|
|
18
|
+
/** A file selected for capture: project-relative POSIX path + where it is on disk. */
|
|
19
|
+
export interface CaptureFile {
|
|
20
|
+
path: string;
|
|
21
|
+
absPath: string;
|
|
22
|
+
}
|
|
23
|
+
/** Which content substrate a checkpoint was written with. */
|
|
24
|
+
export type CheckpointStoreKind = "git" | "shadow";
|
|
25
|
+
/**
|
|
26
|
+
* One captured file in a checkpoint manifest. `oid` is the content address in
|
|
27
|
+
* the store that wrote it: a git blob sha-1 for the git store, a sha-256 for
|
|
28
|
+
* the shadow store. `mode` preserves the executable bit across restore.
|
|
29
|
+
*/
|
|
30
|
+
export interface FileEntry {
|
|
31
|
+
path: string;
|
|
32
|
+
mode: "100644" | "100755";
|
|
33
|
+
oid: string;
|
|
34
|
+
}
|
|
35
|
+
/** A persisted checkpoint: identity, provenance, and the full pre-run manifest. */
|
|
36
|
+
export interface Checkpoint {
|
|
37
|
+
/** Stable id, e.g. `ck-20260703T141530-a4f2`. */
|
|
38
|
+
id: string;
|
|
39
|
+
/** ISO-8601 creation time. */
|
|
40
|
+
createdAt: string;
|
|
41
|
+
/** One line describing the run this checkpoint protects. */
|
|
42
|
+
runSummary: string;
|
|
43
|
+
store: CheckpointStoreKind;
|
|
44
|
+
/** Every file that existed (and was capturable) before the run's first mutation. */
|
|
45
|
+
files: FileEntry[];
|
|
46
|
+
/**
|
|
47
|
+
* Project-relative paths the tracked run mutated through file tools, recorded
|
|
48
|
+
* as the run proceeds. At rollback time, a difference on a path NOT in this
|
|
49
|
+
* list is an *external* change — surfaced loudly in the preview.
|
|
50
|
+
*/
|
|
51
|
+
touchedPaths: string[];
|
|
52
|
+
/**
|
|
53
|
+
* True when the run executed approved shell commands: a shell command can
|
|
54
|
+
* touch any path, so per-file attribution becomes impossible and the preview
|
|
55
|
+
* says so instead of guessing.
|
|
56
|
+
*/
|
|
57
|
+
hasShellMutations: boolean;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The content substrate seam. Implementations persist and retrieve file
|
|
61
|
+
* *contents* only — manifests are the service's job.
|
|
62
|
+
*
|
|
63
|
+
* THE invariant (git store): snapshot/read must never disturb user-visible git
|
|
64
|
+
* state — HEAD, the index, the stash, or any ref. The git store self-checks
|
|
65
|
+
* this at runtime and fails loudly on drift.
|
|
66
|
+
*/
|
|
67
|
+
export interface CheckpointStore {
|
|
68
|
+
readonly kind: CheckpointStoreKind;
|
|
69
|
+
/** Persist the current contents of `files`; returns the manifest entries. */
|
|
70
|
+
snapshot(files: CaptureFile[]): Promise<FileEntry[]>;
|
|
71
|
+
/** The content address `snapshot` would give this buffer (for diffing). */
|
|
72
|
+
hashContent(content: Buffer): string;
|
|
73
|
+
/** Read one captured file's bytes back. Throws CRUXY_E_CHECKPOINT_FAILED if gone. */
|
|
74
|
+
readContent(entry: FileEntry): Promise<Buffer>;
|
|
75
|
+
/**
|
|
76
|
+
* Best-effort GC after prune: drop stored content whose oid is no longer
|
|
77
|
+
* referenced by any surviving manifest. The git store is a no-op (dangling
|
|
78
|
+
* objects belong to git's own gc); the shadow store sweeps its object pool.
|
|
79
|
+
*/
|
|
80
|
+
collect(referenced: ReadonlySet<string>): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
/** What rollback will do to one path. */
|
|
83
|
+
export type RollbackAction =
|
|
84
|
+
/** The run deleted it — recreate from the checkpoint. */
|
|
85
|
+
"recreate"
|
|
86
|
+
/** The run edited it — revert content (and mode) to the checkpoint. */
|
|
87
|
+
| "revert"
|
|
88
|
+
/** The run created it — delete it. */
|
|
89
|
+
| "delete";
|
|
90
|
+
export interface RollbackEntry {
|
|
91
|
+
path: string;
|
|
92
|
+
action: RollbackAction;
|
|
93
|
+
/**
|
|
94
|
+
* True when this path changed since the checkpoint but the tracked run never
|
|
95
|
+
* touched it — concurrent user (or other-tool) work that rollback would
|
|
96
|
+
* clobber. Always surfaced in the preview; never overwritten silently.
|
|
97
|
+
*/
|
|
98
|
+
external: boolean;
|
|
99
|
+
/** Checkpoint-side content to restore (recreate/revert; absent for delete). */
|
|
100
|
+
entry?: FileEntry;
|
|
101
|
+
}
|
|
102
|
+
/** The computed diff between the current working tree and a checkpoint. */
|
|
103
|
+
export interface RollbackPlan {
|
|
104
|
+
checkpoint: Checkpoint;
|
|
105
|
+
/** Sorted by path; empty means the tree already matches the checkpoint. */
|
|
106
|
+
entries: RollbackEntry[];
|
|
107
|
+
/** The `external: true` paths, for the preview's warning block. */
|
|
108
|
+
externalPaths: string[];
|
|
109
|
+
/** Mirrors {@link Checkpoint.hasShellMutations}: attribution is unknowable. */
|
|
110
|
+
attributionUnknown: boolean;
|
|
111
|
+
}
|
|
112
|
+
/** Counts of what a successful rollback actually did. */
|
|
113
|
+
export interface RollbackApplied {
|
|
114
|
+
recreated: number;
|
|
115
|
+
reverted: number;
|
|
116
|
+
deleted: number;
|
|
117
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working-tree checkpoints (C.32): a complete snapshot of everything the agent
|
|
3
|
+
* could touch, captured before a run's first mutation, so `cruxy rollback` can
|
|
4
|
+
* undo the entire run — creates, edits, deletes — in one gated operation.
|
|
5
|
+
*
|
|
6
|
+
* Two layers, deliberately separated:
|
|
7
|
+
* • {@link CheckpointStore} — the *content substrate* (where file bytes
|
|
8
|
+
* live), swappable like VectorStore: git-object-backed in a repo, a
|
|
9
|
+
* content-addressed shadow copy otherwise.
|
|
10
|
+
* • The manifest — uniform JSON under `.cruxy/checkpoints/`, owned by the
|
|
11
|
+
* service regardless of substrate, so list/prune/rollback never care which
|
|
12
|
+
* store wrote a checkpoint.
|
|
13
|
+
*
|
|
14
|
+
* Explicit boundary: checkpoints cover WORKING-TREE file state only. They can
|
|
15
|
+
* never undo git commits, pushes, or PRs made during a run (C.15) — that is
|
|
16
|
+
* stated in the rollback preview, not silently implied.
|
|
17
|
+
*/
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `cruxy checkpoint` (C.32) — inspect the working-tree snapshots that back
|
|
4
|
+
* `cruxy rollback`. Creation is automatic (before a run's first mutation);
|
|
5
|
+
* this command only lists.
|
|
6
|
+
*/
|
|
7
|
+
export declare function checkpointCommand(): Command;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { loadConfig } from "../../config/index.js";
|
|
4
|
+
import { CheckpointService } from "../../checkpoint/index.js";
|
|
5
|
+
import { logger } from "../../utils/logger.js";
|
|
6
|
+
/**
|
|
7
|
+
* `cruxy checkpoint` (C.32) — inspect the working-tree snapshots that back
|
|
8
|
+
* `cruxy rollback`. Creation is automatic (before a run's first mutation);
|
|
9
|
+
* this command only lists.
|
|
10
|
+
*/
|
|
11
|
+
export function checkpointCommand() {
|
|
12
|
+
const cmd = new Command("checkpoint").description("working-tree checkpoints — the undo units behind `cruxy rollback`");
|
|
13
|
+
cmd
|
|
14
|
+
.command("list")
|
|
15
|
+
.description("list saved checkpoints, newest first")
|
|
16
|
+
.action(async () => {
|
|
17
|
+
const { config } = loadConfig();
|
|
18
|
+
const service = new CheckpointService({ root: process.cwd(), config });
|
|
19
|
+
const checkpoints = await service.list();
|
|
20
|
+
if (checkpoints.length === 0) {
|
|
21
|
+
logger.print(pc.dim("no checkpoints yet — one is created automatically before an agent run's first file change"));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const c of checkpoints) {
|
|
25
|
+
const files = `${c.files.length} file${c.files.length === 1 ? "" : "s"}`;
|
|
26
|
+
logger.print(`${pc.cyan(c.id)} ${pc.dim(c.createdAt)} ${pc.dim(`[${c.store}]`)} ${files} ${c.runSummary}`);
|
|
27
|
+
}
|
|
28
|
+
logger.print(pc.dim(`\nrestore one with \`cruxy rollback <id>\` (or \`cruxy rollback\` for the newest)`));
|
|
29
|
+
});
|
|
30
|
+
return cmd;
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `cruxy rollback [id]` (C.32) — restore the working tree to a checkpoint,
|
|
4
|
+
* undoing everything an agent run changed (creates, edits, deletes) in one
|
|
5
|
+
* operation. Destructive by definition, so it is preview-first and gated
|
|
6
|
+
* through U.3 at the destructive tier, ungrantable; non-TTY is refused with a
|
|
7
|
+
* coded error before anything is computed. Out of scope, stated in the
|
|
8
|
+
* preview: commits, pushes, and PRs made during the run are not undone.
|
|
9
|
+
*/
|
|
10
|
+
export declare function rollbackCommand(): Command;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { loadConfig } from "../../config/index.js";
|
|
4
|
+
import { CheckpointService } from "../../checkpoint/index.js";
|
|
5
|
+
import { ApprovalService, defaultPromptIO } from "../../approval/index.js";
|
|
6
|
+
import { fuzzyFind, selectList } from "../../components/index.js";
|
|
7
|
+
import { rollbackApprovalRequired, shouldUseColor, } from "../../errors/index.js";
|
|
8
|
+
import { logger } from "../../utils/logger.js";
|
|
9
|
+
/** One picker row: id, age, and what the run was about. */
|
|
10
|
+
function checkpointLabel(c) {
|
|
11
|
+
return `${c.id} ${c.createdAt} ${c.runSummary}`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Choose which checkpoint to restore when the user gave no id (U.7 dogfood).
|
|
15
|
+
* One checkpoint → it, no ceremony. A short list → arrow-key SelectList with
|
|
16
|
+
* the newest preselected, so Enter-once matches the old "defaults to the
|
|
17
|
+
* most recent" behavior. A long list → type-to-filter FuzzyFinder. Returns
|
|
18
|
+
* `null` on cancel (nothing restored) — cancellation is a result, not an
|
|
19
|
+
* error.
|
|
20
|
+
*/
|
|
21
|
+
async function pickCheckpoint(service) {
|
|
22
|
+
const checkpoints = await service.list(); // newest first
|
|
23
|
+
if (checkpoints.length === 0)
|
|
24
|
+
return undefined; // let rollback() fail loud
|
|
25
|
+
if (checkpoints.length === 1)
|
|
26
|
+
return checkpoints[0];
|
|
27
|
+
const common = {
|
|
28
|
+
title: "pick a checkpoint to roll back to (newest first)",
|
|
29
|
+
nonInteractiveHint: ["or pass the id directly: `cruxy rollback <id>`"],
|
|
30
|
+
// Non-TTY can't reach here (refused above), but the components' own
|
|
31
|
+
// fallback still names the flag if that ever changes.
|
|
32
|
+
defaultValue: checkpoints[0],
|
|
33
|
+
};
|
|
34
|
+
const result = checkpoints.length > 10
|
|
35
|
+
? await fuzzyFind(checkpoints, { ...common, toLabel: checkpointLabel })
|
|
36
|
+
: await selectList(checkpoints, {
|
|
37
|
+
...common,
|
|
38
|
+
toLabel: checkpointLabel,
|
|
39
|
+
initialIndex: 0,
|
|
40
|
+
});
|
|
41
|
+
return result.kind === "selected" ? result.value : null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `cruxy rollback [id]` (C.32) — restore the working tree to a checkpoint,
|
|
45
|
+
* undoing everything an agent run changed (creates, edits, deletes) in one
|
|
46
|
+
* operation. Destructive by definition, so it is preview-first and gated
|
|
47
|
+
* through U.3 at the destructive tier, ungrantable; non-TTY is refused with a
|
|
48
|
+
* coded error before anything is computed. Out of scope, stated in the
|
|
49
|
+
* preview: commits, pushes, and PRs made during the run are not undone.
|
|
50
|
+
*/
|
|
51
|
+
export function rollbackCommand() {
|
|
52
|
+
return new Command("rollback")
|
|
53
|
+
.description("restore the working tree to a checkpoint, undoing an agent run's file changes")
|
|
54
|
+
.argument("[id]", "checkpoint id (defaults to the most recent)")
|
|
55
|
+
.action(async (id) => {
|
|
56
|
+
const interactive = Boolean(process.stdin.isTTY);
|
|
57
|
+
// Refuse before touching anything: rollback is a deliberate, interactive
|
|
58
|
+
// act. There is no flag to bypass this, by design.
|
|
59
|
+
if (!interactive)
|
|
60
|
+
throw rollbackApprovalRequired();
|
|
61
|
+
const { config } = loadConfig();
|
|
62
|
+
const root = process.cwd();
|
|
63
|
+
const service = new CheckpointService({ root, config });
|
|
64
|
+
const approval = new ApprovalService({
|
|
65
|
+
cwd: root,
|
|
66
|
+
interactive,
|
|
67
|
+
io: defaultPromptIO(shouldUseColor()),
|
|
68
|
+
});
|
|
69
|
+
// No id given → pick one interactively (U.7). Enter-once still restores
|
|
70
|
+
// the newest, exactly as before the picker existed.
|
|
71
|
+
if (id === undefined) {
|
|
72
|
+
const picked = await pickCheckpoint(service);
|
|
73
|
+
if (picked === null) {
|
|
74
|
+
logger.print(pc.dim("rollback cancelled — nothing was changed"));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
id = picked?.id;
|
|
78
|
+
}
|
|
79
|
+
const result = await service.rollback(id, {
|
|
80
|
+
requestApproval: (action) => approval.requestApproval(action),
|
|
81
|
+
interactive,
|
|
82
|
+
});
|
|
83
|
+
if (result.kind === "noop") {
|
|
84
|
+
logger.print(pc.dim(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (result.kind === "rejected") {
|
|
88
|
+
logger.print(pc.dim("rollback declined — nothing was changed"));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const { recreated, reverted, deleted } = result.applied;
|
|
92
|
+
logger.print(`${pc.green("✓")} restored checkpoint ${pc.cyan(result.checkpoint.id)} — ` +
|
|
93
|
+
`${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
|
|
94
|
+
logger.print(pc.dim("note: commits, pushes, and PRs made during the run are not undone"));
|
|
95
|
+
});
|
|
96
|
+
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -4,6 +4,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
4
4
|
import { loadConfig, resolveApiKey } from "../../config/index.js";
|
|
5
5
|
import { authMissingKey, usageError } from "../../errors/index.js";
|
|
6
6
|
import { createRenderer } from "../../render/index.js";
|
|
7
|
+
import { CheckpointService } from "../../checkpoint/index.js";
|
|
7
8
|
import { runInteractive } from "../repl.js";
|
|
8
9
|
import { buildAgentSession } from "../session-factory.js";
|
|
9
10
|
import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
|
|
@@ -53,11 +54,18 @@ export function runCommand() {
|
|
|
53
54
|
// One renderer for the whole run (U.2): the streaming path and the
|
|
54
55
|
// approval prompt's status-suspend hook must share the same live region.
|
|
55
56
|
const renderer = createRenderer();
|
|
56
|
-
|
|
57
|
+
// Checkpoint-before-first-mutation (C.32): the service is latched per run
|
|
58
|
+
// and fires from the approval seam inside the session, so one instance
|
|
59
|
+
// covers the one-shot path, every REPL turn, and plan-mode execution.
|
|
60
|
+
const checkpoints = config.checkpoint.enabled
|
|
61
|
+
? new CheckpointService({ root: process.cwd(), config })
|
|
62
|
+
: undefined;
|
|
63
|
+
const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints);
|
|
57
64
|
if (interactive) {
|
|
58
|
-
await runInteractive(session, undefined, renderer);
|
|
65
|
+
await runInteractive(session, undefined, renderer, checkpoints);
|
|
59
66
|
return;
|
|
60
67
|
}
|
|
68
|
+
checkpoints?.beginRun(prompt);
|
|
61
69
|
// One-shot: a single turn, then exit. Preserves scripting/pipe use.
|
|
62
70
|
// Assistant text streams to stdout delta by delta (same as the REPL);
|
|
63
71
|
// piped output degrades to the plain renderer (no ANSI, chrome on stderr).
|
package/dist/cli/program.js
CHANGED
|
@@ -10,6 +10,8 @@ import { skillsCommand } from "./commands/skills.js";
|
|
|
10
10
|
import { prCommand } from "./commands/pr.js";
|
|
11
11
|
import { loginCommand } from "./commands/login.js";
|
|
12
12
|
import { initCommand } from "./commands/init.js";
|
|
13
|
+
import { checkpointCommand } from "./commands/checkpoint.js";
|
|
14
|
+
import { rollbackCommand } from "./commands/rollback.js";
|
|
13
15
|
import { loadConfig } from "../config/index.js";
|
|
14
16
|
import { maybeRunOnboarding } from "./onboard.js";
|
|
15
17
|
export function buildProgram() {
|
|
@@ -36,6 +38,8 @@ export function buildProgram() {
|
|
|
36
38
|
program.addCommand(prCommand());
|
|
37
39
|
program.addCommand(loginCommand());
|
|
38
40
|
program.addCommand(initCommand());
|
|
41
|
+
program.addCommand(checkpointCommand());
|
|
42
|
+
program.addCommand(rollbackCommand());
|
|
39
43
|
// Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
|
|
40
44
|
// means an unknown command (Commander runs the default action with it as an
|
|
41
45
|
// operand rather than erroring), so reject it as a usage error.
|
package/dist/cli/repl.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { Readable, Writable } from "node:stream";
|
|
2
2
|
import type { Session } from "../agent/index.js";
|
|
3
|
+
import type { CheckpointService } from "../checkpoint/index.js";
|
|
3
4
|
import { type StreamRenderer } from "../render/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
|
|
7
|
+
* sync with the dispatch below and the HELP text.
|
|
8
|
+
*/
|
|
9
|
+
export declare const REPL_COMMANDS: readonly ["/help", "/clear", "/compact", "/reload", "/plan", "/exit", "/quit"];
|
|
4
10
|
/** The stdin/stdout pair the REPL reads from and prompts on. Injectable for tests. */
|
|
5
11
|
export interface ReplIO {
|
|
6
12
|
input: Readable;
|
|
@@ -17,4 +23,4 @@ export interface ReplIO {
|
|
|
17
23
|
* live region, anything else the plain append-only renderer); `cruxy run`
|
|
18
24
|
* passes its own so the approval prompt's status-suspend hook shares it.
|
|
19
25
|
*/
|
|
20
|
-
export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer): Promise<void>;
|
|
26
|
+
export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?: CheckpointService): Promise<void>;
|
package/dist/cli/repl.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import readline from "node:readline";
|
|
2
2
|
import pc from "picocolors";
|
|
3
|
+
import { makeReplCompleter } from "../components/index.js";
|
|
3
4
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
4
5
|
import { createRenderer } from "../render/index.js";
|
|
5
6
|
import { logger } from "../utils/logger.js";
|
|
6
7
|
const PROMPT = `${pc.cyan("cruxy")} ${pc.dim("›")} `;
|
|
8
|
+
/**
|
|
9
|
+
* The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
|
|
10
|
+
* sync with the dispatch below and the HELP text.
|
|
11
|
+
*/
|
|
12
|
+
export const REPL_COMMANDS = [
|
|
13
|
+
"/help",
|
|
14
|
+
"/clear",
|
|
15
|
+
"/compact",
|
|
16
|
+
"/reload",
|
|
17
|
+
"/plan",
|
|
18
|
+
"/exit",
|
|
19
|
+
"/quit",
|
|
20
|
+
];
|
|
7
21
|
const HELP = `Commands:
|
|
8
22
|
/help show this help
|
|
9
23
|
/clear clear the conversation history (keep the session)
|
|
@@ -35,6 +49,9 @@ function readLine(io, prompt) {
|
|
|
35
49
|
const rl = readline.createInterface({
|
|
36
50
|
input: io.input,
|
|
37
51
|
output: io.output,
|
|
52
|
+
// Tab-completion for slash commands (U.7): readline rewrites the edit
|
|
53
|
+
// buffer only — completing never submits, Enter remains the sole trigger.
|
|
54
|
+
completer: makeReplCompleter(() => REPL_COMMANDS),
|
|
38
55
|
});
|
|
39
56
|
return new Promise((resolve) => {
|
|
40
57
|
let answered = false;
|
|
@@ -74,16 +91,16 @@ function printReplError(err) {
|
|
|
74
91
|
* live region, anything else the plain append-only renderer); `cruxy run`
|
|
75
92
|
* passes its own so the approval prompt's status-suspend hook shares it.
|
|
76
93
|
*/
|
|
77
|
-
export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr)) {
|
|
94
|
+
export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr), checkpoints) {
|
|
78
95
|
logger.print(pc.dim("interactive session — /help for commands, /exit or Ctrl+D to quit"));
|
|
79
96
|
try {
|
|
80
|
-
await replLoop(session, io, renderer);
|
|
97
|
+
await replLoop(session, io, renderer, checkpoints);
|
|
81
98
|
}
|
|
82
99
|
finally {
|
|
83
100
|
renderer.close();
|
|
84
101
|
}
|
|
85
102
|
}
|
|
86
|
-
async function replLoop(session, io, renderer) {
|
|
103
|
+
async function replLoop(session, io, renderer, checkpoints) {
|
|
87
104
|
for (;;) {
|
|
88
105
|
const line = await readLine(io, PROMPT);
|
|
89
106
|
// EOF / Ctrl+D.
|
|
@@ -138,6 +155,9 @@ async function replLoop(session, io, renderer) {
|
|
|
138
155
|
// line. Errors (provider/API failures) log and return to the prompt rather
|
|
139
156
|
// than killing the REPL.
|
|
140
157
|
try {
|
|
158
|
+
// Each REPL turn is its own undo unit (C.32): a fresh checkpoint latch,
|
|
159
|
+
// so `cruxy rollback` reverts exactly one turn's mutations.
|
|
160
|
+
checkpoints?.beginRun(trimmed);
|
|
141
161
|
await session.send(line, renderer);
|
|
142
162
|
}
|
|
143
163
|
catch (err) {
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { ApprovalDecision } from "../approval/index.js";
|
|
3
|
+
import type { CheckpointService } from "../checkpoint/index.js";
|
|
2
4
|
import type { StreamRenderer } from "../render/index.js";
|
|
5
|
+
import { type ApproveAction } from "../tools/index.js";
|
|
3
6
|
import { Session } from "../agent/index.js";
|
|
7
|
+
/**
|
|
8
|
+
* Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
|
|
9
|
+
* whole point: a tool mutates only *after* `requestApproval` resolves, so
|
|
10
|
+
* snapshotting after an `allow` decision but before returning it means the
|
|
11
|
+
* checkpoint always lands before the run's first mutation — and a denied
|
|
12
|
+
* action never creates one. The same seam records which paths the run touched
|
|
13
|
+
* (file actions) or that attribution is lost (shell), for rollback's
|
|
14
|
+
* external-change detection.
|
|
15
|
+
*/
|
|
16
|
+
export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>, checkpoints: CheckpointService | undefined, cwd: string): (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
4
17
|
/**
|
|
5
18
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
6
19
|
* shared by `cruxy run` and the onboarding first-win task (so they can't drift).
|
|
@@ -10,4 +23,4 @@ import { Session } from "../agent/index.js";
|
|
|
10
23
|
* over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
|
|
11
24
|
* approves → executes. Plan mode is fully opt-in; the default path is unchanged.
|
|
12
25
|
*/
|
|
13
|
-
export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer): Session;
|
|
26
|
+
export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService): Session;
|