backstep 0.1.2 → 0.1.4

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 CHANGED
@@ -8,6 +8,7 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
8
8
  - **Supabase:** `execute_sql` and `apply_migration` snapshot the tables they change, over a connection to the project's database. Backstep never connects it on its own: when Claude Code opens a project that uses Supabase and isn't connected, it asks (Connect, Not now, or Don't ask for this project). Connect uses a connection string in the project's `.env` files, the local `supabase start` database, or a Supabase sign-in (`supabase login`, `SUPABASE_ACCESS_TOKEN`, or an access token you give Backstep once), with which the project gets a temporary database login that Backstep renews as it runs out. You can also ask Claude to "connect Backstep to Supabase" any time. Deleted rows, dropped tables and migrations, including the migration's history row, all come back. Works for the claude.ai Supabase connector and a local Supabase MCP server alike.
9
9
  - **Firebase:** before a change, Backstep reads what's there through the Firebase MCP server itself, and undoes through it too, so there's nothing to set up. Firestore documents updated, deleted or added; Realtime Database writes; Remote Config publishes (rolled back to the version before); and users enabled or disabled.
10
10
  - **claude.ai connectors and servers Claude Code signs in to:** Backstep can't sit in front of these, so the Claude Code mod watches their calls instead. Calls with an undo rule are undone through Claude Code's own connection.
11
+ - **Database commands Claude runs** (`supabase db push`, `supabase migration up`, `supabase db reset`, `psql`, `prisma migrate`, `drizzle-kit push`, seed scripts…): in a project whose Supabase database is connected, the whole database is noted before the command runs, and ctrl+z puts it back in one transaction: rows, new tables dropped, dropped or altered tables rebuilt with their constraints, indexes, foreign keys, row level security policies, triggers and grants, sequence counters, and the migration history. Copies sit in a `backstep` schema in the same database; the newest 10 per database are kept. On a very big database it keeps every table's structure and the migration history, and rows smallest-table-first up to a budget (`BACKSTEP_DB_SNAPSHOT_MB`, default 250); undo names any table whose rows it couldn't bring back. Functions, views, types and extensions aren't restored.
11
12
  - **Code Claude pushed:** ctrl+z rewinds the commits locally, then asks whether to undo them on GitHub (or any remote) too. Yes pushes one new commit that undoes Claude's, made in a temporary worktree: no force push, no rewritten history, so it works on protected branches and for teammates who already pulled. Newer commits that clash, or a rewritten branch, stop it. A branch Claude created is deleted instead, if nobody has added to it. It uses the project's own git sign-in, so there's nothing to connect.
12
13
  - **Things that can't be undone** (sending email, payments, deploys, `ALTER TABLE`…) run as normal and are marked in the journal, so you know what an undo didn't cover.
13
14
  - **Anything else, by rule:** a rules file maps a tool to its opposite (`create_issue` → close the issue, `send_message` → retract it), filled in from the call and its result. See [Undo rules](#undo-rules).
@@ -52,6 +53,8 @@ With Claude Code: `claude mcp add files -- backstep wrap --name files -- <the se
52
53
  | `--rules <file>` | undo rules (or set `BACKSTEP_RULES`) |
53
54
  | `--pg <url>` | the Postgres database the wrapped server writes to. Enables database undo. Without it, SQL writes are recorded but can't be undone. |
54
55
 
56
+ The undo key is ⌘Z and ctrl+z on a Mac (⌘Z where the terminal passes it on, as Ghostty, Kitty and WezTerm do; Terminal.app and iTerm2 keep ⌘Z for their own menu) and ctrl+z elsewhere. The first press asks "Undo Claude's last turn?" above the prompt and a second press within 5 seconds undoes, so a stray press does nothing. Change the key whenever you like: ask Claude ("change my Backstep undo key to ctrl+u"), or run `backstep key ctrl+u`. The line above the prompt is always there, even with nothing to undo, because the key only reaches Backstep while it's on screen; otherwise ctrl+z would fall through to the terminal and suspend Claude Code.
57
+
55
58
  Backstep checks npm for a newer version as each Claude Code session starts and asks before updating (Update, Not now, or Skip this version; `backstep update` does it by hand, `BACKSTEP_UPDATE_CHECK=0` turns the check off).
56
59
 
57
60
  Other commands: `backstep ui`, `backstep log`, `backstep undo <action-id>`, `backstep undo --run <run-id>`, `backstep connect [supabase|firebase]` (connects what the project uses, the same as asking Claude), `backstep disconnect supabase <ref>`.
@@ -0,0 +1,265 @@
1
+ // Database undo for shell commands (`supabase db push`, `supabase migration up`,
2
+ // `psql`, `prisma migrate`, seed scripts…), which change a database Backstep
3
+ // can't see into. Before the command runs, the whole database's own tables are
4
+ // noted:
5
+ // - every table's structure: columns, constraints, indexes, foreign keys,
6
+ // row level security and its policies, triggers, grants, sequence counters;
7
+ // - every table's rows, copied into Backstep's own schema in the same
8
+ // database, smallest tables first, until a size budget is spent. A very big
9
+ // database keeps its structure, its migration history and as many tables'
10
+ // rows as fit; undo names the tables whose rows it couldn't bring back.
11
+ // Undo, in one transaction: drops tables made since, rebuilds tables dropped
12
+ // or reshaped, and puts back rows wherever they changed.
13
+ import pg from "pg";
14
+ import { q } from "./postgres.js";
15
+ const SNAP_SCHEMA = "backstep";
16
+ const budget = () => Number(process.env.BACKSTEP_DB_SNAPSHOT_MB ?? 250) * 1024 * 1024;
17
+ // Supabase's and Postgres's own schemas: managed by them, not by the project's migrations.
18
+ const SYSTEM = [
19
+ "information_schema", "auth", "storage", "realtime", "_realtime", "extensions", "graphql", "graphql_public", "pgsodium",
20
+ "pgsodium_masks", "vault", "net", "supabase_functions", "cron", "pgbouncer", "_analytics", "_supavisor", "pgtle", "topology",
21
+ "tiger", "tiger_data",
22
+ ];
23
+ const MIGRATIONS = "supabase_migrations";
24
+ async function withClient(url, fn) {
25
+ const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 10_000 });
26
+ await client.connect();
27
+ try {
28
+ return await fn(client);
29
+ }
30
+ finally {
31
+ await client.end();
32
+ }
33
+ }
34
+ async function tables(c) {
35
+ const r = await c.query(`SELECT c.oid::int AS oid, n.nspname AS schema, c.relname AS name, pg_total_relation_size(c.oid)::bigint AS bytes
36
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
37
+ WHERE c.relkind = 'r' AND NOT c.relispartition
38
+ AND n.nspname NOT LIKE 'pg\\_%' AND n.nspname NOT LIKE 'backstep%' AND n.nspname <> ALL($1::text[])
39
+ ORDER BY n.nspname, c.relname`, [SYSTEM]);
40
+ return r.rows.map((x) => ({ oid: Number(x.oid), schema: x.schema, name: x.name, bytes: Number(x.bytes) }));
41
+ }
42
+ const full = (t) => `${q(t.schema)}.${q(t.name)}`;
43
+ async function shapeOf(c, oid) {
44
+ const r = await c.query(`SELECT string_agg(attname || ' ' || format_type(atttypid, atttypmod), ', ' ORDER BY attnum) AS s
45
+ FROM pg_attribute WHERE attrelid = $1 AND attnum > 0 AND NOT attisdropped`, [oid]);
46
+ return r.rows[0].s ?? "";
47
+ }
48
+ async function describe(c, t) {
49
+ const name = full(t);
50
+ const cols = await c.query(`SELECT a.attname, format_type(a.atttypid, a.atttypmod) AS type, a.attnotnull, a.attidentity, a.attgenerated,
51
+ pg_get_expr(d.adbin, d.adrelid) AS def
52
+ FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
53
+ WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, [t.oid]);
54
+ const seqs = await c.query(`SELECT s.oid::regclass::text AS name, a.attname
55
+ FROM pg_depend dep JOIN pg_class s ON s.oid = dep.objid AND s.relkind = 'S'
56
+ JOIN pg_attribute a ON a.attrelid = dep.refobjid AND a.attnum = dep.refobjsubid
57
+ WHERE dep.refobjid = $1 AND dep.deptype IN ('a', 'i')`, [t.oid]);
58
+ const seqValues = [];
59
+ const create = [];
60
+ // Serial columns' sequences go with their table; make them first so the defaults resolve.
61
+ const serial = seqs.rows.filter((s) => !cols.rows.find((col) => col.attname === s.attname)?.attidentity);
62
+ for (const s of serial)
63
+ create.push(`CREATE SEQUENCE IF NOT EXISTS ${s.name}`);
64
+ const defs = cols.rows.map((col) => {
65
+ let d = `${q(col.attname)} ${col.type}`;
66
+ if (col.attgenerated === "s")
67
+ d += ` GENERATED ALWAYS AS (${col.def}) STORED`;
68
+ else if (col.attidentity)
69
+ d += ` GENERATED ${col.attidentity === "a" ? "ALWAYS" : "BY DEFAULT"} AS IDENTITY`;
70
+ else if (col.def !== null)
71
+ d += ` DEFAULT ${col.def}`;
72
+ if (col.attnotnull)
73
+ d += " NOT NULL";
74
+ return d;
75
+ });
76
+ create.push(`CREATE TABLE ${name} (${defs.join(", ")})`);
77
+ for (const s of serial)
78
+ create.push(`ALTER SEQUENCE ${s.name} OWNED BY ${name}.${q(s.attname)}`);
79
+ for (const s of seqs.rows) {
80
+ const v = await c.query(`SELECT last_value::text AS v, is_called FROM ${s.name}`);
81
+ seqValues.push({ name: s.name, value: v.rows[0].v, called: v.rows[0].is_called });
82
+ }
83
+ const cons = await c.query(`SELECT conname, contype, pg_get_constraintdef(oid) AS def FROM pg_constraint
84
+ WHERE conrelid = $1 AND contype IN ('p', 'u', 'c', 'x', 'f') ORDER BY contype = 'p' DESC, conname`, [t.oid]);
85
+ const fks = [];
86
+ for (const k of cons.rows) {
87
+ const sql = `ALTER TABLE ${name} ADD CONSTRAINT ${q(k.conname)} ${k.def}`;
88
+ if (k.contype === "f")
89
+ fks.push({ name: k.conname, sql });
90
+ else
91
+ create.push(sql);
92
+ }
93
+ const idx = await c.query(`SELECT pg_get_indexdef(i.indexrelid) AS def FROM pg_index i
94
+ WHERE i.indrelid = $1 AND NOT EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conindid = i.indexrelid)`, [t.oid]);
95
+ for (const i of idx.rows)
96
+ create.push(i.def);
97
+ const rls = await c.query(`SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = $1`, [t.oid]);
98
+ if (rls.rows[0].relrowsecurity)
99
+ create.push(`ALTER TABLE ${name} ENABLE ROW LEVEL SECURITY`);
100
+ if (rls.rows[0].relforcerowsecurity)
101
+ create.push(`ALTER TABLE ${name} FORCE ROW LEVEL SECURITY`);
102
+ const pols = await c.query(`SELECT policyname, permissive, cmd, roles::text[] AS roles, qual, with_check FROM pg_policies WHERE schemaname = $1 AND tablename = $2`, [t.schema, t.name]);
103
+ for (const p of pols.rows) {
104
+ const roles = p.roles.map((r) => (r === "public" ? "public" : q(r))).join(", ");
105
+ create.push(`CREATE POLICY ${q(p.policyname)} ON ${name} AS ${p.permissive} FOR ${p.cmd} TO ${roles}` +
106
+ (p.qual ? ` USING (${p.qual})` : "") +
107
+ (p.with_check ? ` WITH CHECK (${p.with_check})` : ""));
108
+ }
109
+ // Supabase's API reaches tables through grants to anon and authenticated.
110
+ const grants = await c.query(`SELECT CASE WHEN g.grantee = 0 THEN 'PUBLIC' ELSE quote_ident(pg_get_userbyid(g.grantee)) END AS who, g.privilege_type AS priv
111
+ FROM pg_class c, aclexplode(c.relacl) g WHERE c.oid = $1 AND g.grantee <> c.relowner`, [t.oid]);
112
+ for (const g of grants.rows)
113
+ create.push(`GRANT ${g.priv} ON ${name} TO ${g.who}`);
114
+ const trig = await c.query(`SELECT pg_get_triggerdef(oid) AS def FROM pg_trigger WHERE tgrelid = $1 AND NOT tgisinternal`, [t.oid]);
115
+ return { schema: t.schema, name: t.name, shape: await shapeOf(c, t.oid), create, triggers: trig.rows.map((x) => x.def), fks, seqs: seqValues };
116
+ }
117
+ // The same rows in any order give the same fingerprint.
118
+ async function fingerprint(c, table) {
119
+ const r = await c.query(`SELECT md5(coalesce(string_agg(h, '' ORDER BY h), '')) AS s FROM (SELECT md5(t::text) AS h FROM ${table} t) x`);
120
+ return r.rows[0].s;
121
+ }
122
+ export async function snapshotDb(actionId, url) {
123
+ return withClient(url, async (c) => {
124
+ const all = await tables(c);
125
+ await c.query(`CREATE SCHEMA IF NOT EXISTS ${q(SNAP_SCHEMA)}`);
126
+ // The migration history first, then the smallest tables: the most tables for the budget.
127
+ const order = [...all].sort((a, b) => Number(b.schema === MIGRATIONS) - Number(a.schema === MIGRATIONS) || a.bytes - b.bytes);
128
+ let spent = 0;
129
+ const copied = new Map();
130
+ let k = 0;
131
+ for (const t of order) {
132
+ if (t.schema !== MIGRATIONS && spent + t.bytes > budget())
133
+ continue;
134
+ spent += t.bytes;
135
+ const copy = `${actionId}_db${k++}`;
136
+ await c.query(`CREATE TABLE ${q(SNAP_SCHEMA)}.${q(copy)} AS TABLE ${full(t)}`);
137
+ copied.set(t.oid, { copy, sum: await fingerprint(c, full(t)) });
138
+ }
139
+ const out = [];
140
+ for (const t of all)
141
+ out.push({ ...(await describe(c, t)), copy: copied.get(t.oid)?.copy ?? null, sum: copied.get(t.oid)?.sum ?? null });
142
+ return { adapter: "db", url, tables: out };
143
+ });
144
+ }
145
+ async function insertable(c, table) {
146
+ const r = await c.query(`SELECT string_agg(quote_ident(attname), ', ' ORDER BY attnum) AS cols FROM pg_attribute
147
+ WHERE attrelid = to_regclass($1) AND attnum > 0 AND NOT attisdropped AND attgenerated = ''`, [table]);
148
+ return r.rows[0].cols;
149
+ }
150
+ async function putRows(c, t) {
151
+ const cols = await insertable(c, full(t));
152
+ const r = await c.query(`INSERT INTO ${full(t)} (${cols}) OVERRIDING SYSTEM VALUE SELECT ${cols} FROM ${q(SNAP_SCHEMA)}.${q(t.copy)}`);
153
+ return r.rowCount ?? 0;
154
+ }
155
+ const label = (t) => (t.schema === "public" ? t.name : `${t.schema}.${t.name}`);
156
+ export async function undoDb(url, u) {
157
+ return withClient(url, async (c) => {
158
+ const log = [];
159
+ await c.query("BEGIN");
160
+ try {
161
+ const now = new Map((await tables(c)).map((t) => [`${t.schema}.${t.name}`, t]));
162
+ const was = new Map(u.tables.map((t) => [`${t.schema}.${t.name}`, t]));
163
+ // Made since: gone again.
164
+ for (const [key, t] of now) {
165
+ if (was.has(key))
166
+ continue;
167
+ await c.query(`DROP TABLE IF EXISTS ${full(t)} CASCADE`);
168
+ log.push(`removed new table ${label(t)}`);
169
+ }
170
+ // Dropped or reshaped since: rebuilt as it was, rows and all.
171
+ const rebuilt = [];
172
+ const changed = [];
173
+ for (const [key, t] of was) {
174
+ const cur = now.get(key);
175
+ if (cur && (await shapeOf(c, cur.oid)) === t.shape) {
176
+ if (t.copy && (await fingerprint(c, full(t))) !== t.sum)
177
+ changed.push(t);
178
+ continue;
179
+ }
180
+ if (cur)
181
+ await c.query(`DROP TABLE ${full(t)} CASCADE`);
182
+ for (const sql of t.create)
183
+ await c.query(sql);
184
+ rebuilt.push(t);
185
+ }
186
+ for (const t of rebuilt) {
187
+ const rows = t.copy ? await putRows(c, t) : 0;
188
+ log.push(t.copy
189
+ ? `restored table ${label(t)} (${rows} rows)`
190
+ : `restored table ${label(t)}, but not its rows: it was too big to copy`);
191
+ }
192
+ // Rows changed in a table that's still there: put the old ones back. Tables
193
+ // whose rows point at these come along, so no foreign key is left dangling.
194
+ const set = new Map(changed.map((t) => [`${t.schema}.${t.name}`, t]));
195
+ for (let grew = true; grew;) {
196
+ grew = false;
197
+ const refs = await c.query(`SELECT DISTINCT cn.nspname || '.' || cl.relname AS child, pn.nspname || '.' || pl.relname AS parent
198
+ FROM pg_constraint k JOIN pg_class cl ON cl.oid = k.conrelid JOIN pg_namespace cn ON cn.oid = cl.relnamespace
199
+ JOIN pg_class pl ON pl.oid = k.confrelid JOIN pg_namespace pn ON pn.oid = pl.relnamespace WHERE k.contype = 'f'`);
200
+ for (const r of refs.rows) {
201
+ if (!set.has(r.parent) || set.has(r.child) || r.child === r.parent)
202
+ continue;
203
+ const child = was.get(r.child);
204
+ if (!child?.copy)
205
+ throw new Error(`couldn't put back the rows of ${label(set.get(r.parent))}: ${r.child} points at them and was too big to copy`);
206
+ set.set(r.child, child);
207
+ grew = true;
208
+ }
209
+ }
210
+ if (set.size) {
211
+ const list = [...set.values()];
212
+ for (const t of list)
213
+ await c.query(`ALTER TABLE ${full(t)} DISABLE TRIGGER USER`);
214
+ await c.query(`TRUNCATE ${list.map(full).join(", ")}`);
215
+ // Parents before the tables that point at them.
216
+ const left = new Set(set.keys());
217
+ const deps = await c.query(`SELECT cn.nspname || '.' || cl.relname AS child, pn.nspname || '.' || pl.relname AS parent
218
+ FROM pg_constraint k JOIN pg_class cl ON cl.oid = k.conrelid JOIN pg_namespace cn ON cn.oid = cl.relnamespace
219
+ JOIN pg_class pl ON pl.oid = k.confrelid JOIN pg_namespace pn ON pn.oid = pl.relnamespace WHERE k.contype = 'f'`);
220
+ while (left.size) {
221
+ const ready = [...left].filter((k) => !deps.rows.some((d) => d.child === k && d.parent !== k && left.has(d.parent)));
222
+ const batch = ready.length ? ready : [...left];
223
+ for (const key of batch) {
224
+ const t = set.get(key);
225
+ log.push(`restored ${await putRows(c, t)} rows in ${label(t)}`);
226
+ left.delete(key);
227
+ }
228
+ }
229
+ for (const t of list)
230
+ await c.query(`ALTER TABLE ${full(t)} ENABLE TRIGGER USER`);
231
+ }
232
+ // Triggers on rebuilt tables once their rows are in, then every foreign key a drop took with it.
233
+ for (const t of rebuilt)
234
+ for (const sql of t.triggers)
235
+ await c.query(sql);
236
+ for (const t of u.tables) {
237
+ for (const fk of t.fks) {
238
+ const has = await c.query(`SELECT 1 FROM pg_constraint WHERE conrelid = to_regclass($1) AND conname = $2`, [full(t), fk.name]);
239
+ if (has.rowCount === 0)
240
+ await c.query(fk.sql);
241
+ }
242
+ }
243
+ // Counters, so new rows don't collide with restored ones.
244
+ for (const t of [...rebuilt, ...set.values()]) {
245
+ for (const s of t.seqs)
246
+ await c.query(`SELECT setval($1, $2::bigint, $3)`, [s.name, s.value, s.called]);
247
+ }
248
+ await c.query("COMMIT");
249
+ }
250
+ catch (err) {
251
+ await c.query("ROLLBACK");
252
+ throw err;
253
+ }
254
+ await dropCopies(c, u);
255
+ return log.length ? log.join("\n") : "nothing had changed in the database";
256
+ });
257
+ }
258
+ async function dropCopies(c, u) {
259
+ for (const t of u.tables)
260
+ if (t.copy)
261
+ await c.query(`DROP TABLE IF EXISTS ${q(SNAP_SCHEMA)}.${q(t.copy)}`);
262
+ }
263
+ export async function discardDb(u) {
264
+ await withClient(u.url, (c) => dropCopies(c, u)).catch(() => { });
265
+ }
package/dist/check.js CHANGED
@@ -7,6 +7,7 @@ import { configServers, projectUses } from "./connect.js";
7
7
  import { supabaseProjects } from "./supabase.js";
