backstep 0.1.16 → 0.1.17

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
@@ -25,10 +25,16 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
25
25
  npx backstep setup
26
26
  ```
27
27
 
28
- This installs Backstep, adds its Claude Code mod, binds **ctrl+u** to undo everything Claude did in its last turn, and puts the MCP servers you've already added to Claude Code behind Backstep, so their changes can be undone too. If you use Supabase, it asks for your database's connection string (Enter skips; you can ask Claude to connect it later). Restart Claude Code afterwards. Needs Node 22.13+ and Claude Code.
28
+ This installs Backstep, adds its Claude Code mod, binds **ctrl+u** to undo everything Claude did in its last turn, and puts the MCP servers you've already added to Claude Code behind Backstep, so their changes can be undone too. Databases (Supabase, Postgres, Firebase) aren't touched at setup: the first time Claude goes to change one, Backstep asks whether to connect it. Restart Claude Code afterwards. Needs Node 22.13+ and Claude Code.
29
29
 
30
30
  Servers in a repo's shared `.mcp.json` that you've approved get a wrapped copy for that project only; the repo's file is never edited. Remote servers that Claude Code signs in to stay as they are, and the mod watches their calls instead. Servers you add later are wrapped the next time Claude Code starts. `backstep unwrap` puts every server back the way it was and stops new ones being wrapped.
31
31
 
32
+ ## Uninstall
33
+
34
+ In Claude Code, type **/uninstall-backstep**, or just ask Claude to uninstall Backstep. From a terminal: `backstep uninstall` (or `npx backstep uninstall`).
35
+
36
+ It asks first, then takes away everything Backstep set up: your MCP servers go back to their own commands, the database copies Backstep saved are dropped (and its `backstep` schema, only if that leaves it empty), the undo key comes out of Claude Code's keybindings, the plugin is removed, and `~/.backstep` (history, snapshots, saved connections) is deleted. Nothing you undid or kept changes. Restart Claude Code to finish.
37
+
32
38
  ## Try it
33
39
 
34
40
  ```sh
