backstep 0.0.0-stage → 0.1.1
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 +116 -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/check.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// What Backstep can undo for each service Claude Code is connected to, and
|
|
2
|
+
// what to connect next. Setup prints it, `backstep status` prints it, and the
|
|
3
|
+
// Claude Code mod checks it as a session starts, so nobody assumes a service
|
|
4
|
+
// is covered when it isn't.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { configServers, projectUses } from "./connect.js";
|
|
7
|
+
import { supabaseProjects } from "./supabase.js";
|
|
8
|
+
import { watchRules } from "./watch.js";
|
|
9
|
+
import { dim, pink, white, width, wrap } from "./look.js";
|
|
10
|
+
const WRAPPED = /(^|[/\s])(backstep|cli\.[jt]s) wrap\b/;
|
|
11
|
+
const SUPABASE = /supabase/i;
|
|
12
|
+
const FIREBASE = /firebase|firestore/i;
|
|
13
|
+
// `claude mcp list` sees everything a session would, plugins and claude.ai
|
|
14
|
+
// connectors included, and whether each works; it prints "name: target - ✔ Connected".
|
|
15
|
+
function listed(dir) {
|
|
16
|
+
const r = spawnSync("claude", ["mcp", "list"], { cwd: dir, encoding: "utf8", timeout: 90_000 });
|
|
17
|
+
if (r.status !== 0 || !r.stdout)
|
|
18
|
+
return null;
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const line of r.stdout.split("\n")) {
|
|
21
|
+
const m = line.match(/^(.+?): (.*) - ([✔!✘]) /);
|
|
22
|
+
if (m)
|
|
23
|
+
out.push({ name: m[1], target: m[2], health: m[3] === "✔" ? "ok" : m[3] === "!" ? "auth" : "failed" });
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
// "claude.ai Notion" → "Notion", "plugin:vercel:vercel" → "vercel".
|
|
28
|
+
const label = (name) => name.replace(/^claude\.ai /, "").replace(/^plugin:(?:[^:]+:)?/, "");
|
|
29
|
+
function classify(s, supabaseConnected, ruled) {
|
|
30
|
+
const what = `${s.name} ${s.target}`;
|
|
31
|
+
const name = label(s.name);
|
|
32
|
+
const wrapped = WRAPPED.test(s.target);
|
|
33
|
+
// Wrapped at the next session start: servers in Claude Code's config, not plugins or claude.ai connectors.
|
|
34
|
+
const wrappable = !wrapped && s.target !== "" && !s.name.startsWith("plugin:") && !s.name.startsWith("claude.ai ");
|
|
35
|
+
const signIn = s.health === "auth" || undefined;
|
|
36
|
+
if (s.health === "failed")
|
|
37
|
+
return { name, level: "off", detail: "check it with /mcp in Claude Code" };
|
|
38
|
+
if (SUPABASE.test(what)) {
|
|
39
|
+
return supabaseConnected
|
|
40
|
+
? { name, level: "undoable", detail: "rows, tables and migrations", signIn }
|
|
41
|
+
: { name, level: "connect", detail: "rows, tables and migrations, once it's connected", signIn };
|
|
42
|
+
}
|
|
43
|
+
if (FIREBASE.test(what)) {
|
|
44
|
+
if (wrapped)
|
|
45
|
+
return { name, level: "undoable", detail: "Firestore, Realtime Database, Remote Config", signIn };
|
|
46
|
+
if (wrappable)
|
|
47
|
+
return { name, level: "connect", detail: "undoable from the next Claude Code session", signIn };
|
|
48
|
+
}
|
|
49
|
+
if (ruled.has(s.name.replace(/[^a-zA-Z0-9_-]/g, "_")))
|
|
50
|
+
return { name, level: "undoable", detail: "the tools your undo rules cover", signIn };
|
|
51
|
+
if (wrapped)
|
|
52
|
+
return { name, level: "recorded", detail: "except file and database changes, which undo", signIn };
|
|
53
|
+
if (wrappable)
|
|
54
|
+
return { name, level: "recorded", detail: "goes behind Backstep from the next Claude Code session", signIn };
|
|
55
|
+
// Recorded only: printed as one line for all of them.
|
|
56
|
+
return { name, level: "recorded", detail: "", signIn };
|
|
57
|
+
}
|
|
58
|
+
// `quick` reads Claude Code's config instead of asking `claude mcp list`,
|
|
59
|
+
// which starts every server: for a check as a session starts.
|
|
60
|
+
export function check(dir, opts = {}) {
|
|
61
|
+
const found = (opts.quick ? null : listed(dir)) ?? configServers(dir);
|
|
62
|
+
const connected = supabaseProjects().length > 0;
|
|
63
|
+
const ruled = new Set(watchRules().map((r) => r.server ?? "").filter(Boolean));
|
|
64
|
+
const items = found.map((s) => classify(s, connected, ruled));
|
|
65
|
+
const uses = projectUses(dir);
|
|
66
|
+
const suggest = [];
|
|
67
|
+
const supabaseHere = items.some((i) => SUPABASE.test(i.name)) || uses.supabase;
|
|
68
|
+
if (!connected && supabaseHere) {
|
|
69
|
+
suggest.push({ title: "Connect Supabase", why: "so Claude's database changes can be undone", how: 'Ask Claude: "connect Backstep to Supabase"' });
|
|
70
|
+
}
|
|
71
|
+
if (uses.firebase && !items.some((i) => FIREBASE.test(i.name))) {
|
|
72
|
+
suggest.push({ title: "Add Firebase", why: "so Claude can reach it and its changes can be undone", how: 'Ask Claude: "connect Backstep to Firebase"' });
|
|
73
|
+
}
|
|
74
|
+
return { items, suggest };
|
|
75
|
+
}
|
|
76
|
+
// Short and friendly: what undoes, the one thing worth connecting, and a
|
|
77
|
+
// single line for everything else. Claude's other tools that have nothing to
|
|
78
|
+
// do with undo (ones that are broken, or need signing in) aren't Backstep's to list.
|
|
79
|
+
export function printCheck(c, bs = "backstep") {
|
|
80
|
+
const cols = width() - 6;
|
|
81
|
+
const undoable = [...new Set(c.items.filter((i) => i.level === "undoable").map((i) => i.name))];
|
|
82
|
+
const soon = [...new Set(c.items.filter((i) => i.level === "connect" && FIREBASE.test(i.name)).map((i) => i.name))];
|
|
83
|
+
const supabase = c.items.find((i) => SUPABASE.test(i.name));
|
|
84
|
+
const supabaseLeft = supabase?.level === "connect" || c.suggest.some((s) => s.title === "Connect Supabase");
|
|
85
|
+
const other = [...new Set(c.items.filter((i) => i.level === "recorded").map((i) => i.name))];
|
|
86
|
+
console.log(`\n ${white("What ctrl+z can undo")}`);
|
|
87
|
+
console.log(` ${pink("✓")} Files Claude edits and commands it runs`);
|
|
88
|
+
console.log(` ${pink("✓")} Code Claude pushes to GitHub ${dim("(automatic, nothing to connect)")}`);
|
|
89
|
+
for (const n of undoable)
|
|
90
|
+
console.log(` ${pink("✓")} ${n}`);
|
|
91
|
+
for (const n of soon)
|
|
92
|
+
console.log(` ${pink("✓")} ${n} ${dim("(from your next Claude Code session)")}`);
|
|
93
|
+
console.log("");
|
|
94
|
+
if (supabaseLeft) {
|
|
95
|
+
console.log(` ${white("Connect Supabase to undo database changes too")}`);
|
|
96
|
+
console.log(` ${pink("→")} ${wrap("Open your project in Claude Code. Backstep checks it for Supabase and asks if you want to connect.", cols, 6)}`);
|
|
97
|
+
console.log(` ${pink("→")} Or just ask Claude: ${white('"connect Backstep to Supabase"')}`);
|
|
98
|
+
console.log("");
|
|
99
|
+
}
|
|
100
|
+
else if (!supabase || supabase.level !== "undoable") {
|
|
101
|
+
console.log(` ${dim(wrap("Use Supabase? Open your project in Claude Code and Backstep will check for it and offer to connect.", cols, 2))}`);
|
|
102
|
+
console.log("");
|
|
103
|
+
}
|
|
104
|
+
if (c.suggest.some((s) => s.title === "Add Firebase")) {
|
|
105
|
+
console.log(` ${white("Your project uses Firebase")}`);
|
|
106
|
+
console.log(` ${pink("→")} Ask Claude: ${white('"connect Backstep to Firebase"')}`);
|
|
107
|
+
console.log("");
|
|
108
|
+
}
|
|
109
|
+
if (other.length) {
|
|
110
|
+
const names = other.length > 3 ? `${other.slice(0, 2).join(", ")} and ${other.length - 2} more` : other.join(", ");
|
|
111
|
+
console.log(` ${dim(wrap(`Changes in your other tools (${names}) show up in Backstep's history, but can't be undone.`, cols, 2))}`);
|
|
112
|
+
console.log("");
|
|
113
|
+
}
|
|
114
|
+
console.log(` ${pink(`${bs} status`)} ${dim("see this again")}`);
|
|
115
|
+
console.log(` ${pink(`${bs} ui`.padEnd(bs.length + 7))} ${dim("see everything Claude changed")}`);
|
|
116
|
+
console.log("");
|
|
117
|
+
}
|
package/dist/classify.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { parseSql } from "./sql.js";
|
|
2
|
+
const READ_VERB = /^(get|list|read|search|query|fetch|describe|find|show|view|lookup|check|count|inspect|browse|stat|head|preview|directory_tree|tree)(_|$|[A-Z])/i;
|
|
3
|
+
const READ_ANYWHERE = /(^|_)(get|list|read|search|find|describe)(_|$)/i;
|
|
4
|
+
const IRREVERSIBLE = /(^|_|-)(send|email|mail|sms|message|reply|post|publish|tweet|pay|charge|transfer|refund|payout|invoice|deploy|release|merge|notify|call|dial|purchase|buy|order|checkout|book|sign|submit|broadcast|exec|execute|run|shell|command)(_|-|$)/i;
|
|
5
|
+
const PATH_KEY = /^(path|paths|file|files|filename|filepath|file_path|source|destination|src|dest|target|dir|directory)$/i;
|
|
6
|
+
const SQL_KEY = /^(sql|query|statement|statements)$/i;
|
|
7
|
+
const LOOKS_SQL = /^\s*(?:--[^\n]*\n\s*)*(select|insert|update|delete|truncate|drop|alter|create|with|show|explain|values|table|grant|revoke|begin|commit|copy|vacuum|comment|merge)\b/i;
|
|
8
|
+
function findSql(args) {
|
|
9
|
+
for (const [k, v] of Object.entries(args)) {
|
|
10
|
+
// "query" is also what search tools call their search box, so require it to read like SQL.
|
|
11
|
+
if (SQL_KEY.test(k) && typeof v === "string" && LOOKS_SQL.test(v))
|
|
12
|
+
return { key: k, sql: v };
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
function findPaths(args) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const [k, v] of Object.entries(args)) {
|
|
19
|
+
if (!PATH_KEY.test(k))
|
|
20
|
+
continue;
|
|
21
|
+
if (typeof v === "string")
|
|
22
|
+
out.push(v);
|
|
23
|
+
else if (Array.isArray(v))
|
|
24
|
+
out.push(...v.filter((x) => typeof x === "string"));
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
export function planCall(tool, name, args) {
|
|
29
|
+
const ann = tool?.annotations;
|
|
30
|
+
const sql = findSql(args);
|
|
31
|
+
// SQL tools are judged by the statement, not the tool name: "query" can drop a table.
|
|
32
|
+
if (sql) {
|
|
33
|
+
const statements = parseSql(sql.sql);
|
|
34
|
+
if (statements.length > 0 && statements.every((s) => s.op === "read"))
|
|
35
|
+
return { kind: "read" };
|
|
36
|
+
return { kind: "sql", sql: sql.sql, argKey: sql.key };
|
|
37
|
+
}
|
|
38
|
+
if (ann?.readOnlyHint === true)
|
|
39
|
+
return { kind: "read" };
|
|
40
|
+
const looksRead = READ_VERB.test(name) || (READ_ANYWHERE.test(name) && !IRREVERSIBLE.test(name));
|
|
41
|
+
if (looksRead && ann?.destructiveHint !== true)
|
|
42
|
+
return { kind: "read" };
|
|
43
|
+
if (IRREVERSIBLE.test(name))
|
|
44
|
+
return { kind: "irreversible", reason: `"${name}" acts outside anything Backstep can snapshot` };
|
|
45
|
+
const paths = findPaths(args);
|
|
46
|
+
if (paths.length > 0)
|
|
47
|
+
return { kind: "paths", paths };
|
|
48
|
+
if (ann?.destructiveHint === true)
|
|
49
|
+
return { kind: "irreversible", reason: "the server marks this tool destructive" };
|
|
50
|
+
if (ann?.openWorldHint === true)
|
|
51
|
+
return { kind: "irreversible", reason: "the server marks this tool as reaching the outside world" };
|
|
52
|
+
return { kind: "write" };
|
|
53
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { runProxy } from "./proxy.js";
|
|
5
|
+
import { startUi } from "./ui/server.js";
|
|
6
|
+
import { Store, snapshotDir } from "./store.js";
|
|
7
|
+
import { undoAction, undoRun, undoSince } from "./undo.js";
|
|
8
|
+
import { snapshotPaths } from "./adapters/fs.js";
|
|
9
|
+
import { notePushed, snapshotTree } from "./adapters/tree.js";
|
|
10
|
+
import { revertPush } from "./push.js";
|
|
11
|
+
const PUSH = /\bgit\b[^;&|]*\bpush\b/;
|
|
12
|
+
import { classifyShell } from "./shell.js";
|
|
13
|
+
import { watchEnd, watchStart } from "./watch.js";
|
|
14
|
+
import { sendActive } from "./telemetry.js";
|
|
15
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
16
|
+
import { loadRules } from "./rules.js";
|
|
17
|
+
import { setup } from "./setup.js";
|
|
18
|
+
import { autoWrap, printChanges, unwrapServers, wrapServers } from "./servers.js";
|
|
19
|
+
import { disconnectSupabase, dismissSupabase } from "./supabase.js";
|
|
20
|
+
import { connect } from "./connect.js";
|
|
21
|
+
import { check, printCheck } from "./check.js";
|
|
22
|
+
import { logo } from "./look.js";
|
|
23
|
+
import { askConnectionString } from "./setup.js";
|
|
24
|
+
// Header values can name env vars, so tokens don't have to sit in agent config files.
|
|
25
|
+
function parseHeaders(list) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const h of list) {
|
|
28
|
+
const i = h.indexOf(":");
|
|
29
|
+
if (i < 1)
|
|
30
|
+
throw new Error(`--header must look like "Name: value", got "${h}"`);
|
|
31
|
+
out[h.slice(0, i).trim()] = h
|
|
32
|
+
.slice(i + 1)
|
|
33
|
+
.trim()
|
|
34
|
+
.replace(/\$\{(\w+)\}/g, (_, v) => {
|
|
35
|
+
const val = process.env[v];
|
|
36
|
+
if (val === undefined)
|
|
37
|
+
throw new Error(`--header uses \${${v}}, which isn't set`);
|
|
38
|
+
return val;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
// Setup passes the headers of a wrapped remote server as BACKSTEP_HEADER_0, _1, … (see servers.ts).
|
|
44
|
+
function envHeaders() {
|
|
45
|
+
return Object.keys(process.env)
|
|
46
|
+
.filter((k) => /^BACKSTEP_HEADER_\d+$/.test(k))
|
|
47
|
+
.sort((a, b) => Number(a.slice(16)) - Number(b.slice(16)))
|
|
48
|
+
.map((k) => process.env[k]);
|
|
49
|
+
}
|
|
50
|
+
const HELP = `backstep: undo for everything your agents touch
|
|
51
|
+
|
|
52
|
+
backstep setup install the Claude Code mod, bind ctrl+z to undo Claude's last turn,
|
|
53
|
+
and put your MCP servers behind Backstep
|
|
54
|
+
backstep unwrap take your MCP servers back out from behind Backstep
|
|
55
|
+
backstep connect [supabase|firebase] [<access-token>|<connection-string>] [--project <ref>] [--dir <folder>] [--json]
|
|
56
|
+
connect what the project uses, so Claude's changes to it can be undone:
|
|
57
|
+
Supabase from the project's .env files or local database (or the
|
|
58
|
+
connection string you give), Firebase by adding its MCP server.
|
|
59
|
+
In Claude Code you can just ask: "connect Backstep to my Supabase"
|
|
60
|
+
backstep status [--json] what Backstep can and can't undo for each service Claude Code reaches,
|
|
61
|
+
and what to connect next
|
|
62
|
+
backstep disconnect supabase <ref>
|
|
63
|
+
backstep wrap-servers [--project <dir>] [--json]
|
|
64
|
+
put MCP servers added since setup behind Backstep (the Claude Code mod runs this)
|
|
65
|
+
|
|
66
|
+
backstep wrap [options] -- <command> [args...]
|
|
67
|
+
backstep wrap [options] --url <https://server/mcp>
|
|
68
|
+
Run an MCP server behind Backstep. Point your agent at this instead.
|
|
69
|
+
--name <name> label shown in the dashboard (default: the command)
|
|
70
|
+
--url <url> wrap a remote MCP server (Streamable HTTP, falling back to SSE)
|
|
71
|
+
--header "K: V" send a header to the remote server; \${VAR} reads an env var (repeatable)
|
|
72
|
+
--rules <file> undo rules: which tool undoes which (see examples/rules.json)
|
|
73
|
+
--pg <url> Postgres URL the wrapped server writes to; enables database undo
|
|
74
|
+
|
|
75
|
+
backstep ui [--port 4747] open the dashboard
|
|
76
|
+
backstep log [--limit 30] print recent actions
|
|
77
|
+
backstep undo <action-id> undo one action (and later ones that touched the same things)
|
|
78
|
+
backstep undo --run <run-id> undo everything a run did
|
|
79
|
+
backstep undo --since <ms> [--until <ms>] [--json] [--via-claude-code]
|
|
80
|
+
undo everything any agent did in that window
|
|
81
|
+
backstep snap --session <id> --tool <name> [--cwd <dir>] -- <paths...>
|
|
82
|
+
save paths before Claude Code's own tools change them (used by the Claude Code mod)
|
|
83
|
+
backstep revert-push --root <repo>
|
|
84
|
+
undo code Claude pushed, on the remote, with a new commit (the push as JSON on stdin;
|
|
85
|
+
the Claude Code mod runs this after asking)
|
|
86
|
+
backstep shell --session <id> [--cwd <dir>] -- <command>
|
|
87
|
+
snapshot the project before Claude Code runs a shell command (used by the Claude Code mod)
|
|
88
|
+
|
|
89
|
+
Example (Claude Code):
|
|
90
|
+
claude mcp add files -- backstep wrap --name files -- npx -y @modelcontextprotocol/server-filesystem ~/project
|
|
91
|
+
`;
|
|
92
|
+
async function main() {
|
|
93
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
94
|
+
if (cmd === "wrap") {
|
|
95
|
+
const sep = rest.indexOf("--");
|
|
96
|
+
const { values } = parseArgs({
|
|
97
|
+
args: sep === -1 ? rest : rest.slice(0, sep),
|
|
98
|
+
options: {
|
|
99
|
+
name: { type: "string" },
|
|
100
|
+
url: { type: "string" },
|
|
101
|
+
header: { type: "string", multiple: true, default: [] },
|
|
102
|
+
rules: { type: "string" },
|
|
103
|
+
pg: { type: "string" },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const [command, ...args] = sep === -1 ? [] : rest.slice(sep + 1);
|
|
107
|
+
if (!command && !values.url)
|
|
108
|
+
throw new Error("usage: backstep wrap [options] -- <command> [args...] or --url <url>");
|
|
109
|
+
if (command && values.url)
|
|
110
|
+
throw new Error("give either a command or --url, not both");
|
|
111
|
+
await runProxy({
|
|
112
|
+
name: values.name ?? command ?? new URL(values.url).host,
|
|
113
|
+
command,
|
|
114
|
+
args,
|
|
115
|
+
url: values.url,
|
|
116
|
+
headers: parseHeaders([...values.header, ...envHeaders()]),
|
|
117
|
+
rules: loadRules(values.rules ?? process.env.BACKSTEP_RULES),
|
|
118
|
+
pgUrl: values.pg ?? process.env.BACKSTEP_PG_URL ?? null,
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (cmd === "setup") {
|
|
123
|
+
await setup();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// The mod runs this as Claude Code starts; it counts at most once a day.
|
|
127
|
+
if (cmd === "ping") {
|
|
128
|
+
await sendActive();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (cmd === "unwrap") {
|
|
132
|
+
autoWrap(false);
|
|
133
|
+
const changes = unwrapServers();
|
|
134
|
+
if (changes.length === 0)
|
|
135
|
+
console.log("No MCP servers are behind Backstep.");
|
|
136
|
+
printChanges(changes);
|
|
137
|
+
if (changes.some((c) => c.done))
|
|
138
|
+
console.log("\nRestart Claude Code to use them directly again.");
|
|
139
|
+
console.log("New MCP servers won't be put behind Backstep either, until you run backstep setup again.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Run by the Claude Code mod as a session starts: wraps servers added since setup.
|
|
143
|
+
if (cmd === "wrap-servers") {
|
|
144
|
+
const { values } = parseArgs({ args: rest, options: { project: { type: "string" }, json: { type: "boolean", default: false } } });
|
|
145
|
+
const changes = autoWrap() ? wrapServers(fileURLToPath(import.meta.url), { onlyDir: values.project }) : [];
|
|
146
|
+
if (values.json)
|
|
147
|
+
console.log(JSON.stringify(changes));
|
|
148
|
+
else
|
|
149
|
+
printChanges(changes);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Connects whatever the project uses; the Claude Code mod runs this when the
|
|
153
|
+
// person asks Claude to, passing a connection string they gave on stdin.
|
|
154
|
+
if (cmd === "connect") {
|
|
155
|
+
const { values, positionals } = parseArgs({
|
|
156
|
+
args: rest,
|
|
157
|
+
allowPositionals: true,
|
|
158
|
+
options: {
|
|
159
|
+
project: { type: "string" },
|
|
160
|
+
dir: { type: "string" },
|
|
161
|
+
json: { type: "boolean", default: false },
|
|
162
|
+
stdin: { type: "boolean", default: false },
|
|
163
|
+
auto: { type: "boolean", default: false },
|
|
164
|
+
dismiss: { type: "boolean", default: false },
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
const [service, given] = positionals;
|
|
168
|
+
if (service && service !== "supabase" && service !== "firebase")
|
|
169
|
+
throw new Error(`can't connect "${service}": Backstep connects supabase and firebase`);
|
|
170
|
+
// "Don't ask for this project", from the question the Claude Code mod asks.
|
|
171
|
+
if (values.dismiss) {
|
|
172
|
+
dismissSupabase(values.dir ?? process.cwd());
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const opts = {
|
|
176
|
+
dir: values.dir ?? process.cwd(),
|
|
177
|
+
cli: fileURLToPath(import.meta.url),
|
|
178
|
+
only: service,
|
|
179
|
+
url: given ?? (values.stdin ? readFileSync(0, "utf8").trim() || undefined : undefined),
|
|
180
|
+
ref: values.project,
|
|
181
|
+
auto: values.auto,
|
|
182
|
+
interactive: !values.json && process.stdin.isTTY === true,
|
|
183
|
+
};
|
|
184
|
+
let out = await connect(opts);
|
|
185
|
+
// In a terminal, ask for what couldn't be found.
|
|
186
|
+
if (!values.json && !opts.url && service === "supabase" && out[0].state === "needs" && process.stdin.isTTY) {
|
|
187
|
+
out = await connect({ ...opts, url: await askConnectionString() });
|
|
188
|
+
}
|
|
189
|
+
if (values.json) {
|
|
190
|
+
console.log(JSON.stringify(out));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
for (const s of out)
|
|
194
|
+
console.log(`${s.service === "supabase" ? "Supabase" : "Firebase"}: ${s.detail}`);
|
|
195
|
+
if (out.some((s) => s.state === "needs" && s.service === "supabase"))
|
|
196
|
+
console.log("Then run: backstep connect supabase <connection-string>");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// What each service Claude Code reaches can and can't undo, and what to connect.
|
|
200
|
+
if (cmd === "status") {
|
|
201
|
+
const { values } = parseArgs({ args: rest, options: { dir: { type: "string" }, json: { type: "boolean", default: false }, quick: { type: "boolean", default: false } } });
|
|
202
|
+
const c = check(values.dir ?? process.cwd(), { quick: values.quick });
|
|
203
|
+
if (values.json)
|
|
204
|
+
console.log(JSON.stringify(c));
|
|
205
|
+
else {
|
|
206
|
+
console.log(logo("what Claude's changes can be undone"));
|
|
207
|
+
printCheck(c);
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (cmd === "disconnect") {
|
|
212
|
+
const [service, ref] = rest;
|
|
213
|
+
if (service !== "supabase" || !ref)
|
|
214
|
+
throw new Error("usage: backstep disconnect supabase <ref>");
|
|
215
|
+
console.log(disconnectSupabase(ref) ? `Disconnected ${ref}.` : `${ref} wasn't connected.`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (cmd === "ui") {
|
|
219
|
+
const { values } = parseArgs({ args: rest, options: { port: { type: "string", default: "4747" } } });
|
|
220
|
+
await startUi(Number(values.port));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (cmd === "log") {
|
|
224
|
+
const { values } = parseArgs({ args: rest, options: { limit: { type: "string", default: "30" } } });
|
|
225
|
+
const store = new Store();
|
|
226
|
+
for (const a of store.feed(Number(values.limit)).reverse()) {
|
|
227
|
+
const when = new Date(a.created_at).toLocaleTimeString();
|
|
228
|
+
const undo = a.undo && (a.status === "ok" || a.status === "error") ? " [undoable]" : "";
|
|
229
|
+
console.log(`${when} ${a.id} ${a.server}/${a.tool} ${a.kind} ${a.status}${undo}`);
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (cmd === "snap") {
|
|
234
|
+
const sep = rest.indexOf("--");
|
|
235
|
+
const { values } = parseArgs({
|
|
236
|
+
args: sep === -1 ? rest : rest.slice(0, sep),
|
|
237
|
+
options: { session: { type: "string" }, tool: { type: "string" }, cwd: { type: "string" } },
|
|
238
|
+
});
|
|
239
|
+
const paths = sep === -1 ? [] : rest.slice(sep + 1);
|
|
240
|
+
if (!values.session || !values.tool || paths.length === 0) {
|
|
241
|
+
throw new Error("usage: backstep snap --session <id> --tool <name> [--cwd <dir>] -- <paths...>");
|
|
242
|
+
}
|
|
243
|
+
const store = new Store();
|
|
244
|
+
const run = store.sessionRun("claude-code", `session:${values.session}`);
|
|
245
|
+
const a = store.addAction({
|
|
246
|
+
run_id: run.id,
|
|
247
|
+
tool: values.tool,
|
|
248
|
+
args: JSON.stringify({ paths }),
|
|
249
|
+
kind: "reversible",
|
|
250
|
+
status: "ok",
|
|
251
|
+
reason: null,
|
|
252
|
+
undo: null,
|
|
253
|
+
result: null,
|
|
254
|
+
error: null,
|
|
255
|
+
release_at: null,
|
|
256
|
+
});
|
|
257
|
+
const undo = snapshotPaths(a.id, paths, values.cwd ?? process.cwd());
|
|
258
|
+
if (undo)
|
|
259
|
+
store.updateAction(a.id, { undo: JSON.stringify(undo) });
|
|
260
|
+
else
|
|
261
|
+
store.updateAction(a.id, { kind: "write", reason: "too large to snapshot" });
|
|
262
|
+
console.log(JSON.stringify({ id: a.id, undoable: undo !== null }));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Prints {kind, reason?, id?, undoable}: "read" is let through untouched, "outbound"
|
|
266
|
+
// is recorded but can't be undone, and anything else gets the project snapshotted.
|
|
267
|
+
if (cmd === "shell") {
|
|
268
|
+
const sep = rest.indexOf("--");
|
|
269
|
+
const { values } = parseArgs({
|
|
270
|
+
args: sep === -1 ? rest : rest.slice(0, sep),
|
|
271
|
+
options: { session: { type: "string" }, cwd: { type: "string" } },
|
|
272
|
+
});
|
|
273
|
+
const command = sep === -1 ? "" : rest.slice(sep + 1).join(" ");
|
|
274
|
+
if (!values.session || !command)
|
|
275
|
+
throw new Error("usage: backstep shell --session <id> [--cwd <dir>] -- <command>");
|
|
276
|
+
const plan = classifyShell(command);
|
|
277
|
+
if (plan.kind === "read") {
|
|
278
|
+
console.log(JSON.stringify({ kind: "read", undoable: false }));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const store = new Store();
|
|
282
|
+
const run = store.sessionRun("claude-code", `session:${values.session}`);
|
|
283
|
+
const a = store.addAction({
|
|
284
|
+
run_id: run.id,
|
|
285
|
+
tool: "Bash",
|
|
286
|
+
args: JSON.stringify({ command }),
|
|
287
|
+
kind: plan.kind === "outbound" ? "irreversible" : "reversible",
|
|
288
|
+
status: "ok",
|
|
289
|
+
reason: plan.reason ?? null,
|
|
290
|
+
undo: null,
|
|
291
|
+
result: null,
|
|
292
|
+
error: null,
|
|
293
|
+
release_at: null,
|
|
294
|
+
});
|
|
295
|
+
// Outbound commands still get a snapshot: whatever they change locally can come back.
|
|
296
|
+
// A push also notes where the remote branches stood, so undo can take it back there too.
|
|
297
|
+
const undo = snapshotTree(a.id, values.cwd ?? process.cwd(), { pushes: PUSH.test(command) });
|
|
298
|
+
if (undo)
|
|
299
|
+
store.updateAction(a.id, { undo: JSON.stringify(undo) });
|
|
300
|
+
else if (plan.kind !== "outbound")
|
|
301
|
+
store.updateAction(a.id, { kind: "write", reason: "project too large to snapshot" });
|
|
302
|
+
console.log(JSON.stringify({ kind: plan.kind, reason: plan.reason, id: a.id, undoable: undo !== null }));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// After a shell command that pushed: notes what it sent (run by the Claude Code mod).
|
|
306
|
+
if (cmd === "shell-end") {
|
|
307
|
+
const store = new Store();
|
|
308
|
+
const a = store.getAction(rest[0] ?? "");
|
|
309
|
+
const u = a?.undo ? JSON.parse(a.undo) : null;
|
|
310
|
+
if (a && u?.adapter === "tree" && u.git?.remotes) {
|
|
311
|
+
const pushed = notePushed(u);
|
|
312
|
+
if (pushed.length)
|
|
313
|
+
store.updateAction(a.id, { undo: JSON.stringify({ ...u, pushed }) });
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// Puts an undo of code Claude pushed on the remote, as a new commit. The push, as undo reported it, comes on stdin.
|
|
318
|
+
if (cmd === "revert-push") {
|
|
319
|
+
const { values } = parseArgs({ args: rest, options: { root: { type: "string" } } });
|
|
320
|
+
if (!values.root)
|
|
321
|
+
throw new Error("usage: backstep revert-push --root <repo> (the push as JSON on stdin)");
|
|
322
|
+
const out = revertPush(values.root, JSON.parse(readFileSync(0, "utf8")));
|
|
323
|
+
console.log(JSON.stringify(out));
|
|
324
|
+
if (!out.ok)
|
|
325
|
+
process.exitCode = 1;
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// The mod reports MCP calls to servers Backstep isn't in front of. The call's
|
|
329
|
+
// arguments, and later its result, come as JSON on stdin.
|
|
330
|
+
if (cmd === "watch-start") {
|
|
331
|
+
const sep = rest.indexOf("--");
|
|
332
|
+
const { values } = parseArgs({ args: sep === -1 ? rest : rest.slice(0, sep), options: { session: { type: "string" }, cwd: { type: "string" } } });
|
|
333
|
+
const tool = sep === -1 ? "" : rest[sep + 1];
|
|
334
|
+
if (!values.session || !tool)
|
|
335
|
+
throw new Error("usage: backstep watch-start --session <id> [--cwd <dir>] -- <mcp__server__tool> (arguments as JSON on stdin)");
|
|
336
|
+
const input = readFileSync(0, "utf8").trim();
|
|
337
|
+
console.log(JSON.stringify(await watchStart(new Store(), values.session, values.cwd ?? process.cwd(), tool, input ? JSON.parse(input) : {})));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (cmd === "watch-end") {
|
|
341
|
+
const input = readFileSync(0, "utf8").trim();
|
|
342
|
+
console.log(JSON.stringify(await watchEnd(new Store(), rest[0] ?? "", input ? JSON.parse(input) : {})));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// Claude Code ran an opposite call `undo --via-claude-code` handed it.
|
|
346
|
+
if (cmd === "settle") {
|
|
347
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { failed: { type: "string" } } });
|
|
348
|
+
const store = new Store();
|
|
349
|
+
const id = positionals[0] ?? "";
|
|
350
|
+
if (!store.getAction(id))
|
|
351
|
+
throw new Error(`no action ${id}`);
|
|
352
|
+
if (values.failed !== undefined)
|
|
353
|
+
store.updateAction(id, { status: "undo_failed", error: values.failed });
|
|
354
|
+
else
|
|
355
|
+
store.updateAction(id, { status: "undone", undone_at: Date.now() });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// A call the mod reported was refused before it ran.
|
|
359
|
+
if (cmd === "cancel") {
|
|
360
|
+
const store = new Store();
|
|
361
|
+
const id = rest[0] ?? "";
|
|
362
|
+
const a = store.getAction(id);
|
|
363
|
+
if (!a)
|
|
364
|
+
throw new Error(`no action ${id}`);
|
|
365
|
+
store.updateAction(id, { status: "cancelled", undo: null });
|
|
366
|
+
rmSync(snapshotDir(id), { recursive: true, force: true });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (cmd === "undo") {
|
|
370
|
+
const { values, positionals } = parseArgs({
|
|
371
|
+
args: rest,
|
|
372
|
+
allowPositionals: true,
|
|
373
|
+
options: {
|
|
374
|
+
run: { type: "string" },
|
|
375
|
+
since: { type: "string" },
|
|
376
|
+
until: { type: "string" },
|
|
377
|
+
json: { type: "boolean", default: false },
|
|
378
|
+
"via-claude-code": { type: "boolean", default: false },
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
const store = new Store();
|
|
382
|
+
const outcomes = values.since
|
|
383
|
+
? await undoSince(store, Number(values.since), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"] })
|
|
384
|
+
: values.run
|
|
385
|
+
? await undoRun(store, values.run)
|
|
386
|
+
: await undoAction(store, positionals[0] ?? "");
|
|
387
|
+
if (values.json) {
|
|
388
|
+
console.log(JSON.stringify(outcomes));
|
|
389
|
+
if (outcomes.some((o) => !o.ok))
|
|
390
|
+
process.exitCode = 1;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (outcomes.length === 0)
|
|
394
|
+
console.log("Nothing to undo.");
|
|
395
|
+
for (const o of outcomes)
|
|
396
|
+
console.log(`${o.ok ? "undone" : "FAILED"} ${o.tool} (${o.id})\n ${o.detail.replaceAll("\n", "\n ")}`);
|
|
397
|
+
if (outcomes.some((o) => !o.ok))
|
|
398
|
+
process.exitCode = 1;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
process.stdout.write(HELP);
|
|
402
|
+
if (cmd && cmd !== "help" && cmd !== "--help" && cmd !== "-h")
|
|
403
|
+
process.exitCode = 1;
|
|
404
|
+
}
|
|
405
|
+
main().catch((err) => {
|
|
406
|
+
process.stderr.write(`backstep: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
407
|
+
process.exit(1);
|
|
408
|
+
});
|