8
8
  import { watchRules } from "./watch.js";
9
9
  import { dim, pink, white, width, wrap } from "./look.js";
10
+ import { DEFAULT_KEY, keyLabel, undoKeys } from "./keys.js";
10
11
  const WRAPPED = /(^|[/\s])(backstep|cli\.[jt]s) wrap\b/;
11
12
  const SUPABASE = /supabase/i;
12
13
  const FIREBASE = /firebase|firestore/i;
@@ -83,7 +84,7 @@ export function printCheck(c, bs = "backstep") {
83
84
  const supabase = c.items.find((i) => SUPABASE.test(i.name));
84
85
  const supabaseLeft = supabase?.level === "connect" || c.suggest.some((s) => s.title === "Connect Supabase");
85
86
  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(`\n ${white(`What ${(undoKeys().length ? undoKeys() : [DEFAULT_KEY]).map(keyLabel).join(" or ")} can undo`)}`);
87
88
  console.log(` ${pink("✓")} Files Claude edits and commands it runs`);
88
89
  console.log(` ${pink("✓")} Code Claude pushes to GitHub ${dim("(automatic, nothing to connect)")}`);
89
90
  for (const n of undoable)
package/dist/cli.js CHANGED
@@ -4,12 +4,14 @@ import { fileURLToPath } from "node:url";
4
4
  import { runProxy } from "./proxy.js";
5
5
  import { startUi } from "./ui/server.js";
6
6
  import { Store, snapshotDir } from "./store.js";
7
- import { undoAction, undoRun, undoSince } from "./undo.js";
7
+ import { pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
8
8
  import { snapshotPaths } from "./adapters/fs.js";
9
9
  import { notePushed, snapshotTree } from "./adapters/tree.js";
10
10
  import { revertPush } from "./push.js";
11
+ import { snapshotDb } from "./adapters/dbsnap.js";
12
+ import { projectDatabases } from "./connect.js";
11
13
  const PUSH = /\bgit\b[^;&|]*\bpush\b/;
12
- import { classifyShell } from "./shell.js";
14
+ import { classifyShell, supabaseTarget, touchesDatabase } from "./shell.js";
13
15
  import { watchEnd, watchStart } from "./watch.js";
14
16
  import { sendActive } from "./telemetry.js";
15
17
  import { readFileSync, rmSync } from "node:fs";
@@ -20,6 +22,7 @@ import { disconnectSupabase, dismissSupabase } from "./supabase.js";
20
22
  import { connect } from "./connect.js";
21
23
  import { check, printCheck } from "./check.js";
22
24
  import { logo } from "./look.js";
25
+ import { keyLabel, setUndoKey, undoKeys } from "./keys.js";
23
26
  import { checkUpdate, runUpdate, skipVersion } from "./update.js";
24
27
  import { askConnectionString } from "./setup.js";
25
28
  // Header values can name env vars, so tokens don't have to sit in agent config files.
@@ -58,6 +61,7 @@ const HELP = `backstep: undo for everything your agents touch
58
61
  Supabase from the project's .env files or local database (or the
59
62
  connection string you give), Firebase by adding its MCP server.
60
63
  In Claude Code you can just ask: "connect Backstep to my Supabase"
64
+ backstep key [<key>] show the key that undoes Claude's last turn, or change it (e.g. backstep key ctrl+u)
61
65
  backstep update install the newest version (Claude Code offers this as a session starts)
62
66
  backstep status [--json] what Backstep can and can't undo for each service Claude Code reaches,
63
67
  and what to connect next
@@ -210,6 +214,26 @@ async function main() {
210
214
  }
211
215
  return;
212
216
  }
217
+ // The undo key: `backstep key` shows it, `backstep key ctrl+u` changes it (what Claude runs when asked).
218
+ if (cmd === "key") {
219
+ const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { json: { type: "boolean", default: false } } });
220
+ if (!positionals[0]) {
221
+ const keys = undoKeys();
222
+ console.log(values.json ? JSON.stringify({ key: keys[0] ?? null, keys }) : keys.length ? `Undo is ${keys.map(keyLabel).join(" or ")}.` : "No undo key is bound. Run: backstep key ctrl+z");
223
+ return;
224
+ }
225
+ try {
226
+ const r = setUndoKey(positionals.join(" "));
227
+ console.log(values.json ? JSON.stringify({ ok: true, ...r }) : `Undo is now ${r.keys.map(keyLabel).join(" or ")}${r.previous && !r.keys.includes(r.previous) ? ` (was ${keyLabel(r.previous)})` : ""}.`);
228
+ }
229
+ catch (err) {
230
+ if (!values.json)
231
+ throw err;
232
+ console.log(JSON.stringify({ ok: false, detail: err instanceof Error ? err.message : String(err) }));
233
+ process.exitCode = 1;
234
+ }
235
+ return;
236
+ }
213
237
  // Run by the Claude Code mod as a session starts: is there a newer Backstep?
214
238
  if (cmd === "update-check") {
215
239
  console.log(JSON.stringify(await checkUpdate()));
@@ -317,12 +341,35 @@ async function main() {
317
341
  });
318
342
  // Outbound commands still get a snapshot: whatever they change locally can come back.
319
343
  // A push also notes where the remote branches stood, so undo can take it back there too.
320
- const undo = snapshotTree(a.id, values.cwd ?? process.cwd(), { pushes: PUSH.test(command) });
344
+ const cwd = values.cwd ?? process.cwd();
345
+ const tree = snapshotTree(a.id, cwd, { pushes: PUSH.test(command) });
346
+ // A migration, reset, seed or raw SQL: the connected databases it could change are noted too.
347
+ const dbs = [];
348
+ const missed = [];
349
+ if (touchesDatabase(command)) {
350
+ for (const url of await projectDatabases(cwd, supabaseTarget(command))) {
351
+ try {
352
+ dbs.push(await snapshotDb(a.id, url));
353
+ }
354
+ catch (err) {
355
+ missed.push(err instanceof Error ? err.message : String(err));
356
+ }
357
+ }
358
+ }
359
+ const undo = tree ? { ...tree, ...(dbs.length ? { dbs } : {}) } : dbs.length ? { adapter: "dbs", dbs } : null;
321
360
  if (undo)
322
361
  store.updateAction(a.id, { undo: JSON.stringify(undo) });
323
- else if (plan.kind !== "outbound")
362
+ // With its database saved, a command that reaches it is no longer lost to undo.
363
+ const kind = plan.kind === "outbound" && dbs.length && plan.reason === "changes a database directly" ? "write" : plan.kind;
364
+ if (kind !== plan.kind)
365
+ store.updateAction(a.id, { kind: "reversible", reason: null });
366
+ if (!undo && kind !== "outbound")
324
367
  store.updateAction(a.id, { kind: "write", reason: "project too large to snapshot" });
325
- console.log(JSON.stringify({ kind: plan.kind, reason: plan.reason, id: a.id, undoable: undo !== null }));
368
+ if (missed.length)
369
+ store.updateAction(a.id, { reason: `couldn't save the database first: ${missed[0]}` });
370
+ if (dbs.length)
371
+ await pruneDatabaseCopies(store).catch(() => { });
372
+ console.log(JSON.stringify({ kind, reason: kind === plan.kind ? plan.reason : undefined, id: a.id, undoable: undo !== null, db: dbs.length }));
326
373
  return;
327
374
  }
328
375
  // After a shell command that pushed: notes what it sent (run by the Claude Code mod).
package/dist/connect.js CHANGED
@@ -12,7 +12,7 @@ import { existsSync, readFileSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { join, resolve } from "node:path";
14
14
  import { claudeConfigFile, wrapServers } from "./servers.js";
15
- import { connectSupabase, hasSavedToken, LOCAL, mintSupabase, projectRef, saveSupabaseToken, supabaseDismissed, supabaseProjects, supabaseToken } from "./supabase.js";
15
+ import { connectSupabase, hasSavedToken, LOCAL, mintSupabase, projectRef, saveSupabaseToken, supabaseDb, supabaseDismissed, supabaseProjects, supabaseToken, supabaseUrl, } from "./supabase.js";
16
16
  const ENV_FILES = [".env", ".env.local", ".env.development", ".env.development.local"];
17
17
  const PG = /^postgres(ql)?:\/\//;
18
18
  const FIREBASE_SERVER = ["npx", "-y", "firebase-tools@latest", "mcp"];
@@ -105,6 +105,23 @@ function linkedRef(dir) {
105
105
  }
106
106
  return undefined;
107
107
  }
108
+ // The connected databases a command in this folder could change: only ones the
109
+ // person connected, so a database is never touched without their say.
110
+ export async function projectDatabases(dir, target) {
111
+ const out = [];
112
+ const ref = linkedRef(dir);
113
+ if (target !== "local" && ref && supabaseProjects().includes(ref)) {
114
+ const url = await supabaseUrl(ref);
115
+ if (url)
116
+ out.push(url);
117
+ }
118
+ if (target !== "hosted" && projectUses(dir).supabase) {
119
+ const local = supabaseDb(LOCAL);
120
+ if (local)
121
+ out.push(local);
122
+ }
123
+ return out;
124
+ }
108
125
  // Signed in to Supabase: this folder's project connects now, the rest as Claude uses them.
109
126
  async function signedIn(dir, token, ref, isNew) {
110
127
  const project = ref ?? linkedRef(dir);
package/dist/keys.js ADDED
@@ -0,0 +1,88 @@
1
+ // The key that undoes Claude's last turn, in Claude Code's ~/.claude/keybindings.json.
2
+ // The mod's undo button borrows a keybinding action only the diff panel uses
3
+ // (mod buttons can't have actions of their own yet), and whatever key is bound
4
+ // to it, in Global and Chat, presses the button. ctrl+z by default; the person
5
+ // can ask Claude for another, which runs `backstep key <key>`.
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ export const UNDO_ACTION = "app:cycleDiffBase";
10
+ export const DEFAULT_KEY = "ctrl+z";
11
+ // A Mac gets ⌘Z too, where the terminal passes it on (Ghostty, Kitty, WezTerm,
12
+ // Claude Code's desktop app); Terminal.app and iTerm2 keep ⌘Z for their own
13
+ // Edit menu, so ctrl+z stays bound alongside it.
14
+ export const defaultKeys = () => (process.platform === "darwin" ? ["cmd+z", DEFAULT_KEY] : [DEFAULT_KEY]);
15
+ const CONTEXTS = ["Global", "Chat"];
16
+ // Claude Code never hands these to anything else.
17
+ const RESERVED = new Set(["ctrl+c", "ctrl+d", "ctrl+m", "ctrl+i", "ctrl+h", "ctrl+[", "ctrl+\\"]);
18
+ const MODIFIERS = new Set(["ctrl", "control", "shift", "alt", "opt", "option", "meta", "cmd", "command", "super", "win"]);
19
+ const file = () => join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "keybindings.json");
20
+ function load() {
21
+ if (!existsSync(file()))
22
+ return { $schema: "https://www.schemastore.org/claude-code-keybindings.json", $docs: "https://code.claude.com/docs/en/keybindings" };
23
+ return JSON.parse(readFileSync(file(), "utf8"));
24
+ }
25
+ // Every key bound to undo now; empty when none is.
26
+ export function undoKeys() {
27
+ try {
28
+ const block = load().bindings?.find((b) => b.context === "Global");
29
+ return Object.entries(block?.bindings ?? {}).filter(([, a]) => a === UNDO_ACTION).map(([k]) => k);
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ export const undoKey = () => undoKeys()[0] ?? null;
36
+ // "Ctrl + U" → "ctrl+u". Throws with a reason a person can act on.
37
+ export function normalizeKey(raw) {
38
+ const key = raw.trim().toLowerCase().replace(/^⌘\s*\+?\s*/, "cmd+").replace(/\s*\+\s*/g, "+").replace(/^control\+/, "ctrl+").replace(/^(option|opt|alt)\+/, "meta+").replace(/^command\+/, "cmd+");
39
+ const parts = key.split("+");
40
+ const last = parts.at(-1) ?? "";
41
+ if (!last || parts.slice(0, -1).some((m) => !MODIFIERS.has(m)))
42
+ throw new Error(`"${raw}" isn't a key Claude Code understands. Try something like ctrl+u or meta+z`);
43
+ if (parts.length === 1)
44
+ throw new Error(`"${raw}" on its own would type a letter. Use a modifier, like ctrl+${last}`);
45
+ if (RESERVED.has(key))
46
+ throw new Error(`Claude Code keeps ${key} for itself. Pick another, like ctrl+u`);
47
+ return key;
48
+ }
49
+ // Binds undo to `keys` (one, or several) and unbinds the old ones. Refuses a key that does something else, unless it was undo's.
50
+ export function setUndoKey(raw) {
51
+ const keys = (Array.isArray(raw) ? raw : raw.split(/\s*(?:,|\s(?:or|and)\s)\s*/)).filter(Boolean).map(normalizeKey);
52
+ const key = keys[0];
53
+ if (!key)
54
+ throw new Error("give a key, like ctrl+u");
55
+ let config;
56
+ try {
57
+ config = load();
58
+ }
59
+ catch {
60
+ throw new Error(`couldn't read ${file()}. Add "${key}": "${UNDO_ACTION}" under Global and Chat by hand`);
61
+ }
62
+ config.bindings ??= [];
63
+ const previous = undoKey();
64
+ for (const context of CONTEXTS) {
65
+ for (const k of keys) {
66
+ const taken = config.bindings.find((b) => b.context === context)?.bindings[k];
67
+ if (taken && taken !== UNDO_ACTION)
68
+ throw new Error(`${k} already does ${taken} in Claude Code. Pick another key`);
69
+ }
70
+ }
71
+ for (const context of CONTEXTS) {
72
+ let block = config.bindings.find((b) => b.context === context);
73
+ if (!block)
74
+ config.bindings.push((block = { context, bindings: {} }));
75
+ for (const [k, a] of Object.entries(block.bindings))
76
+ if (a === UNDO_ACTION)
77
+ delete block.bindings[k];
78
+ for (const k of keys)
79
+ block.bindings[k] = UNDO_ACTION;
80
+ }
81
+ mkdirSync(join(file(), ".."), { recursive: true });
82
+ writeFileSync(file(), JSON.stringify(config, null, 2) + "\n");
83
+ return { key, keys, previous };
84
+ }
85
+ // How a key reads on screen: cmd+z → ⌘Z.
86
+ export function keyLabel(k) {
87
+ return k.startsWith("cmd+") ? `⌘${k.slice(4).toUpperCase()}` : k;
88
+ }
package/dist/setup.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
2
+ import { cpSync, readFileSync, rmSync } from "node:fs";
4
3
  import { dirname, join } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
6
5
  import { homeDir } from "./store.js";
6
+ import { defaultKeys, keyLabel, setUndoKey, undoKey, undoKeys } from "./keys.js";
7
7
  import { createInterface } from "node:readline/promises";
8
8
  import { autoWrap, wrapServers } from "./servers.js";
9
9
  import { check, printCheck } from "./check.js";
@@ -11,9 +11,6 @@ import { dim, logo, pink, white, width, wrap } from "./look.js";
11
11
  import { sendInstall, telemetryOn } from "./telemetry.js";
12
12
  // Both src/ and dist/ sit one level below the package root, next to mod/.
13
13
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
14
- const UNDO_KEY = "ctrl+z";
15
- // The mod's undo button borrows this action; see mod/hooks/register.tsx.
16
- const UNDO_ACTION = "app:cycleDiffBase";
17
14
  function run(cmd, args) {
18
15
  const r = spawnSync(cmd, args, { encoding: "utf8" });
19
16
  return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim(), missing: r.error !== undefined };
@@ -32,34 +29,17 @@ function keepCopy() {
32
29
  cpSync(dirname(ROOT), modules, { recursive: true });
33
30
  return { root: join(modules, "backstep"), onPath: false };
34
31
  }
32
+ // Keeps a key the person picked; on a first install binds ⌘Z and ctrl+z on a Mac, ctrl+z elsewhere.
35
33
  function bindUndoKey() {
36
- const dir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
37
- const file = join(dir, "keybindings.json");
38
- let config = {
39
- $schema: "https://www.schemastore.org/claude-code-keybindings.json",
40
- $docs: "https://code.claude.com/docs/en/keybindings",
41
- };
42
- if (existsSync(file)) {
43
- try {
44
- config = JSON.parse(readFileSync(file, "utf8"));
45
- }
46
- catch {
47
- return `couldn't read ${file}, so ${UNDO_KEY} isn't bound. Add "${UNDO_KEY}": "${UNDO_ACTION}" under Global and Chat.`;
48
- }
34
+ if (undoKey())
35
+ return "";
36
+ try {
37
+ setUndoKey(defaultKeys());
38
+ return "";
49
39
  }
50
- config.bindings ??= [];
51
- for (const context of ["Global", "Chat"]) {
52
- let block = config.bindings.find((b) => b.context === context);
53
- if (!block)
54
- config.bindings.push((block = { context, bindings: {} }));
55
- const taken = block.bindings[UNDO_KEY];
56
- if (taken && taken !== UNDO_ACTION)
57
- return `${UNDO_KEY} is already bound to ${taken} in ${file}; left it alone.`;
58
- block.bindings[UNDO_KEY] = UNDO_ACTION;
40
+ catch (err) {
41
+ return err instanceof Error ? err.message : String(err);
59
42
  }
60
- mkdirSync(dir, { recursive: true });
61
- writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
62
- return "";
63
43
  }
64
44
  const WHERE = `In Supabase, open your project, press Connect at the top, and copy the URI under
65
45
  "Direct connection" (or "Session pooler" if your network has no IPv6). Put your
@@ -107,7 +87,9 @@ export async function setup() {
107
87
  autoWrap(true);
108
88
  wrapServers(cli);
109
89
  // What's covered and what isn't, said once here and kept up to date by `backstep status`.
110
- console.log(`\n ${pink("✓")} ${white("Backstep is ready.")} Restart Claude Code, then press ${white(UNDO_KEY)} after any turn to undo it.`);
90
+ const key = (undoKeys().length ? undoKeys() : defaultKeys()).map(keyLabel).join(" or ");
91
+ console.log(`\n ${pink("✓")} ${white("Backstep is ready.")} Restart Claude Code, then press ${white(key)} after any turn to undo it.`);
92
+ console.log(dim(` Want a different key? Just ask Claude: "change my Backstep undo key to ctrl+u"`));
111
93
  printCheck(check(process.cwd()), bs);
112
94
  if (telemetryOn()) {
113
95
  console.log(dim(" Sends an anonymous install count. Turn it off with BACKSTEP_TELEMETRY=0.\n"));
package/dist/shell.js CHANGED
@@ -110,3 +110,18 @@ export function classifyShell(command) {
110
110
  return { kind: "read" };
111
111
  return { kind: "write" };
112
112
  }
113
+ // Commands that change a database through its own tools rather than Claude's:
114
+ // migrations, resets, seeds and raw SQL.
115
+ const DATABASE = /(^|[\s;&|(])(npx\s+|pnpm\s+(dlx\s+)?|bunx\s+|yarn\s+)?(supabase\s+(db|migration|seed)\b|psql\b|pg_restore\b|prisma\s+(migrate|db)\b|drizzle-kit\s+(push|migrate)\b|knex\s+(migrate|seed)|sequelize(-cli)?\s+db:|typeorm\s+(migration|schema)|node-pg-migrate\b|dbmate\b|goose\b|sqlx\s+(migrate|database)\b|alembic\b|(rails|rake)\s+db:|python3?\s+manage\.py\s+(migrate|flush|loaddata)\b)/;
116
+ export function touchesDatabase(command) {
117
+ return DATABASE.test(command);
118
+ }
119
+ // Which of a project's databases a Supabase command aims at: `db push` and
120
+ // --linked mean the hosted project, the rest default to the local one.
121
+ export function supabaseTarget(command) {
122
+ if (!/\bsupabase\s+(db|migration|seed)\b/.test(command))
123
+ return "both";
124
+ if (/--local\b/.test(command))
125
+ return "local";
126
+ return /--linked\b|--db-url\b|\bdb\s+push\b/.test(command) ? "hosted" : "local";
127
+ }
package/dist/store.js CHANGED
@@ -114,6 +114,12 @@ export class Store {
114
114
  const r = this.db.prepare("UPDATE actions SET status = ? WHERE id = ? AND status = ?").run(to, id, from);
115
115
  return r.changes === 1;
116
116
  }
117
+ // Actions still holding a copy of a database, newest first.
118
+ databaseCopies() {
119
+ return this.db
120
+ .prepare(`SELECT * FROM actions WHERE undo LIKE '%"adapter":"db"%' AND status IN ('ok', 'error') ORDER BY created_at DESC`)
121
+ .all();
122
+ }
117
123
  getAction(id) {
118
124
  return this.db.prepare("SELECT * FROM actions WHERE id = ?").get(id);
119
125
  }
package/dist/undo.js CHANGED
@@ -3,18 +3,45 @@ import { rmSync } from "node:fs";
3
3
  import { undoPaths } from "./adapters/fs.js";
4
4
  import { undoSql } from "./adapters/postgres.js";
5
5
  import { undoTree } from "./adapters/tree.js";
6
+ import { discardDb, undoDb } from "./adapters/dbsnap.js";
6
7
  import { snapshotDir } from "./store.js";
7
8
  import { dbKey, freshUrl } from "./supabase.js";
8
9
  const INVERSE_TIMEOUT_MS = 20_000;
10
+ // Database copies sit in the person's own database, so only the newest few per
11
+ // database are kept; older commands keep their file undo but not their database undo.
12
+ const KEEP_DB_COPIES = 10;
13
+ export async function pruneDatabaseCopies(store) {
14
+ const seen = new Map();
15
+ for (const a of store.databaseCopies()) {
16
+ const u = JSON.parse(a.undo);
17
+ const keep = [];
18
+ for (const d of u.dbs ?? []) {
19
+ const k = dbKey(d.url);
20
+ const n = (seen.get(k) ?? 0) + 1;
21
+ seen.set(k, n);
22
+ if (n <= KEEP_DB_COPIES)
23
+ keep.push(d);
24
+ else
25
+ await discardDb({ ...d, url: await freshUrl(d.url) });
26
+ }
27
+ if (keep.length === (u.dbs ?? []).length)
28
+ continue;
29
+ const next = u.adapter === "dbs" ? (keep.length ? { ...u, dbs: keep } : null) : { ...u, dbs: keep.length ? keep : undefined };
30
+ store.updateAction(a.id, { undo: next ? JSON.stringify(next) : null, ...(next ? {} : { kind: "write", reason: "its database copy was cleared to save space" }) });
31
+ }
32
+ }
9
33
  function resources(store, a) {
10
34
  const u = a.undo ? JSON.parse(a.undo) : null;
11
35
  if (!u)
12
36
  return new Set();
13
37
  if (u.adapter === "fs")
14
38
  return new Set(u.entries.map((e) => `fs:${e.path}`));
15
- // A project snapshot rewinds every file under its folder.
39
+ // A project snapshot rewinds every file under its folder, and any whole database saved with it.
40
+ const whole = (dbs = []) => dbs.map((d) => `pgdb:${dbKey(d.url)}`);
16
41
  if (u.adapter === "tree")
17
- return new Set([`fs:${u.root}`]);
42
+ return new Set([`fs:${u.root}`, ...whole(u.dbs)]);
43
+ if (u.adapter === "dbs")
44
+ return new Set(whole(u.dbs));
18
45
  // An opposite tool call doesn't rewind shared state, so it never conflicts.
19
46
  if (u.adapter === "inverse" || u.adapter === "engine")
20
47
  return new Set();
@@ -22,7 +49,7 @@ function resources(store, a) {
22
49
  const url = u.url ?? store.getRun(a.run_id)?.pg_url;
23
50
  const db = url ? dbKey(url) : a.run_id;
24
51
  const pgKey = (t) => `pg:${db}:${t.schema}.${t.name}`;
25
- return new Set([...u.snapshots, ...u.trashed, ...u.created].map(pgKey));
52
+ return new Set([...u.snapshots, ...u.trashed, ...u.created].map(pgKey).concat(url ? [`pgdb:${dbKey(url)}`] : []));
26
53
  }
27
54
  // Same resource, or a file inside a folder that's rewound whole.
28
55
  function overlaps(a, b) {
@@ -102,8 +129,14 @@ async function undoOne(store, a, opts = {}) {
102
129
  if (u.adapter === "fs") {
103
130
  detail = undoPaths(u);
104
131
  }
105
- else if (u.adapter === "tree") {
106
- detail = undoTree(u, a.id);
132
+ else if (u.adapter === "tree" || u.adapter === "dbs") {
133
+ // The databases first: their undo is one transaction each, so a failure leaves everything as it was.
134
+ const lines = [];
135
+ for (const d of u.dbs ?? [])
136
+ lines.push(await undoDb(await freshUrl(d.url), d));
137
+ if (u.adapter === "tree")
138
+ lines.unshift(undoTree(u, a.id));
139
+ detail = lines.filter((l) => !/^nothing had changed/.test(l)).join("\n") || lines[0];
107
140
  }
108
141
  else {
109
142
  const url = u.url ?? store.getRun(a.run_id)?.pg_url;
@@ -65,6 +65,7 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
65
65
  await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
66
66
 
67
67
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
68
+ await ui.press({ key: 'undo' })
68
69
  const pressed = ui.press({ key: 'undo' })
69
70
  await clock.settle()
70
71
  expect((await ui.find({ type: 'Text', text: /Rewinding/ }))?.text).toBeTruthy()
@@ -77,7 +78,7 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
77
78
 
78
79
  // The band folds away, and with the turn undone nothing is left to offer.
79
80
  await clock.advance(5000)
80
- expect(await ui.find({ type: 'Button', key: 'undo' })).toBeFalsy()
81
+ expect(await ui.find({ type: 'Text', text: /nothing to undo yet/ })).toBeTruthy()
81
82
  await ui.unmount()
82
83
 
83
84
  await $.prompt.submit({ text: 'try again', wait: false, origin: { kind: 'composer' } })
@@ -104,6 +105,7 @@ test('a change that cannot be undone stops the rewind and says so', { options: {
104
105
  await $.tool.call({ tool: 'mcp__db__query', sql: 'update t set x = 1' } as never)
105
106
 
106
107
  const ui = await $.ui.mount({ ...BAND, surface: 'desktop' })
108
+ await ui.press({ key: 'undo' })
107
109
  const pressed = ui.press({ key: 'undo' })
108
110
  await clock.advance(1000)
109
111
  await pressed
@@ -117,14 +119,21 @@ test('a change that cannot be undone stops the rewind and says so', { options: {
117
119
  expect(prompts).toHaveLength(1)
118
120
  })
119
121
 
120
- test('with nothing to undo the band leaves the space to the engine', async ($, on) => {
122
+ test('with nothing to undo the band still holds the key, so ctrl+z never suspends Claude Code', async ($, on) => {
121
123
  engine(on, { exitCode: 0, stdout: '[]' })
124
+ const toasts: string[] = []
125
+ on('ui.toast', (_, e) => {
126
+ toasts.push(e.text)
127
+ return { value: undefined }
128
+ })
122
129
 
123
130
  await $.prompt.submit({ text: 'hi', wait: false, origin: { kind: 'composer' } })
124
131
  await $.tool.call({ tool: 'Bash', command: 'ls' })
125
132
 
126
133
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
127
- expect((await ui.find({ type: 'Text' }))?.text).toBe('engine')
134
+ expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))).toBeTruthy()
135
+ await ui.press({ key: 'undo' })
136
+ expect(toasts[0]).toContain('Nothing to undo yet')
128
137
  await ui.unmount()
129
138
  })
130
139
 
@@ -181,9 +190,10 @@ test('servers added since setup are wrapped as a session starts', { options: { c
181
190
 
182
191
  await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
183
192
  await new Promise((r) => setTimeout(r, 10))
184
- expect(tools).toEqual(['connect'])
193
+ expect(tools).toEqual(['connect', 'undo_key'])
185
194
  expect(logs[0]).toContain('Backstep connected')
186
195
  expect(runs).toEqual([
196
+ ['node', '--disable-warning=ExperimentalWarning', CLI, 'key', '--json'],
187
197
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'wrap-servers', '--project', '/repo', '--json'],
188
198
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'connect', '--dir', '/repo', '--json', '--auto'],
189
199
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
@@ -248,6 +258,7 @@ test('a connector call with an undo rule is undone through Claude Code', { optio
248
258
 
249
259
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
250
260
  expect((await ui.find({ type: 'Text', text: /Last turn/ }))?.text).toContain('1 tool action')
261
+ await ui.press({ key: 'undo' })
251
262
  const pressed = ui.press({ key: 'undo' })
252
263
  await clock.advance(1000)
253
264
  await pressed
@@ -265,6 +276,7 @@ test('when the opposite call fails, the rewind says so', { options: { cli: CLI }
265
276
  await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
266
277
  await $.tool.call({ tool: 'mcp__claude_ai_Notion__create-page', title: 'Plan' } as never)
267
278
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
279
+ await ui.press({ key: 'undo' })
268
280
  const pressed = ui.press({ key: 'undo' })
269
281
  await clock.advance(1000)
270
282
  await pressed
@@ -424,6 +436,7 @@ test('after undoing a turn that pushed, Backstep asks and pushes the undo', { op
424
436
  expect(runs.at(-1)?.argv.slice(3)).toEqual(['shell-end', 'p1'])
425
437
 
426
438
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
439
+ await ui.press({ key: 'undo' })
427
440
  const pressed = ui.press({ key: 'undo' })
428
441
  await clock.advance(1000)
429
442
  await pressed
@@ -484,3 +497,70 @@ test('Skip this version stops asking about it, and Not now changes nothing', { o
484
497
  expect(runs.some((r) => r.slice(3).join(' ') === 'update --skip 0.2.0')).toBe(true)
485
498
  expect(runs.some((r) => r.includes('--version'))).toBe(false)
486
499
  })
500
+
501
+ test('asking Claude to change the undo key rebinds it and relabels the band', { options: { cli: CLI } }, async ($, on) => {
502
+ const runs: string[][] = []
503
+ on('process.run', (_, e) => {
504
+ runs.push([...e.argv])
505
+ const stdout = e.argv.includes('key') ? JSON.stringify({ ok: true, key: 'ctrl+u', previous: 'ctrl+z' }) : '[]'
506
+ return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
507
+ })
508
+ on('ui.toast', () => ({ value: undefined }))
509
+ on('ui.render', ($, e) => {
510
+ const { Text } = $.ui.resolve(e)
511
+ return <Text>engine</Text>
512
+ })
513
+ const r = await $.tool.call({ tool: 'mcp__backstep__undo_key', key: 'Ctrl + U' } as never)
514
+ expect(runs[0].slice(3)).toEqual(['key', 'Ctrl + U', '--json'])
515
+ expect(r.result).toBe('Undo is now ctrl+u (it was ctrl+z). It works right away.')
516
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
517
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u to undo')
518
+ await ui.unmount()
519
+ })
520
+
521
+ test('one press only asks; it lets go after a few seconds, or on cancel, and undoes nothing', { options: { cli: CLI } }, async ($, on) => {
522
+ const clock = mock.clock(on, { now: 5000 })
523
+ const { runs } = engine(on, { exitCode: 0, stdout: '[]' })
524
+
525
+ await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
526
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
527
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
528
+
529
+ await ui.press({ key: 'undo' })
530
+ expect((await ui.find({ type: 'Text', text: /Undo Claude's last turn\?/ }))).toBeTruthy()
531
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+z again to undo')
532
+ await clock.advance(6000)
533
+ expect((await ui.find({ type: 'Text', text: /Last turn/ }))).toBeTruthy()
534
+
535
+ await ui.press({ key: 'undo' })
536
+ await ui.press({ key: 'cancel' })
537
+ expect((await ui.find({ type: 'Text', text: /Last turn/ }))).toBeTruthy()
538
+ // A press after the question let go asks again rather than undoing.
539
+ await ui.press({ key: 'undo' })
540
+ expect(runs.some((r) => r.includes('undo'))).toBe(false)
541
+ await ui.unmount()
542
+ })
543
+
544
+ test('on a Mac the band names both keys', { options: { cli: CLI } }, async ($, on) => {
545
+ on('process.run', (_, e) => {
546
+ const stdout = e.argv.includes('key') ? JSON.stringify({ key: 'cmd+z', keys: ['cmd+z', 'ctrl+z'] }) : '[]'
547
+ return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
548
+ })
549
+ const logs: string[] = []
550
+ on('ui.log', (_, e) => {
551
+ logs.push(e.text)
552
+ return { value: undefined }
553
+ })
554
+ on('ui.toast', () => ({ value: undefined }))
555
+ on('tool.register', (_, e) => ({ value: { tool: `mcp__backstep__${e.name}` } }))
556
+ on('session.start', (_, e) => ({ cwd: e.cwd }))
557
+ on('ui.render', ($, e) => {
558
+ const { Text } = $.ui.resolve(e)
559
+ return <Text>engine</Text>
560
+ })
561
+ await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
562
+ expect(logs[0]).toContain('press ⌘Z / ctrl+z to undo')
563
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
564
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('⌘Z / ctrl+z to undo')
565
+ await ui.unmount()
566
+ })
@@ -12,6 +12,9 @@ import type { Rewind, RewindItem, Turn } from '../types'
12
12
  const turns = atom({ plugin: 'backstep', key: 'turns' } as const, [] as Turn[])
13
13
  const rewind = atom({ plugin: 'backstep', key: 'rewind' } as const, null as Rewind | null)
14
14
  const note = atom({ plugin: 'backstep', key: 'note' } as const, null as string | null)
15
+ const armed = atom({ plugin: 'backstep', key: 'armed' } as const, null as number | null)
16
+ // How long the "press again to undo" question waits before it lets go.
17
+ const CONFIRM_MS = 5000
15
18
 
16
19
  // The engine has no keybinding actions of its own for plugins yet, so the undo
17
20
  // button borrows one that only the diff panel handles; ~/.claude/keybindings.json
@@ -80,6 +83,28 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
80
83
  }
81
84
  }
82
85
 
86
+ // The first press asks, right there above the prompt; a second press within a
87
+ // few seconds undoes. A dialog would take the band off screen, and with it the
88
+ // key, which would then suspend Claude Code in a terminal.
89
+ async function pressUndo($: EngineInterface) {
90
+ if (await read($, rewind)) return
91
+ const target = (await read($, turns)).findLast(hasWork)
92
+ if (!target) {
93
+ $.ui.toast('Nothing to undo yet: Claude hasn\'t changed anything Backstep can take back.')
94
+ return
95
+ }
96
+ const at = await read($, armed)
97
+ const now = await $.clock.now()
98
+ if (at !== null && now - at < CONFIRM_MS) {
99
+ await update($, armed, () => null)
100
+ return undoLastTurn($)
101
+ }
102
+ await update($, armed, () => now)
103
+ $.clock.after(CONFIRM_MS, () => {
104
+ void update($, armed, (a) => (a === now ? null : a))
105
+ })
106
+ }
107
+
83
108
  async function undoLastTurn($: EngineInterface) {
84
109
  if (await read($, rewind)) return
85
110
  const target = (await read($, turns)).findLast(hasWork)
@@ -162,7 +187,7 @@ async function undoLastTurn($: EngineInterface) {
162
187
  if (done) {
163
188
  const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
164
189
  await update($, note, () =>
165
- `[Backstep] The user pressed undo (ctrl+z) on your previous turn. These changes were reverted:\n${list}\n` +
190
+ `[Backstep] The user pressed undo (${keysLabel()}) on your previous turn. These changes were reverted:\n${list}\n` +
166
191
  (lost ? `${plural(lost, 'action')} from that turn couldn't be undone (they reached outside this machine with no way back, or the project was too big to snapshot).\n` : '') +
167
192
  `Treat them as not having happened, re-read files before editing them, and don't redo them unless asked.`,
168
193
  )
@@ -250,6 +275,10 @@ type ShellPlan = { kind: 'read' | 'write' | 'outbound'; reason?: string; id?: st
250
275
 
251
276
  // Set from the plugin's options each time the module registers.
252
277
  let cli = 'backstep'
278
+ // The keys bound to undo, read as a session starts and when the person changes them.
279
+ let undoKeys = ['ctrl+z']
280
+ const keyLabel = (k: string) => (k.startsWith('cmd+') ? `⌘${k.slice(4).toUpperCase()}` : k)
281
+ const keysLabel = () => undoKeys.map(keyLabel).join(' / ')
253
282
 
254
283
  function backstep(args: string[]): string[] {
255
284
  return cli === 'backstep' ? ['backstep', ...args] : ['node', '--disable-warning=ExperimentalWarning', cli, ...args]
@@ -385,6 +414,39 @@ async function offerUpdate($: EngineInterface) {
385
414
  $.ui.toast(r.ok ? `Backstep ${r.detail}. Restart Claude Code to use it.` : `Backstep couldn't update: ${r.detail}. Try: npx backstep@latest setup`)
386
415
  }
387
416
 
417
+ const KEY = 'mcp__backstep__undo_key'
418
+
419
+ const KEY_TOOL = {
420
+ name: 'undo_key',
421
+ description:
422
+ "Change the key that undoes Claude's last turn with Backstep (⌘Z and ctrl+z on a Mac, ctrl+z elsewhere, unless the user changed it). " +
423
+ 'Use it when the user asks to change, rebind or pick a different Backstep or undo shortcut. ' +
424
+ 'Keys are written like ctrl+u, cmd+z (⌘ on a Mac; most Mac terminals keep ⌘ keys for themselves, so pair it with a ctrl key), meta+z (Option) or ctrl+shift+z. ' +
425
+ 'Several keys can share undo: separate them with commas. It takes effect right away, no restart.',
426
+ inputSchema: {
427
+ type: 'object',
428
+ properties: { key: { type: 'string', description: 'the new key, like ctrl+u, or several separated by commas, like "cmd+z, ctrl+z"' } },
429
+ required: ['key'],
430
+ },
431
+ }
432
+
433
+ async function changeKey($: EngineInterface, key: string): Promise<string> {
434
+ const ran = await $.process
435
+ .run(backstep(['key', key, '--json']))
436
+ .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
437
+ let r: { ok?: boolean; key?: string; keys?: string[]; previous?: string | null; detail?: string } = {}
438
+ try {
439
+ r = JSON.parse(ran.stdout || '{}') as typeof r
440
+ } catch {
441
+ r = { ok: false, detail: ran.stderr.trim() || 'it failed' }
442
+ }
443
+ if (!r.ok || !r.key) return `Couldn't change the undo key: ${r.detail ?? 'it failed'}`
444
+ undoKeys = r.keys?.length ? r.keys : [r.key]
445
+ $.ui.toast(`Backstep: undo is now ${keysLabel()}.`)
446
+ const was = r.previous && !undoKeys.includes(r.previous) ? ` (it was ${keyLabel(r.previous)})` : ''
447
+ return `Undo is now ${keysLabel()}${was}. It works right away.`
448
+ }
449
+
388
450
  const CONNECT = 'mcp__backstep__connect'
389
451
 
390
452
  const CONNECT_TOOL = {
@@ -435,9 +497,17 @@ export const register: Register = (on, options) => {
435
497
 
436
498
  on('session.start', async ($, e, next) => {
437
499
  await $.tool.register(CONNECT_TOOL)
500
+ await $.tool.register(KEY_TOOL)
438
501
  const r = await next(e)
502
+ const bound = await $.process.run(backstep(['key', '--json'])).catch(() => null)
503
+ try {
504
+ const keys = (JSON.parse(bound?.stdout || '{}') as { keys?: string[] }).keys
505
+ if (keys?.length) undoKeys = keys
506
+ } catch {
507
+ // keep the default
508
+ }
439
509
  // Said first thing, so nobody has to wonder whether it's running.
440
- $.ui.log("✓ Backstep connected · press ctrl+z to undo Claude's last turn")
510
+ $.ui.log(`✓ Backstep connected · press ${keysLabel()} to undo Claude's last turn`)
441
511
  // In the background: a session never waits on either.
442
512
  void wrapNewServers($, e.cwd)
443
513
  // One question at a time: Supabase first, then any update.
@@ -458,11 +528,13 @@ export const register: Register = (on, options) => {
458
528
  return next({ ...e, context: [...(e.context ?? []), pending] })
459
529
  })
460
530
 
531
+ on('tool.call', { tool: KEY }, async ($, e) => ({ result: await changeKey($, String((e as { key?: unknown }).key ?? '')) }))
532
+
461
533
  on('tool.call', { tool: CONNECT }, async ($, e) => ({ result: await connectServices($, e as { service?: string; connection_string?: string; project_ref?: string }) }))
462
534
 
463
535
  on('tool.call', async ($, e, next) => {
464
536
  const tool = String(e.tool)
465
- if (tool === CONNECT) return next(e)
537
+ if (tool === CONNECT || tool === KEY) return next(e)
466
538
  if (EDIT_TOOLS.has(tool)) {
467
539
  const input = e as { file_path?: string; notebook_path?: string }
468
540
  const path = input.file_path ?? input.notebook_path
@@ -485,7 +557,8 @@ export const register: Register = (on, options) => {
485
557
  const sid = await $.session.id()
486
558
  // Never blocks the command: if Backstep can't be reached it just won't be undoable.
487
559
  const ran = await $.process
488
- .run(backstep(['shell', '--session', sid, '--cwd', cwd, '--', command]), { timeoutMs: 60_000 })
560
+ // A database command copies the database first, which takes longer on a big one.
561
+ .run(backstep(['shell', '--session', sid, '--cwd', cwd, '--', command]), { timeoutMs: 180_000 })
489
562
  .catch(() => null)
490
563
  let plan: ShellPlan | null = null
491
564
  try {
@@ -548,16 +621,32 @@ export const register: Register = (on, options) => {
548
621
  )
549
622
  }
550
623
 
551
- // Idle: a quiet line naming what one press would take back. The button
552
- // has to be on screen for the chord to reach it.
553
624
  const target = (await read($, turns)).findLast(hasWork)
554
- if (!target) return next(e)
625
+ if (target && (await read($, armed)) !== null) {
626
+ return (
627
+ <Box>
628
+ <Text color="warning" bold>↺ Undo Claude's last turn? </Text>
629
+ <Text dimColor>{summary(target)} </Text>
630
+ <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
631
+ {`${keysLabel()} again to undo`}
632
+ </Button>
633
+ <Text dimColor> · </Text>
634
+ <Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
635
+ cancel
636
+ </Button>
637
+ </Box>
638
+ )
639
+ }
640
+
641
+ // Idle: a quiet line naming what one press would take back. It's always
642
+ // there, because the button has to be on screen for the key to reach it;
643
+ // without it, ctrl+z falls through to the terminal and suspends Claude Code.
555
644
  return (
556
645
  <Box>
557
646
  <Text color="success">↺ </Text>
558
- <Text dimColor>Last turn: {summary(target)} </Text>
559
- <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void undoLastTurn($)}>
560
- ctrl+z to undo
647
+ <Text dimColor>{target ? `Last turn: ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
648
+ <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
649
+ {`${keysLabel()} to undo`}
561
650
  </Button>
562
651
  </Box>
563
652
  )
@@ -25,6 +25,7 @@ export type Rewind = {
25
25
 
26
26
  declare module 'claude-code' {
27
27
  interface PluginState {
28
- backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null }
28
+ // armed: when the undo key was first pressed; a second press within a few seconds undoes.
29
+ backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null }
29
30
  }
30
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
5
5
  "scripts": {
6
6
  "dev": "node src/cli.ts",