@@ -112,7 +118,7 @@ Everything is journaled to `~/.backstep/journal.db` (SQLite, mode 0600; override
112
118
 
113
119
  ## Telemetry
114
120
 
115
- `backstep setup` sends one anonymous `install` event, and Claude Code with the Backstep mod sends one `active` event a day. Each carries a random id made on your machine (`~/.backstep/install-id`), the Backstep version, your OS and CPU type, and the Node major version. Nothing else: no paths, commands, server names, IP-based location, or anything from the journal. The code is `src/telemetry.ts`.
121
+ `backstep setup` sends one anonymous `install` event, Claude Code with the Backstep mod sends one `active` event a day, and `backstep uninstall` sends one `uninstall` event. Each carries a random id made on your machine (`~/.backstep/install-id`), the Backstep version, your OS and CPU type, and the Node major version. Nothing else: no paths, commands, server names, IP-based location, or anything from the journal. The code is `src/telemetry.ts`.
116
122
 
117
123
  Turn it off with `BACKSTEP_TELEMETRY=0`, or the standard `DO_NOT_TRACK=1`.
118
124
 
@@ -263,3 +263,11 @@ async function dropCopies(c, u) {
263
263
  export async function discardDb(u) {
264
264
  await withClient(u.url, (c) => dropCopies(c, u)).catch(() => { });
265
265
  }
266
+ // On uninstall, after every copy is dropped: removes Backstep's schema, but only
267
+ // if it's empty (no CASCADE), so a schema of the project's own by that name is never touched.
268
+ export async function dropSnapSchema(url) {
269
+ return withClient(url, async (c) => {
270
+ await c.query(`DROP SCHEMA IF EXISTS ${q(SNAP_SCHEMA)}`);
271
+ return true;
272
+ }).catch(() => false);
273
+ }
package/dist/cli.js CHANGED
@@ -88,6 +88,8 @@ const HELP = `backstep: undo for everything your agents touch
88
88
  backstep setup install the Claude Code mod, bind ctrl+u to undo Claude's last turn,
89
89
  and put your MCP servers behind Backstep
90
90
  backstep unwrap take your MCP servers back out from behind Backstep
91
+ backstep uninstall [--yes] remove Backstep and everything it set up (in Claude Code: /uninstall-backstep,
92
+ or ask Claude to uninstall Backstep)
91
93
  backstep connect [supabase|firebase] [<access-token>|<connection-string>] [--project <ref>] [--dir <folder>] [--json]
92
94
  connect what the project uses, so Claude's changes to it can be undone:
93
95
  Supabase from the project's .env files or local database (or the
@@ -166,6 +168,36 @@ async function main() {
166
168
  await sendActive();
167
169
  return;
168
170
  }
171
+ if (cmd === "uninstall") {
172
+ const { values } = parseArgs({ args: rest, options: { yes: { type: "boolean", default: false }, json: { type: "boolean", default: false } } });
173
+ if (!values.yes) {
174
+ if (!process.stdin.isTTY)
175
+ throw new Error("run it with --yes to confirm: backstep uninstall --yes");
176
+ const { createInterface } = await import("node:readline/promises");
177
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
178
+ const answer = (await rl.question("This removes Backstep from this computer: your MCP servers go back to how they were, the undo key\n" +
179
+ "and the Claude Code plugin are removed, and Backstep's history and saved copies are deleted\n" +
180
+ "(nothing you undid or kept is changed). Remove Backstep? [y/N] ")).trim();
181
+ rl.close();
182
+ if (!/^y(es)?$/i.test(answer)) {
183
+ console.log("Kept Backstep.");
184
+ return;
185
+ }
186
+ }
187
+ const { uninstall } = await import("./uninstall.js");
188
+ const removed = await uninstall();
189
+ if (values.json) {
190
+ console.log(JSON.stringify(removed));
191
+ }
192
+ else {
193
+ for (const r of removed)
194
+ console.log(` ${r.ok ? "✓" : "!"} ${r.step}: ${r.detail}`);
195
+ console.log(`\nBackstep is removed.${removed.every((r) => r.ok) ? "" : " The lines marked ! need a look."} Restart Claude Code to finish.`);
196
+ }
197
+ if (removed.some((r) => !r.ok))
198
+ process.exitCode = 1;
199
+ return;
200
+ }
169
201
  if (cmd === "unwrap") {
170
202
  autoWrap(false);
171
203
  const changes = unwrapServers();
package/dist/connect.js CHANGED
@@ -354,26 +354,16 @@ async function supabase(dir, given, ref, interactive = false, auto = false) {
354
354
  `Or the database's connection string (${WHERE})${errors.length ? `. Tried the one in the project's settings: ${errors[0]}` : ""}`,
355
355
  };
356
356
  }
