backstep 0.0.0-stage → 0.1.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 +118 -2
- package/dist/adapters/fs.js +66 -0
- package/dist/adapters/postgres.js +220 -0
- package/dist/adapters/tree.js +171 -0
- package/dist/check.js +117 -0
- package/dist/classify.js +53 -0
- package/dist/cli.js +408 -0
- package/dist/connect.js +237 -0
- package/dist/firebase.js +137 -0
- package/dist/look.js +34 -0
- package/dist/proxy.js +285 -0
- package/dist/push.js +108 -0
- package/dist/rules.js +86 -0
- package/dist/servers.js +226 -0
- package/dist/setup.js +109 -0
- package/dist/shell.js +112 -0
- package/dist/sql.js +88 -0
- package/dist/store.js +132 -0
- package/dist/supabase.js +245 -0
- package/dist/telemetry.js +77 -0
- package/dist/ui/index.html +246 -0
- package/dist/ui/server.js +70 -0
- package/dist/undo.js +176 -0
- package/dist/watch.js +138 -0
- package/mod/.claude-plugin/marketplace.json +5 -0
- package/mod/.claude-plugin/plugin.json +14 -0
- package/mod/hooks/hooks.json +1 -0
- package/mod/hooks/register.test.tsx +432 -0
- package/mod/hooks/register.tsx +529 -0
- package/mod/types/index.d.ts +30 -0
- package/package.json +48 -4
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// The proxy: speaks MCP to the agent on stdio, runs the real server as a child
|
|
2
|
+
// process, and decides per call whether to snapshot or just pass through.
|
|
3
|
+
import { rmSync } from "node:fs";
|
|
4
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
5
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
6
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
7
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
8
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
import { planCall } from "./classify.js";
|
|
12
|
+
import { snapshotPaths } from "./adapters/fs.js";
|
|
13
|
+
import { discardSnapshots, planSql } from "./adapters/postgres.js";
|
|
14
|
+
import { firebasePlan, ReadFailed } from "./firebase.js";
|
|
15
|
+
import { buildInverse, findRule } from "./rules.js";
|
|
16
|
+
import { CONNECT_HINT, isSupabaseSql, refFromArgs, supabasePlanOptions, supabaseUrl } from "./supabase.js";
|
|
17
|
+
import { snapshotDir, Store } from "./store.js";
|
|
18
|
+
const CALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
19
|
+
const UNDO_POLL_MS = 500;
|
|
20
|
+
function summarize(result) {
|
|
21
|
+
const text = (result.content ?? [])
|
|
22
|
+
.map((c) => (c.type === "text" ? c.text : `[${c.type}]`))
|
|
23
|
+
.join("\n");
|
|
24
|
+
return text.length > 2000 ? `${text.slice(0, 2000)}…` : text;
|
|
25
|
+
}
|
|
26
|
+
function textResult(text, isError = false) {
|
|
27
|
+
return { content: [{ type: "text", text }], isError };
|
|
28
|
+
}
|
|
29
|
+
export async function runProxy(opts) {
|
|
30
|
+
const store = new Store();
|
|
31
|
+
const target = opts.url ?? [opts.command, ...opts.args].join(" ");
|
|
32
|
+
const run = store.createRun(opts.name, target, opts.pgUrl);
|
|
33
|
+
const log = (msg) => process.stderr.write(`[backstep:${opts.name}] ${msg}\n`);
|
|
34
|
+
const upstream = await connectUpstream(opts);
|
|
35
|
+
const caps = upstream.getServerCapabilities() ?? {};
|
|
36
|
+
const info = upstream.getServerVersion();
|
|
37
|
+
const server = new Server({ name: `backstep:${info?.name ?? opts.name}`, version: info?.version ?? "0.0.0" }, {
|
|
38
|
+
capabilities: {
|
|
39
|
+
tools: { listChanged: true },
|
|
40
|
+
...(caps.resources ? { resources: {} } : {}),
|
|
41
|
+
...(caps.prompts ? { prompts: {} } : {}),
|
|
42
|
+
},
|
|
43
|
+
instructions: upstream.getInstructions(),
|
|
44
|
+
});
|
|
45
|
+
const tools = new Map();
|
|
46
|
+
const refreshTools = async () => {
|
|
47
|
+
tools.clear();
|
|
48
|
+
let cursor;
|
|
49
|
+
do {
|
|
50
|
+
const page = await upstream.listTools(cursor ? { cursor } : {});
|
|
51
|
+
for (const t of page.tools)
|
|
52
|
+
tools.set(t.name, t);
|
|
53
|
+
cursor = page.nextCursor;
|
|
54
|
+
} while (cursor);
|
|
55
|
+
};
|
|
56
|
+
await refreshTools();
|
|
57
|
+
upstream.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
58
|
+
await refreshTools();
|
|
59
|
+
await server.sendToolListChanged();
|
|
60
|
+
});
|
|
61
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...tools.values()] }));
|
|
62
|
+
if (caps.resources) {
|
|
63
|
+
server.setRequestHandler(ListResourcesRequestSchema, (req) => upstream.listResources(req.params));
|
|
64
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, (req) => upstream.listResourceTemplates(req.params));
|
|
65
|
+
server.setRequestHandler(ReadResourceRequestSchema, (req) => upstream.readResource(req.params));
|
|
66
|
+
}
|
|
67
|
+
if (caps.prompts) {
|
|
68
|
+
server.setRequestHandler(ListPromptsRequestSchema, (req) => upstream.listPrompts(req.params));
|
|
69
|
+
server.setRequestHandler(GetPromptRequestSchema, (req) => upstream.getPrompt(req.params));
|
|
70
|
+
}
|
|
71
|
+
const forward = async (name, args) => (await upstream.callTool({ name, arguments: args }, undefined, {
|
|
72
|
+
timeout: CALL_TIMEOUT_MS,
|
|
73
|
+
resetTimeoutOnProgress: true,
|
|
74
|
+
}));
|
|
75
|
+
const record = (tool, args, kind, reason) => store.addAction({
|
|
76
|
+
run_id: run.id,
|
|
77
|
+
tool,
|
|
78
|
+
args: JSON.stringify(args),
|
|
79
|
+
kind,
|
|
80
|
+
status: "running",
|
|
81
|
+
reason,
|
|
82
|
+
undo: null,
|
|
83
|
+
result: null,
|
|
84
|
+
error: null,
|
|
85
|
+
release_at: null,
|
|
86
|
+
});
|
|
87
|
+
const finish = async (a, undo, call) => {
|
|
88
|
+
try {
|
|
89
|
+
const result = await call();
|
|
90
|
+
store.updateAction(a.id, {
|
|
91
|
+
status: result.isError ? "error" : "ok",
|
|
92
|
+
undo: undo ? JSON.stringify(undo) : null,
|
|
93
|
+
result: summarize(result),
|
|
94
|
+
});
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
99
|
+
// Keep undo data on failure: the call may have changed things before it threw.
|
|
100
|
+
store.updateAction(a.id, { status: "error", undo: undo ? JSON.stringify(undo) : null, error: msg });
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
// Runs a call Backstep can't take back, recording why.
|
|
105
|
+
const unguarded = (a, name, args, kind, reason) => {
|
|
106
|
+
store.updateAction(a.id, { kind, reason });
|
|
107
|
+
return finish(a, null, () => forward(name, args));
|
|
108
|
+
};
|
|
109
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
110
|
+
const name = req.params.name;
|
|
111
|
+
const args = (req.params.arguments ?? {});
|
|
112
|
+
if (!tools.has(name))
|
|
113
|
+
await refreshTools();
|
|
114
|
+
const rule = findRule(opts.rules, opts.name, name);
|
|
115
|
+
if (rule) {
|
|
116
|
+
const a = record(name, args, "reversible", null);
|
|
117
|
+
const result = await finish(a, null, () => forward(name, args));
|
|
118
|
+
if (!result.isError) {
|
|
119
|
+
const built = buildInverse(rule, opts.name, args, result);
|
|
120
|
+
if ("undo" in built)
|
|
121
|
+
store.updateAction(a.id, { undo: JSON.stringify(built.undo) });
|
|
122
|
+
else
|
|
123
|
+
store.updateAction(a.id, { kind: "write", reason: `undo rule needs "${built.missing}", which wasn't there` });
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
// Firebase: read what's there through the server first, then keep the call that puts it back.
|
|
128
|
+
const fb = firebasePlan(name, args);
|
|
129
|
+
if (fb) {
|
|
130
|
+
const a = record(name, args, "reversible", null);
|
|
131
|
+
const before = fb.read ? await forward(fb.read.tool, fb.read.args).catch(() => null) : null;
|
|
132
|
+
const result = await finish(a, null, () => forward(name, args));
|
|
133
|
+
if (result.isError)
|
|
134
|
+
return result;
|
|
135
|
+
try {
|
|
136
|
+
const inv = fb.undo(before, result);
|
|
137
|
+
if (inv)
|
|
138
|
+
store.updateAction(a.id, { undo: JSON.stringify({ adapter: "inverse", server: opts.name, ...inv }) });
|
|
139
|
+
else
|
|
140
|
+
store.updateAction(a.id, { reason: "nothing changed" });
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (!(err instanceof ReadFailed))
|
|
144
|
+
throw err;
|
|
145
|
+
store.updateAction(a.id, { kind: "write", reason: err.message });
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
const plan = planCall(tools.get(name), name, args);
|
|
150
|
+
switch (plan.kind) {
|
|
151
|
+
case "read": {
|
|
152
|
+
const a = record(name, args, "read", null);
|
|
153
|
+
return finish(a, null, () => forward(name, args));
|
|
154
|
+
}
|
|
155
|
+
case "write": {
|
|
156
|
+
const a = record(name, args, "write", "Backstep has no way to snapshot what this tool changes");
|
|
157
|
+
return finish(a, null, () => forward(name, args));
|
|
158
|
+
}
|
|
159
|
+
case "irreversible": {
|
|
160
|
+
const a = record(name, args, "irreversible", plan.reason);
|
|
161
|
+
return finish(a, null, () => forward(name, args));
|
|
162
|
+
}
|
|
163
|
+
case "paths": {
|
|
164
|
+
const a = record(name, args, "reversible", null);
|
|
165
|
+
const undo = snapshotPaths(a.id, plan.paths, process.cwd());
|
|
166
|
+
if (!undo) {
|
|
167
|
+
rmSync(snapshotDir(a.id), { recursive: true, force: true });
|
|
168
|
+
return unguarded(a, name, args, "write", "the files are too large to snapshot");
|
|
169
|
+
}
|
|
170
|
+
return finish(a, undo, () => forward(name, args));
|
|
171
|
+
}
|
|
172
|
+
case "sql": {
|
|
173
|
+
const a = record(name, args, "reversible", null);
|
|
174
|
+
const supabase = isSupabaseSql(opts.name, name, target);
|
|
175
|
+
const pgUrl = opts.pgUrl ?? (supabase ? await supabaseUrl(refFromArgs(args, opts.args)) : null);
|
|
176
|
+
if (!pgUrl)
|
|
177
|
+
return unguarded(a, name, args, "write", supabase ? CONNECT_HINT : "database writes can't be undone without --pg");
|
|
178
|
+
let p;
|
|
179
|
+
try {
|
|
180
|
+
p = await planSql(pgUrl, a.id, plan.sql, supabase ? supabasePlanOptions(name) : {});
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
184
|
+
store.updateAction(a.id, { status: "error", error: msg });
|
|
185
|
+
return textResult(`Backstep could not run this SQL: ${msg}`, true);
|
|
186
|
+
}
|
|
187
|
+
if (p.mode === "irreversible")
|
|
188
|
+
return unguarded(a, name, args, "irreversible", p.reason);
|
|
189
|
+
if (p.mode === "self") {
|
|
190
|
+
return finish(a, p.undo, async () => textResult(`${p.summary}\n\n(Backstep kept a restorable copy of any dropped table.)`));
|
|
191
|
+
}
|
|
192
|
+
const undo = { ...p.undo, ...(pgUrl !== opts.pgUrl ? { url: pgUrl } : {}) };
|
|
193
|
+
const sent = p.mode === "rewrite" ? { ...args, [plan.argKey]: p.sql } : args;
|
|
194
|
+
const result = await finish(a, undo, () => forward(name, sent));
|
|
195
|
+
// Postgres runs a multi-statement string as one implicit transaction, so a
|
|
196
|
+
// failed call changed nothing and the snapshots are dead weight.
|
|
197
|
+
if (result.isError) {
|
|
198
|
+
await discardSnapshots(pgUrl, undo).catch(() => { });
|
|
199
|
+
store.updateAction(a.id, { undo: null });
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
// Undo by inverse tool call has to go through a live connection to the server,
|
|
206
|
+
// so the dashboard queues the request and whichever proxy serves that server
|
|
207
|
+
// name runs it, including requests left over from earlier sessions.
|
|
208
|
+
let undoing = false;
|
|
209
|
+
const undoTimer = setInterval(async () => {
|
|
210
|
+
if (undoing)
|
|
211
|
+
return;
|
|
212
|
+
undoing = true;
|
|
213
|
+
try {
|
|
214
|
+
const pending = store.db
|
|
215
|
+
.prepare(`SELECT a.* FROM actions a JOIN runs r ON r.id = a.run_id
|
|
216
|
+
WHERE a.status = 'undo_requested' AND r.server = ? ORDER BY a.created_at DESC`)
|
|
217
|
+
.all(opts.name);
|
|
218
|
+
for (const a of pending) {
|
|
219
|
+
if (!store.transition(a.id, "undo_requested", "undoing"))
|
|
220
|
+
continue;
|
|
221
|
+
const inv = JSON.parse(a.undo);
|
|
222
|
+
try {
|
|
223
|
+
const r = await forward(inv.tool, inv.args);
|
|
224
|
+
if (r.isError)
|
|
225
|
+
store.updateAction(a.id, { status: "undo_failed", error: summarize(r) });
|
|
226
|
+
else
|
|
227
|
+
store.updateAction(a.id, { status: "undone", undone_at: Date.now(), result: summarize(r) });
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
store.updateAction(a.id, { status: "undo_failed", error: err instanceof Error ? err.message : String(err) });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
undoing = false;
|
|
236
|
+
}
|
|
237
|
+
}, UNDO_POLL_MS);
|
|
238
|
+
let closed = false;
|
|
239
|
+
const shutdown = async () => {
|
|
240
|
+
if (closed)
|
|
241
|
+
return;
|
|
242
|
+
closed = true;
|
|
243
|
+
clearInterval(undoTimer);
|
|
244
|
+
store.endRun(run.id);
|
|
245
|
+
await upstream.close().catch(() => { });
|
|
246
|
+
store.close();
|
|
247
|
+
};
|
|
248
|
+
server.onclose = () => void shutdown();
|
|
249
|
+
// The stdio transport doesn't notice the agent hanging up, so watch stdin
|
|
250
|
+
// ourselves; otherwise the wrapped server outlives every session.
|
|
251
|
+
process.stdin.on("end", () => void shutdown().then(() => process.exit(0)));
|
|
252
|
+
process.on("SIGINT", () => void shutdown().then(() => process.exit(0)));
|
|
253
|
+
process.on("SIGTERM", () => void shutdown().then(() => process.exit(0)));
|
|
254
|
+
await server.connect(new StdioServerTransport());
|
|
255
|
+
log(`run ${run.id} started, wrapping: ${run.command}`);
|
|
256
|
+
}
|
|
257
|
+
async function connectUpstream(opts) {
|
|
258
|
+
const make = () => new Client({ name: "backstep", version: "0.1.0" });
|
|
259
|
+
if (opts.url) {
|
|
260
|
+
const url = new URL(opts.url);
|
|
261
|
+
const requestInit = { headers: opts.headers };
|
|
262
|
+
const client = make();
|
|
263
|
+
try {
|
|
264
|
+
await client.connect(new StreamableHTTPClientTransport(url, { requestInit }));
|
|
265
|
+
return client;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Older servers only speak the SSE transport.
|
|
269
|
+
const legacy = make();
|
|
270
|
+
await legacy.connect(new SSEClientTransport(url, { requestInit }));
|
|
271
|
+
return legacy;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!opts.command)
|
|
275
|
+
throw new Error("nothing to wrap: give a command or --url");
|
|
276
|
+
const client = make();
|
|
277
|
+
await client.connect(new StdioClientTransport({
|
|
278
|
+
command: opts.command,
|
|
279
|
+
args: opts.args,
|
|
280
|
+
env: process.env,
|
|
281
|
+
cwd: process.cwd(),
|
|
282
|
+
stderr: "inherit",
|
|
283
|
+
}));
|
|
284
|
+
return client;
|
|
285
|
+
}
|
package/dist/push.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Taking back code Claude pushed. Undo rewinds the local repository; this puts
|
|
2
|
+
// the same undo on the remote (GitHub, GitLab, …) as one new commit, pushed
|
|
3
|
+
// the ordinary way: no force push, no rewritten history, so it works on
|
|
4
|
+
// protected branches and for anyone who already pulled. It uses the project's
|
|
5
|
+
// own git and its sign-in, so if Claude could push, so can this.
|
|
6
|
+
//
|
|
7
|
+
// The commit is made in a temporary worktree, so the person's own folder is
|
|
8
|
+
// never touched. Anything that would clash (newer commits that conflict, a
|
|
9
|
+
// branch someone rewrote or added to) stops it and says why.
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
const TIMEOUT_MS = 120_000;
|
|
15
|
+
// The person's own git: their name on the commit, their hooks, their sign-in.
|
|
16
|
+
function git(cwd, args, input) {
|
|
17
|
+
return execFileSync("git", args, {
|
|
18
|
+
cwd,
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
timeout: TIMEOUT_MS,
|
|
21
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
22
|
+
input,
|
|
23
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
24
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
25
|
+
}).trim();
|
|
26
|
+
}
|
|
27
|
+
function tryGit(cwd, args) {
|
|
28
|
+
try {
|
|
29
|
+
return git(cwd, args);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const why = (err) => {
|
|
36
|
+
const e = err;
|
|
37
|
+
return (e.stderr || e.message || String(err)).trim().split("\n").filter(Boolean).at(-1) ?? "git failed";
|
|
38
|
+
};
|
|
39
|
+
export function revertPush(root, p) {
|
|
40
|
+
const where = `${p.remote}/${p.branch}`;
|
|
41
|
+
const ref = `refs/remotes/${p.remote}/${p.branch}`;
|
|
42
|
+
try {
|
|
43
|
+
git(root, ["fetch", "--quiet", p.remote, `+refs/heads/${p.branch}:${ref}`]);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (p.before === null && /couldn't find remote ref/i.test(why(err)))
|
|
47
|
+
return { ok: true, detail: `${p.branch} is already gone from ${p.host}` };
|
|
48
|
+
return { ok: false, detail: `couldn't reach ${p.host}: ${why(err)}` };
|
|
49
|
+
}
|
|
50
|
+
const head = tryGit(root, ["rev-parse", "-q", "--verify", ref]);
|
|
51
|
+
if (!head)
|
|
52
|
+
return { ok: false, detail: `${where} is gone from ${p.host}` };
|
|
53
|
+
// A branch the push made: taking it back means deleting it, but only while nobody has added to it.
|
|
54
|
+
if (p.before === null) {
|
|
55
|
+
if (head !== p.after)
|
|
56
|
+
return { ok: false, detail: `left ${p.branch} on ${p.host}: commits were added to it after Claude pushed it` };
|
|
57
|
+
try {
|
|
58
|
+
git(root, ["push", "--quiet", p.remote, "--delete", p.branch]);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
return { ok: false, detail: `couldn't delete ${p.branch} on ${p.host}: ${why(err)}` };
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, detail: `deleted the branch ${p.branch} Claude pushed to ${p.host}` };
|
|
64
|
+
}
|
|
65
|
+
if (tryGit(root, ["merge-base", "--is-ancestor", p.after, head]) === null) {
|
|
66
|
+
return { ok: false, detail: `left ${where} alone: its history was rewritten after Claude pushed` };
|
|
67
|
+
}
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), "backstep-push-"));
|
|
69
|
+
const patch = join(dir, "undo.patch");
|
|
70
|
+
const tree = join(dir, "tree");
|
|
71
|
+
try {
|
|
72
|
+
git(root, ["worktree", "add", "--quiet", "--detach", tree, head]);
|
|
73
|
+
// What turns Claude's commits back, applied on top of whatever is there now.
|
|
74
|
+
writeFileSync(patch, git(root, ["diff", "--binary", p.after, p.before]) + "\n");
|
|
75
|
+
if (git(root, ["diff", "--stat", p.after, p.before]) === "")
|
|
76
|
+
return { ok: true, detail: `Claude's commits on ${where} changed no files` };
|
|
77
|
+
try {
|
|
78
|
+
git(tree, ["apply", "--3way", "--index", patch]);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { ok: false, detail: `left ${where} alone: newer commits there clash with undoing Claude's` };
|
|
82
|
+
}
|
|
83
|
+
const n = p.commits === 1 ? "1 commit" : `${p.commits} commits`;
|
|
84
|
+
git(tree, ["commit", "--quiet", "-m", "Undo Claude's changes", "-m", `Reverts ${p.before.slice(0, 7)}..${p.after.slice(0, 7)} (${n}), undone with Backstep.`]);
|
|
85
|
+
const undo = git(tree, ["rev-parse", "HEAD"]);
|
|
86
|
+
try {
|
|
87
|
+
git(tree, ["push", "--quiet", p.remote, `HEAD:refs/heads/${p.branch}`]);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return { ok: false, detail: `couldn't push the undo to ${where}: ${why(err)}` };
|
|
91
|
+
}
|
|
92
|
+
// The local branch is where undo left it, with the same files as the undo
|
|
93
|
+
// commit: move it up so the next pull has nothing to fight.
|
|
94
|
+
const local = `refs/heads/${p.branch}`;
|
|
95
|
+
const sameFiles = tryGit(root, ["rev-parse", `${undo}^{tree}`]) === tryGit(root, ["rev-parse", `${p.before}^{tree}`]);
|
|
96
|
+
if (sameFiles && tryGit(root, ["rev-parse", "-q", "--verify", local]) === p.before) {
|
|
97
|
+
tryGit(root, ["update-ref", local, undo, p.before]);
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, detail: `pushed a commit to ${where} that undoes Claude's ${n}` };
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
return { ok: false, detail: `couldn't undo the push to ${where}: ${why(err)}` };
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
tryGit(root, ["worktree", "remove", "--force", tree]);
|
|
106
|
+
rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Undo rules: for tools Backstep can't snapshot, undo by calling the server's
|
|
2
|
+
// own opposite tool. A rule maps a tool to its inverse, filling the inverse's
|
|
3
|
+
// arguments from the original call and its result:
|
|
4
|
+
//
|
|
5
|
+
// { "tool": "create_issue",
|
|
6
|
+
// "inverse": { "tool": "update_issue",
|
|
7
|
+
// "args": { "owner": "{{args.owner}}", "repo": "{{args.repo}}",
|
|
8
|
+
// "issue_number": "{{result.number}}", "state": "closed" } } }
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
export function loadRules(file) {
|
|
11
|
+
if (!file)
|
|
12
|
+
return [];
|
|
13
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
14
|
+
const rules = Array.isArray(raw) ? raw : raw.rules;
|
|
15
|
+
if (!Array.isArray(rules))
|
|
16
|
+
throw new Error(`${file}: expected a list of rules`);
|
|
17
|
+
for (const r of rules) {
|
|
18
|
+
if (typeof r?.tool !== "string" || typeof r?.inverse?.tool !== "string") {
|
|
19
|
+
throw new Error(`${file}: every rule needs "tool" and "inverse.tool"`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return rules;
|
|
23
|
+
}
|
|
24
|
+
export function findRule(rules, server, tool) {
|
|
25
|
+
return rules.find((r) => r.tool === tool && (r.server === undefined || r.server === server));
|
|
26
|
+
}
|
|
27
|
+
// Tools return JSON as text more often than as structuredContent, so try both.
|
|
28
|
+
export function resultData(result) {
|
|
29
|
+
if (result.structuredContent)
|
|
30
|
+
return result.structuredContent;
|
|
31
|
+
const first = result.content?.find((c) => c.type === "text");
|
|
32
|
+
if (!first || first.type !== "text")
|
|
33
|
+
return undefined;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(first.text);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return first.text;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function lookup(scope, path) {
|
|
42
|
+
let cur = scope;
|
|
43
|
+
for (const part of path.split(".")) {
|
|
44
|
+
if (cur === null || typeof cur !== "object")
|
|
45
|
+
return undefined;
|
|
46
|
+
cur = cur[part];
|
|
47
|
+
}
|
|
48
|
+
return cur;
|
|
49
|
+
}
|
|
50
|
+
class Missing extends Error {
|
|
51
|
+
}
|
|
52
|
+
function render(value, scope) {
|
|
53
|
+
if (typeof value === "string") {
|
|
54
|
+
// A lone placeholder keeps the original type, so numbers stay numbers.
|
|
55
|
+
const whole = value.match(/^\{\{\s*([\w.]+)\s*\}\}$/);
|
|
56
|
+
if (whole) {
|
|
57
|
+
const v = lookup(scope, whole[1]);
|
|
58
|
+
if (v === undefined)
|
|
59
|
+
throw new Missing(whole[1]);
|
|
60
|
+
return v;
|
|
61
|
+
}
|
|
62
|
+
return value.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path) => {
|
|
63
|
+
const v = lookup(scope, path);
|
|
64
|
+
if (v === undefined)
|
|
65
|
+
throw new Missing(path);
|
|
66
|
+
return typeof v === "string" ? v : JSON.stringify(v);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (Array.isArray(value))
|
|
70
|
+
return value.map((v) => render(v, scope));
|
|
71
|
+
if (value && typeof value === "object") {
|
|
72
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, render(v, scope)]));
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
export function buildInverse(rule, server, args, result) {
|
|
77
|
+
try {
|
|
78
|
+
const inverseArgs = render(rule.inverse.args ?? {}, { args, result: resultData(result) });
|
|
79
|
+
return { undo: { adapter: "inverse", server, tool: rule.inverse.tool, args: inverseArgs } };
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (err instanceof Missing)
|
|
83
|
+
return { missing: err.message };
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|