357
- // firebase-tools keeps its sign-in here once someone runs `firebase login`.
358
- function firebaseSignedIn() {
359
- try {
360
- const c = JSON.parse(read(join(homedir(), ".config", "configstore", "firebase-tools.json")));
361
- return Boolean(c.tokens?.refresh_token);
362
- }
363
- catch {
364
- return false;
365
- }
366
- }
367
- // `auto`: on its own as a session starts, so a server is only added when it would work.
357
+ // `auto`: as a session starts. Nothing is added then: like Supabase, Firebase is
358
+ // only set up once Claude goes to change it and the person says Connect.
368
359
  function firebase(dir, cli, auto = false) {
369
360
  if (servers(dir).some((s) => /firebase|firestore/i.test(s))) {
370
361
  return { service: "firebase", state: "covered", detail: "Claude reaches Firebase through its MCP server, so its changes can be undone" };
371
362
  }
372
363
  if (!projectUses(dir).firebase)
373
364
  return { service: "firebase", state: "unused", detail: "this project doesn't use Firebase" };
374
- if (auto && !firebaseSignedIn()) {
375
- return { service: "firebase", state: "needs", detail: "sign in to Firebase once (run: npx firebase-tools login), and Backstep connects it by itself" };
376
- }
365
+ if (auto)
366
+ return { service: "firebase", state: "unused", detail: "set up the first time Claude goes to change Firebase" };
377
367
  // For this project only, like `claude mcp add` without -s.
378
368
  const r = spawnSync("claude", ["mcp", "add", "firebase", "--", ...FIREBASE_SERVER], { cwd: dir, encoding: "utf8" });
379
369
  if (r.status !== 0) {
package/dist/keys.js CHANGED
@@ -84,6 +84,24 @@ export function setUndoKey(raw) {
84
84
  writeFileSync(file(), JSON.stringify(config, null, 2) + "\n");
85
85
  return { key, keys, previous };
86
86
  }
87
+ // On uninstall: takes every key bound to Backstep's undo back out. Returns the keys removed.
88
+ export function removeUndoKeys() {
89
+ if (!existsSync(file()))
90
+ return [];
91
+ const config = load();
92
+ const removed = new Set();
93
+ for (const block of config.bindings ?? []) {
94
+ for (const [k, a] of Object.entries(block.bindings)) {
95
+ if (a === UNDO_ACTION) {
96
+ delete block.bindings[k];
97
+ removed.add(k);
98
+ }
99
+ }
100
+ }
101
+ if (removed.size)
102
+ writeFileSync(file(), JSON.stringify(config, null, 2) + "\n");
103
+ return [...removed];
104
+ }
87
105
  // How a key reads on screen: cmd+z → ⌘Z.
88
106
  export function keyLabel(k) {
89
107
  return k.startsWith("cmd+") ? `⌘${k.slice(4).toUpperCase()}` : k;
package/dist/setup.js CHANGED
@@ -78,6 +78,7 @@ export async function setup() {
78
78
  const key = (undoKeys().length ? undoKeys() : defaultKeys()).map(keyLabel).join(" or ");
79
79
  console.log(`\n ${pink("✓")} ${white("Backstep is ready.")} Restart Claude Code, then press ${white(key)} after any turn to undo it.`);
80
80
  console.log(dim(` Want a different key? Just ask Claude: "change my Backstep undo key to ctrl+q"`));
81
+ console.log(dim(` To remove Backstep, type /uninstall-backstep in Claude Code, or ask Claude to uninstall it.`));
81
82
  printCheck(check(process.cwd()), bs);
82
83
  if (telemetryOn()) {
83
84
  console.log(dim(" Sends an anonymous install count. Turn it off with BACKSTEP_TELEMETRY=0.\n"));
package/dist/telemetry.js CHANGED
@@ -63,6 +63,7 @@ async function send(event) {
63
63
  }
64
64
  }
65
65
  export const sendInstall = () => send("install");
66
+ export const sendUninstall = () => send("uninstall");
66
67
  // At most once a day, as Claude Code starts with Backstep's mod.
67
68
  export async function sendActive() {
68
69
  if (!telemetryOn())
@@ -0,0 +1,105 @@
1
+ // Takes Backstep off this computer: everything setup and use put in place, in
2
+ // the order that never leaves something pointing at a Backstep that's gone.
3
+ // 1. MCP servers wrapped behind Backstep get their own commands back
4
+ // 2. the database copies Backstep made are dropped, and its schema if empty
5
+ // 3. the undo key comes out of Claude Code's keybindings
6
+ // 4. the Claude Code plugin and its source are removed
7
+ // 5. ~/.backstep (history, snapshots, saved connections) is deleted
8
+ // 6. the global npm package, when setup installed one
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync, rmSync } from "node:fs";
11
+ import { homeDir, Store } from "./store.js";
12
+ import { unwrapServers } from "./servers.js";
13
+ import { removeUndoKeys } from "./keys.js";
14
+ import { discardDb, dropSnapSchema } from "./adapters/dbsnap.js";
15
+ import { dbKey, freshUrl } from "./supabase.js";
16
+ import { sendUninstall } from "./telemetry.js";
17
+ const run = (cmd, args) => {
18
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
19
+ return { ok: r.status === 0, missing: Boolean(r.error), out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
20
+ };
21
+ const plural = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
22
+ async function dropDatabaseCopies() {
23
+ if (!existsSync(homeDir()))
24
+ return null;
25
+ const store = new Store();
26
+ const urls = new Map();
27
+ let dropped = 0;
28
+ for (const a of store.databaseCopies()) {
29
+ const u = JSON.parse(a.undo);
30
+ for (const d of u.dbs ?? []) {
31
+ const url = await freshUrl(d.url).catch(() => d.url);
32
+ await discardDb({ ...d, url });
33
+ urls.set(dbKey(d.url), url);
34
+ dropped++;
35
+ }
36
+ }
37
+ if (urls.size === 0)
38
+ return null;
39
+ let schemas = 0;
40
+ for (const url of urls.values())
41
+ if (await dropSnapSchema(url))
42
+ schemas++;
43
+ return {
44
+ step: "database copies",
45
+ ok: schemas === urls.size,
46
+ detail: `dropped ${plural(dropped, "saved copy")} from ${plural(urls.size, "database")}` +
47
+ (schemas < urls.size ? `; couldn't reach ${urls.size - schemas} of them, so a "backstep" schema may be left there to drop by hand` : ""),
48
+ };
49
+ }
50
+ export async function uninstall() {
51
+ const out = [];
52
+ // Counted before anything is deleted (the install id lives in ~/.backstep).
53
+ await sendUninstall();
54
+ const servers = unwrapServers();
55
+ const restored = servers.filter((c) => c.done).length;
56
+ const kept = servers.filter((c) => !c.done && !/nothing to put back/.test(c.note));
57
+ if (servers.length) {
58
+ out.push({
59
+ step: "MCP servers",
60
+ ok: kept.length === 0,
61
+ detail: `gave ${plural(restored, "server")} back ${restored === 1 ? "its" : "their"} own command` +
62
+ (kept.length ? `; left ${kept.map((c) => `${c.name} (${c.note})`).join(", ")}` : ""),
63
+ });
64
+ }
65
+ try {
66
+ const db = await dropDatabaseCopies();
67
+ if (db)
68
+ out.push(db);
69
+ }
70
+ catch (err) {
71
+ out.push({ step: "database copies", ok: false, detail: err instanceof Error ? err.message : String(err) });
72
+ }
73
+ try {
74
+ const keys = removeUndoKeys();
75
+ if (keys.length)
76
+ out.push({ step: "undo key", ok: true, detail: `unbound ${keys.join(", ")}` });
77
+ }
78
+ catch (err) {
79
+ out.push({ step: "undo key", ok: false, detail: `couldn't edit Claude Code's keybindings: ${err instanceof Error ? err.message : String(err)}` });
80
+ }
81
+ const plugin = run("claude", ["plugin", "uninstall", "backstep@backstep"]);
82
+ const market = run("claude", ["plugin", "marketplace", "remove", "backstep"]);
83
+ if (plugin.missing) {
84
+ out.push({ step: "Claude Code plugin", ok: false, detail: "Claude Code isn't on the PATH: remove the plugin with /plugin inside Claude Code" });
85
+ }
86
+ else {
87
+ const gone = plugin.ok || /not (installed|found)/i.test(plugin.out);
88
+ out.push({ step: "Claude Code plugin", ok: gone, detail: gone ? `removed${market.ok ? ", with its source" : ""}` : plugin.out.split("\n").at(-1) ?? "failed" });
89
+ }
90
+ if (existsSync(homeDir())) {
91
+ try {
92
+ rmSync(homeDir(), { recursive: true, force: true });
93
+ out.push({ step: "Backstep's folder", ok: true, detail: `deleted ${homeDir()} (history, snapshots, saved connections)` });
94
+ }
95
+ catch (err) {
96
+ out.push({ step: "Backstep's folder", ok: false, detail: `couldn't delete ${homeDir()}: ${err instanceof Error ? err.message : String(err)}` });
97
+ }
98
+ }
99
+ // Last: this may be the copy that's running.
100
+ if (run("npm", ["ls", "-g", "backstep", "--depth=0"]).ok) {
101
+ const npm = run("npm", ["uninstall", "-g", "backstep"]);
102
+ out.push({ step: "backstep command", ok: npm.ok, detail: npm.ok ? "removed the global npm package" : "run: npm uninstall -g backstep" });
103
+ }
104
+ return out;
105
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Press ctrl+z to rewind everything Claude did in its last turn: file edits, shell commands, and database and tool changes.",
5
5
  "types": "./types/index.d.ts",
6
6
  "userConfig": {
@@ -235,14 +235,13 @@ test('servers added since setup are wrapped as a session starts', { options: { c
235
235
 
236
236
  await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
237
237
  await new Promise((r) => setTimeout(r, 10))
238
- expect(tools).toEqual(['connect', 'undo_key'])
238
+ expect(tools).toEqual(['connect', 'undo_key', 'uninstall'])
239
239
  expect(logs[0]).toContain('Backstep connected')
240
240
  expect(runs).toEqual([
241
241
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'key', '--json'],
242
242
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'wrap-servers', '--project', '/repo', '--json'],
243
- ['node', '--disable-warning=ExperimentalWarning', CLI, 'connect', '--dir', '/repo', '--json', '--auto'],
244
- ['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
245
243
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'status', '--quick', '--json', '--dir', '/repo'],
244
+ ['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
246
245
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'update-check'],
247
246
  ])
248
247
  expect(toasts.find((t) => t.includes('linear is now behind Backstep'))).toBeTruthy()
@@ -386,6 +385,8 @@ test('asking Claude to connect runs backstep connect, with the connection string
386
385
  expect(toasts).toHaveLength(1)
387
386
  })
388
387
 
388
+ const settle = () => new Promise((r) => setTimeout(r, 20))
389
+
389
390
  // A project that uses Supabase, not connected: Backstep asks, and only the answer connects it.
390
391
  function supabaseProject(on: On, answer: string, connectResult: object) {
391
392
  const runs: string[][] = []
@@ -393,7 +394,11 @@ function supabaseProject(on: On, answer: string, connectResult: object) {
393
394
  const asked: string[] = []
394
395
  on('process.run', (_, e) => {
395
396
  runs.push([...e.argv])
396
- const stdout = e.argv.includes('--auto')
397
+ const stdout = e.argv.includes('db-check')
398
+ ? JSON.stringify({ ask: '/repo', service: 'supabase' })
399
+ : e.argv.includes('shell')
400
+ ? JSON.stringify({ kind: 'write', undoable: true })
401
+ : e.argv.includes('--auto')
397
402
  ? JSON.stringify([{ service: 'supabase', state: 'ask', detail: "this project uses Supabase, but Backstep isn't connected to it" }])
398
403
  : e.argv.includes('status')
399
404
  ? JSON.stringify({ items: [], suggest: [] })
@@ -413,15 +418,32 @@ function supabaseProject(on: On, answer: string, connectResult: object) {
413
418
  return { result: { questions, answers: { [questions[0].question]: answer } } }
414
419
  })
415
420
  on('session.start', (_, e) => ({ cwd: e.cwd }))
421
+ on('session.cwd', () => ({ value: '/repo' }))
422
+ on('session.id', () => ({ value: 's1' }))
423
+ on('tool.call', () => ({ result: {} }))
416
424
  return { runs, toasts, asked }
417
425
  }
418
426
 
419
- const settle = () => new Promise((r) => setTimeout(r, 20))
427
+ // Claude is about to change the database: the moment Backstep asks.
428
+ async function changeDatabase($: Parameters<Parameters<typeof test>[2]>[0], on: On) {
429
+ on('ui.log', () => ({ value: undefined }))
430
+ mock.clock(on, { now: 5000 })
431
+ await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
432
+ await settle()
433
+ await $.tool.call({ tool: 'Bash', command: 'supabase db push' })
434
+ await settle()
435
+ }
420
436
 
421
- test('a Supabase project that is not connected is asked about, and Connect connects it', { options: { cli: CLI } }, async ($, on) => {
422
- const { runs, toasts, asked } = supabaseProject(on, 'Connect', { service: 'supabase', state: 'connected', detail: 'connected project abc' })
437
+ test('opening a session never asks about a database, even with Supabase projects below the folder', { options: { cli: CLI } }, async ($, on) => {
438
+ const { asked } = supabaseProject(on, 'Connect', {})
423
439
  await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
424
440
  await settle()
441
+ expect(asked).toHaveLength(0)
442
+ })
443
+
444
+ test('a Supabase project that is not connected is asked about when Claude goes to change it, and Connect connects it', { options: { cli: CLI } }, async ($, on) => {
445
+ const { runs, toasts, asked } = supabaseProject(on, 'Connect', { service: 'supabase', state: 'connected', detail: 'connected project abc' })
446
+ await changeDatabase($, on)
425
447
  expect(asked).toHaveLength(1)
426
448
  expect(runs.some((r) => r.slice(3).join(' ') === 'connect supabase --dir /repo --json')).toBe(true)
427
449
  expect(toasts.find((t) => t.includes('connected project abc'))).toBeTruthy()
@@ -429,15 +451,13 @@ test('a Supabase project that is not connected is asked about, and Connect conne
429
451
 
430
452
  test('Not now connects nothing', { options: { cli: CLI } }, async ($, on) => {
431
453
  const { runs } = supabaseProject(on, 'Not now', {})
432
- await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
433
- await settle()
454
+ await changeDatabase($, on)
434
455
  expect(runs.filter((r) => r.includes('connect') && !r.includes('--auto'))).toHaveLength(0)
435
456
  })
436
457
 
437
458
  test("Don't ask for this project remembers it", { options: { cli: CLI } }, async ($, on) => {
438
459
  const { runs } = supabaseProject(on, "Don't ask for this project", {})
439
- await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
440
- await settle()
460
+ await changeDatabase($, on)
441
461
  expect(runs.some((r) => r.slice(3).join(' ') === 'connect supabase --dir /repo --dismiss')).toBe(true)
442
462
  })
443
463
 
@@ -524,9 +544,22 @@ function updates(on: On, answer: string) {
524
544
  return { result: { questions, answers: { [questions[0].question]: answer } } }
525
545
  })
526
546
  on('session.start', (_, e) => ({ cwd: e.cwd }))
547
+ on('session.cwd', () => ({ value: '/repo' }))
548
+ on('session.id', () => ({ value: 's1' }))
549
+ on('tool.call', () => ({ result: {} }))
527
550
  return { runs, toasts, asked }
528
551
  }
529
552
 
553
+ // Claude is about to change the database: the moment Backstep asks.
554
+ async function changeDatabase($: Parameters<Parameters<typeof test>[2]>[0], on: On) {
555
+ on('ui.log', () => ({ value: undefined }))
556
+ mock.clock(on, { now: 5000 })
557
+ await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
558
+ await settle()
559
+ await $.tool.call({ tool: 'Bash', command: 'supabase db push' })
560
+ await settle()
561
+ }
562
+
530
563
  test('a newer version is offered as a session starts, and Update installs it', { options: { cli: CLI } }, async ($, on) => {
531
564
  const { runs, toasts, asked } = updates(on, 'Update')
532
565
  await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
@@ -826,3 +859,39 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
826
859
  expect(logs.find((l) => l.includes('Backstep undid'))).not.toContain('changes GitHub')
827
860
  await ui.unmount()
828
861
  })
862
+
863
+ // Uninstalling: asked first, and only the answer removes anything.
864
+ function uninstallSetup(on: On, answer: string) {
865
+ const runs: string[][] = []
866
+ const asked: string[] = []
867
+ on('session.cwd', () => ({ value: '/repo' }))
868
+ on('session.id', () => ({ value: 's1' }))
869
+ on('ui.toast', () => ({ value: undefined }))
870
+ on('process.run', (_, e) => {
871
+ runs.push([...e.argv])
872
+ const stdout = e.argv.includes('uninstall') ? JSON.stringify([{ step: 'Claude Code plugin', ok: true, detail: 'removed' }]) : ''
873
+ return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
874
+ })
875
+ on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
876
+ const questions = (e as unknown as { questions: { question: string }[] }).questions
877
+ asked.push(questions[0].question)
878
+ return { result: { questions, answers: { [questions[0].question]: answer } } }
879
+ })
880
+ return { runs, asked }
881
+ }
882
+
883
+ test('/uninstall-backstep asks, then removes Backstep', { options: { cli: CLI } }, async ($, on) => {
884
+ const { runs, asked } = uninstallSetup(on, 'Remove Backstep')
885
+ const r = await $.command.run({ command: 'uninstall-backstep' } as never)
886
+ expect(asked).toHaveLength(1)
887
+ expect(runs.at(-1)?.slice(3)).toEqual(['uninstall', '--yes', '--json'])
888
+ expect(JSON.stringify(r)).toContain('Backstep is removed')
889
+ })
890
+
891
+ test('asking Claude to uninstall asks too, and Keep it removes nothing', { options: { cli: CLI } }, async ($, on) => {
892
+ const { runs, asked } = uninstallSetup(on, 'Keep it')
893
+ const r = await $.tool.call({ tool: 'mcp__backstep__uninstall' } as never)
894
+ expect(asked).toHaveLength(1)
895
+ expect(runs.filter((a) => a.includes('uninstall'))).toHaveLength(0)
896
+ expect(r.result).toBe('Kept Backstep: nothing was removed.')
897
+ })
@@ -489,17 +489,9 @@ async function wrapNewServers($: EngineInterface, cwd: string) {
489
489
  // says what Claude reaches that Backstep could undo once connected, so nobody
490
490
  // presses undo expecting it to come back.
491
491
  async function checkCoverage($: EngineInterface, cwd: string) {
492
- // Supabase is only ever noticed here, never connected: that's the person's
493
- // call. Firebase is added once someone is signed in to it.
494
- const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
495
- try {
496
- for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
497
- if (c.state === 'ask') await askDatabase($, c.dir ?? cwd, c.service)
498
- if (c.state === 'added') $.ui.toast('Backstep added Firebase for this project, so Claude can reach it and its changes can be undone. Restart Claude Code to use it.')
499
- }
500
- } catch {
501
- // Backstep couldn't be reached: nothing to say.
502
- }
492
+ // Nothing is asked or set up here: a database (Supabase, Postgres, Firebase)
493
+ // is only asked about once Claude is about to change it (see the Bash hook),
494
+ // not just because a project in or below this folder happens to use one.
503
495
  const ran = await $.process.run(backstep(['status', '--quick', '--json', '--dir', cwd])).catch(() => null)
504
496
  let items: { name: string; level: string; detail: string }[] = []
505
497
  try {
@@ -525,9 +517,9 @@ const asked = new Set<string>()
525
517
  // How each database is named in the question, and what the answer connects.
526
518
  const DB_NAME: Record<string, string> = { supabase: 'Supabase', postgres: 'a Postgres database', firebase: 'Firebase' }
527
519
 
528
- // Asked when Claude Code opens a project that uses a database (Supabase, other
529
- // Postgres, Firebase), or Claude is about to change one, and Backstep isn't
530
- // connected to it; the answer is the only thing that connects it.
520
+ // Asked when Claude is about to change a database (Supabase, other Postgres,
521
+ // Firebase) that Backstep isn't connected to; never just because a project uses
522
+ // one. The answer is the only thing that connects it.
531
523
  async function askDatabase($: EngineInterface, cwd: string, service = 'supabase') {
532
524
  const key = `${service}:${cwd}`
533
525
  if (asked.has(key)) return
@@ -676,12 +668,56 @@ async function connectServices($: EngineInterface, input: { service?: string; co
676
668
  return out.map((s) => `${s.service === 'supabase' ? 'Supabase' : 'Firebase'} (${s.state}): ${s.detail}`).join('\n')
677
669
  }
678
670
 
671
+ const UNINSTALL = 'mcp__backstep__uninstall'
672
+
673
+ const UNINSTALL_TOOL = {
674
+ name: 'uninstall',
675
+ description:
676
+ 'Remove Backstep (the undo for this session) from this computer: puts MCP servers back as they were, drops its saved database copies, ' +
677
+ "removes the undo key and the Claude Code plugin, and deletes Backstep's history. Use it only when the user asks to uninstall, remove or delete Backstep. " +
678
+ 'The user is asked to confirm first; nothing else is needed.',
679
+ inputSchema: { type: 'object', properties: {} },
680
+ }
681
+
682
+ const UNINSTALL_ASK = { yes: 'Remove Backstep', no: 'Keep it' }
683
+
684
+ // Asked first, every time: the answer is the only thing that removes it.
685
+ async function uninstallBackstep($: EngineInterface): Promise<string> {
686
+ const answer = await $.ui
687
+ .ask(
688
+ "Remove Backstep from this computer? Your MCP servers go back to how they were, and the undo key, the plugin and Backstep's history are removed. " +
689
+ "Nothing you've undone or kept changes, but Claude's turns can't be undone after this.",
690
+ {
691
+ header: 'Backstep',
692
+ options: [UNINSTALL_ASK.yes, UNINSTALL_ASK.no],
693
+ },
694
+ )
695
+ .catch(() => null)
696
+ if (answer !== UNINSTALL_ASK.yes) return 'Kept Backstep: nothing was removed.'
697
+ const ran = await $.process
698
+ .run(backstep(['uninstall', '--yes', '--json']), { timeoutMs: 180_000 })
699
+ .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
700
+ let removed: { step: string; ok: boolean; detail: string }[] = []
701
+ try {
702
+ removed = JSON.parse(ran.stdout || '[]') as typeof removed
703
+ } catch {
704
+ removed = []
705
+ }
706
+ if (removed.length === 0) return `Backstep couldn't uninstall itself: ${(ran.stderr || ran.stdout).trim().split('\n').at(-1) || 'it failed'}. Run: backstep uninstall`
707
+ const lines = removed.map((r) => `${r.ok ? '✓' : '!'} ${r.step}: ${r.detail}`).join('\n')
708
+ $.ui.toast('Backstep is removed. Restart Claude Code to finish.')
709
+ return `Backstep is removed. Restart Claude Code to finish.\n${lines}`
710
+ }
711
+
679
712
  export const register: Register = (on, options) => {
680
713
  cli = String(options.cli ?? 'backstep')
681
714
 
682
715
  on('session.start', async ($, e, next) => {
683
716
  await $.tool.register(CONNECT_TOOL)
684
717
  await $.tool.register(KEY_TOOL)
718
+ await $.tool.register(UNINSTALL_TOOL)
719
+ // A command that can't be added never holds up the session: asking Claude still uninstalls.
720
+ await $.command.register({ name: 'uninstall-backstep', description: 'Remove Backstep and everything it set up from this computer' }).catch(() => null)
685
721
  const r = await next(e)
686
722
  const boundOut = await $.process.run(backstep(['key', '--json'])).catch(() => null)
687
723
  try {
@@ -719,9 +755,13 @@ export const register: Register = (on, options) => {
719
755
 
720
756
  on('tool.call', { tool: CONNECT }, async ($, e) => ({ result: await connectServices($, e as { service?: string; connection_string?: string; project_ref?: string }) }))
721
757
 
758
+ on('tool.call', { tool: UNINSTALL }, async ($) => ({ result: await uninstallBackstep($) }))
759
+
760
+ on('command.run', { command: 'uninstall-backstep' }, async ($) => ({ text: await uninstallBackstep($) }))
761
+
722
762
  on('tool.call', async ($, e, next) => {
723
763
  const tool = String(e.tool)
724
- if (tool === CONNECT || tool === KEY) return next(e)
764
+ if (tool === CONNECT || tool === KEY || tool === UNINSTALL) return next(e)
725
765
  if (EDIT_TOOLS.has(tool)) {
726
766
  const input = e as { file_path?: string; notebook_path?: string }
727
767
  const path = input.file_path ?? input.notebook_path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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",