backstep 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -2
- package/dist/adapters/fs.js +66 -0
- package/dist/adapters/postgres.js +220 -0
- package/dist/adapters/tree.js +171 -0
- package/dist/check.js +117 -0
- package/dist/classify.js +53 -0
- package/dist/cli.js +408 -0
- package/dist/connect.js +237 -0
- package/dist/firebase.js +137 -0
- package/dist/look.js +34 -0
- package/dist/proxy.js +285 -0
- package/dist/push.js +108 -0
- package/dist/rules.js +86 -0
- package/dist/servers.js +226 -0
- package/dist/setup.js +109 -0
- package/dist/shell.js +112 -0
- package/dist/sql.js +88 -0
- package/dist/store.js +132 -0
- package/dist/supabase.js +245 -0
- package/dist/telemetry.js +77 -0
- package/dist/ui/index.html +246 -0
- package/dist/ui/server.js +70 -0
- package/dist/undo.js +176 -0
- package/dist/watch.js +138 -0
- package/mod/.claude-plugin/marketplace.json +5 -0
- package/mod/.claude-plugin/plugin.json +14 -0
- package/mod/hooks/hooks.json +1 -0
- package/mod/hooks/register.test.tsx +432 -0
- package/mod/hooks/register.tsx +529 -0
- package/mod/types/index.d.ts +30 -0
- package/package.json +48 -4
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
// Backstep for Claude Code: snapshot every file Claude is about to change,
|
|
2
|
+
// and the whole project before each shell command, and rewind its whole last
|
|
3
|
+
// turn with one key (ctrl+z) and a short animation above the prompt. Database
|
|
4
|
+
// and tool changes made through servers wrapped by `backstep wrap`, and
|
|
5
|
+
// Supabase changes over a connected database, rewind with it, by the turn's
|
|
6
|
+
// time window.
|
|
7
|
+
import { atom, read, update } from 'claude-code'
|
|
8
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
9
|
+
|
|
10
|
+
import type { Rewind, RewindItem, Turn } from '../types'
|
|
11
|
+
|
|
12
|
+
const turns = atom({ plugin: 'backstep', key: 'turns' } as const, [] as Turn[])
|
|
13
|
+
const rewind = atom({ plugin: 'backstep', key: 'rewind' } as const, null as Rewind | null)
|
|
14
|
+
const note = atom({ plugin: 'backstep', key: 'note' } as const, null as string | null)
|
|
15
|
+
|
|
16
|
+
// The engine has no keybinding actions of its own for plugins yet, so the undo
|
|
17
|
+
// button borrows one that only the diff panel handles; ~/.claude/keybindings.json
|
|
18
|
+
// binds ctrl+z to it everywhere else.
|
|
19
|
+
const UNDO_ACTION = 'app:cycleDiffBase'
|
|
20
|
+
const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
|
|
21
|
+
const KEEP_TURNS = 30
|
|
22
|
+
const BAR = 26
|
|
23
|
+
|
|
24
|
+
const hasWork = (t: Turn) => !t.undone && t.edits + t.mcp + (t.shell ?? 0) > 0
|
|
25
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
26
|
+
|
|
27
|
+
function summary(t: Turn): string {
|
|
28
|
+
const parts: string[] = []
|
|
29
|
+
if (t.edits) parts.push(`${plural(t.edits, 'edit')} to ${plural(t.files.length, 'file')}`)
|
|
30
|
+
if (t.shell) parts.push(plural(t.shell, 'shell command'))
|
|
31
|
+
if (t.mcp) parts.push(plural(t.mcp, 'tool action'))
|
|
32
|
+
return parts.join(', ')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A rewind bar: a run of arrows sliding right to left, like tape winding back.
|
|
36
|
+
function bar(frame: number): string {
|
|
37
|
+
const head = BAR - 1 - (frame % (BAR + 4))
|
|
38
|
+
return Array.from({ length: BAR }, (_, i) => (i >= head && i < head + 4 ? '◂' : '─')).join('')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Pushed = { remote: string; branch: string; before: string | null; after: string; commits: number; host: string }
|
|
42
|
+
type Outcome = {
|
|
43
|
+
id: string
|
|
44
|
+
tool: string
|
|
45
|
+
ok: boolean
|
|
46
|
+
detail: string
|
|
47
|
+
run?: { server: string; tool: string; args: Record<string, unknown> }
|
|
48
|
+
pushed?: { root: string; pushes: Pushed[] }
|
|
49
|
+
}
|
|
50
|
+
const PUSH = /\bgit\b[^;&|]*\bpush\b/
|
|
51
|
+
|
|
52
|
+
const PUSH_ASK = { yes: 'Push the undo', no: 'Leave it as it is' }
|
|
53
|
+
|
|
54
|
+
// Undo rewound the code here; this asks before taking it back on GitHub too,
|
|
55
|
+
// as a new commit that undoes Claude's (never a force push).
|
|
56
|
+
async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
|
|
57
|
+
for (const o of outcomes) {
|
|
58
|
+
if (!o.ok || !o.pushed) continue
|
|
59
|
+
for (const p of o.pushed.pushes) {
|
|
60
|
+
const what =
|
|
61
|
+
p.before === null
|
|
62
|
+
? `Claude pushed a new branch, ${p.branch}, to ${p.host}. Delete it there too?`
|
|
63
|
+
: `Claude pushed ${p.commits === 1 ? '1 commit' : `${p.commits} commits`} to ${p.branch} on ${p.host}. Undo ${p.commits === 1 ? 'it' : 'them'} there too?`
|
|
64
|
+
const answer = await $.ui.ask(what, { header: 'Backstep', options: [p.before === null ? 'Delete the branch' : PUSH_ASK.yes, PUSH_ASK.no] }).catch(() => null)
|
|
65
|
+
if (!answer || answer === PUSH_ASK.no) continue
|
|
66
|
+
const ran = await $.process
|
|
67
|
+
.run(backstep(['revert-push', '--root', o.pushed.root]), { stdin: JSON.stringify(p), timeoutMs: 180_000 })
|
|
68
|
+
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
69
|
+
let r: { ok: boolean; detail: string } = { ok: false, detail: (ran.stderr || 'it failed').trim().split('\n').at(-1) ?? 'it failed' }
|
|
70
|
+
try {
|
|
71
|
+
r = JSON.parse(ran.stdout) as typeof r
|
|
72
|
+
} catch {
|
|
73
|
+
// keep the error above
|
|
74
|
+
}
|
|
75
|
+
$.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}. Nothing was changed on ${p.host}.`)
|
|
76
|
+
await update($, note, (n) =>
|
|
77
|
+
n ? `${n}\n${r.ok ? `On ${p.host}: ${r.detail}.` : `${p.host} was left as it was: ${r.detail}.`}` : n,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function undoLastTurn($: EngineInterface) {
|
|
84
|
+
if (await read($, rewind)) return
|
|
85
|
+
const target = (await read($, turns)).findLast(hasWork)
|
|
86
|
+
if (!target) return
|
|
87
|
+
|
|
88
|
+
await update($, rewind, (): Rewind => ({ phase: 'running', frame: 0, shown: 0, items: [], message: '' }))
|
|
89
|
+
const ticker = $.clock.every(55, () => {
|
|
90
|
+
void update($, rewind, (r) => (r && r.phase === 'running' ? { ...r, frame: r.frame + 1 } : r))
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const cwd = await $.session.cwd()
|
|
94
|
+
const short = (p: string) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p)
|
|
95
|
+
const [ran] = await Promise.all([
|
|
96
|
+
$.process.run(backstep(['undo', '--since', String(target.startedAt), '--json', '--via-claude-code'])).catch((err: unknown) => ({
|
|
97
|
+
exitCode: 1,
|
|
98
|
+
stdout: '',
|
|
99
|
+
stderr: err instanceof Error ? err.message : String(err),
|
|
100
|
+
})),
|
|
101
|
+
// Long enough to read as a rewind, short enough to never feel slow.
|
|
102
|
+
$.clock.sleep(700),
|
|
103
|
+
])
|
|
104
|
+
ticker.cancel()
|
|
105
|
+
|
|
106
|
+
let outcomes: Outcome[] = []
|
|
107
|
+
try {
|
|
108
|
+
outcomes = JSON.parse(ran.stdout || '[]') as Outcome[]
|
|
109
|
+
} catch {
|
|
110
|
+
outcomes = [{ id: '', tool: 'backstep', ok: false, detail: (ran.stderr || ran.stdout).trim().split('\n')[0] ?? 'failed' }]
|
|
111
|
+
}
|
|
112
|
+
if (ran.exitCode !== 0 && outcomes.length === 0) {
|
|
113
|
+
outcomes = [{ id: '', tool: 'backstep', ok: false, detail: ran.stderr.trim().split('\n').at(-1) ?? 'failed' }]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Opposite calls on servers only Claude Code is connected to run here, newest first.
|
|
117
|
+
for (const o of outcomes) {
|
|
118
|
+
if (!o.ok || !o.run) continue
|
|
119
|
+
const done = await $.mcp.call(o.run.server, o.run.tool, o.run.args).catch((err: unknown) => ({
|
|
120
|
+
isError: true,
|
|
121
|
+
content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
|
|
122
|
+
}))
|
|
123
|
+
if (done.isError) {
|
|
124
|
+
const why = done.content.map((c) => (c.type === 'text' ? c.text : '')).join(' ').trim().split('\n')[0] || 'failed'
|
|
125
|
+
o.ok = false
|
|
126
|
+
o.detail = `${o.run.tool} failed: ${why}`
|
|
127
|
+
await $.process.run(backstep(['settle', o.id, '--failed', why])).catch(() => null)
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
o.detail = `ran ${o.run.tool}`
|
|
131
|
+
await $.process.run(backstep(['settle', o.id])).catch(() => null)
|
|
132
|
+
}
|
|
133
|
+
// Stop the list at the first failure, as the CLI does.
|
|
134
|
+
const cut = outcomes.findIndex((o) => !o.ok)
|
|
135
|
+
if (cut !== -1) outcomes = outcomes.slice(0, cut + 1)
|
|
136
|
+
|
|
137
|
+
const items: RewindItem[] = outcomes.map((o) => {
|
|
138
|
+
const first = o.detail.split('\n')[0] ?? ''
|
|
139
|
+
const what = first.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`)
|
|
140
|
+
return { label: o.ok ? what || o.tool : `${o.tool}: ${first}`, ok: o.ok }
|
|
141
|
+
})
|
|
142
|
+
const done = items.filter((i) => i.ok).length
|
|
143
|
+
const failed = items.some((i) => !i.ok)
|
|
144
|
+
// A push counted as lost when it ran, but undo can now offer to take it back there.
|
|
145
|
+
const offered = outcomes.filter((o) => o.ok && o.pushed).length
|
|
146
|
+
const lost = Math.max(0, (target.lost ?? 0) - offered)
|
|
147
|
+
const shell = lost ? ` ${plural(lost, 'action')} that reached outside couldn't be undone.` : ''
|
|
148
|
+
|
|
149
|
+
await update($, rewind, (): Rewind => ({
|
|
150
|
+
phase: failed ? 'failed' : items.length ? 'done' : 'empty',
|
|
151
|
+
frame: 0,
|
|
152
|
+
shown: 0,
|
|
153
|
+
items,
|
|
154
|
+
message: failed
|
|
155
|
+
? `Stopped after ${plural(done, 'change')}: the next one couldn't be undone.`
|
|
156
|
+
: items.length
|
|
157
|
+
? `Rewound ${plural(done, 'change')} from the last turn.${shell}`
|
|
158
|
+
: `Nothing in the last turn could be undone.${shell}`,
|
|
159
|
+
}))
|
|
160
|
+
await bump($, target.id, (t) => ({ ...t, undone: !failed }))
|
|
161
|
+
|
|
162
|
+
if (done) {
|
|
163
|
+
const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
|
|
164
|
+
await update($, note, () =>
|
|
165
|
+
`[Backstep] The user pressed undo (ctrl+z) on your previous turn. These changes were reverted:\n${list}\n` +
|
|
166
|
+
(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
|
+
`Treat them as not having happened, re-read files before editing them, and don't redo them unless asked.`,
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Asked once the band shows what came back.
|
|
172
|
+
void offerPushUndo($, outcomes)
|
|
173
|
+
|
|
174
|
+
// Reveal the restored items one by one, then fold the band away.
|
|
175
|
+
const reveal = $.clock.every(90, () => {
|
|
176
|
+
void update($, rewind, (r) => (r && r.shown < r.items.length ? { ...r, shown: r.shown + 1 } : r))
|
|
177
|
+
})
|
|
178
|
+
$.clock.after(failed ? 9000 : 4200, () => {
|
|
179
|
+
reveal.cancel()
|
|
180
|
+
void update($, rewind, () => null)
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type Next = (e: Record<string, unknown>) => Promise<{ deny?: string; isError?: true; result?: unknown; text?: string }>
|
|
185
|
+
type WatchPlan = {
|
|
186
|
+
kind: 'wrapped' | 'read' | 'irreversible' | 'write' | 'reversible'
|
|
187
|
+
id?: string
|
|
188
|
+
reason?: string
|
|
189
|
+
args?: Record<string, unknown>
|
|
190
|
+
hint?: 'supabase'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Said once a session: a Supabase change went through that could have been undoable.
|
|
194
|
+
let hinted = false
|
|
195
|
+
|
|
196
|
+
function hint($: EngineInterface, plan: WatchPlan | null) {
|
|
197
|
+
if (plan?.hint !== 'supabase' || hinted) return
|
|
198
|
+
hinted = true
|
|
199
|
+
$.ui.toast("Backstep can't undo Supabase changes until it's connected to the database. Ask Claude to connect Backstep to Supabase.")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The keys a tool.call carries that aren't the tool's own arguments.
|
|
203
|
+
const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId', 'requestMeta'])
|
|
204
|
+
|
|
205
|
+
// An MCP call. Servers behind Backstep's proxy record themselves; for the rest
|
|
206
|
+
// (claude.ai connectors, servers Claude Code signs in to) Backstep records the
|
|
207
|
+
// call here and keeps an opposite call when an undo rule matches, which undo
|
|
208
|
+
// runs through Claude Code's own connection. Supabase SQL is snapshotted
|
|
209
|
+
// first, and a DROP comes back rewritten as a move to Backstep's trash.
|
|
210
|
+
async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Next) {
|
|
211
|
+
const tool = String(e.tool)
|
|
212
|
+
const t = await current($)
|
|
213
|
+
const args = Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))
|
|
214
|
+
const cwd = await $.session.cwd()
|
|
215
|
+
const sid = await $.session.id()
|
|
216
|
+
const started = await $.process
|
|
217
|
+
.run(backstep(['watch-start', '--session', sid, '--cwd', cwd, '--', tool]), { stdin: JSON.stringify(args) })
|
|
218
|
+
.catch(() => null)
|
|
219
|
+
let plan: WatchPlan | null = null
|
|
220
|
+
try {
|
|
221
|
+
plan = JSON.parse(started?.stdout || 'null') as WatchPlan | null
|
|
222
|
+
} catch {
|
|
223
|
+
plan = null
|
|
224
|
+
}
|
|
225
|
+
hint($, plan)
|
|
226
|
+
// Behind Backstep, or Backstep couldn't be reached: count it as before.
|
|
227
|
+
if (!plan || plan.kind === 'wrapped' || !plan.id) {
|
|
228
|
+
if (plan?.kind !== 'read') await bump($, t.id, (x) => ({ ...x, mcp: x.mcp + 1 }))
|
|
229
|
+
return next(e)
|
|
230
|
+
}
|
|
231
|
+
const r = await next(plan.args ? { ...e, ...plan.args } : e)
|
|
232
|
+
if (r.deny) {
|
|
233
|
+
await $.process.run(backstep(['cancel', plan.id])).catch(() => null)
|
|
234
|
+
return r
|
|
235
|
+
}
|
|
236
|
+
const ended = await $.process
|
|
237
|
+
.run(backstep(['watch-end', plan.id]), { stdin: JSON.stringify({ isError: r.isError ?? false, result: r.result, text: r.text }) })
|
|
238
|
+
.catch(() => null)
|
|
239
|
+
let undoable = false
|
|
240
|
+
try {
|
|
241
|
+
undoable = (JSON.parse(ended?.stdout || '{}') as { undoable?: boolean }).undoable === true
|
|
242
|
+
} catch {
|
|
243
|
+
undoable = false
|
|
244
|
+
}
|
|
245
|
+
if (!r.isError) await bump($, t.id, (x) => (undoable ? { ...x, mcp: x.mcp + 1 } : { ...x, lost: (x.lost ?? 0) + 1 }))
|
|
246
|
+
return r
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
type ShellPlan = { kind: 'read' | 'write' | 'outbound'; reason?: string; id?: string; undoable: boolean }
|
|
250
|
+
|
|
251
|
+
// Set from the plugin's options each time the module registers.
|
|
252
|
+
let cli = 'backstep'
|
|
253
|
+
|
|
254
|
+
function backstep(args: string[]): string[] {
|
|
255
|
+
return cli === 'backstep' ? ['backstep', ...args] : ['node', '--disable-warning=ExperimentalWarning', cli, ...args]
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function newTurn(startedAt: number): Turn {
|
|
259
|
+
return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The turn tool calls belong to; one opens if none is (a call before any prompt).
|
|
263
|
+
async function current($: EngineInterface): Promise<Turn> {
|
|
264
|
+
const last = (await read($, turns)).at(-1)
|
|
265
|
+
if (last && !last.undone) return last
|
|
266
|
+
const t = newTurn(await $.clock.now())
|
|
267
|
+
await update($, turns, (l) => [...(l ?? []), t].slice(-KEEP_TURNS))
|
|
268
|
+
return t
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function bump($: EngineInterface, id: string, fn: (t: Turn) => Turn): Promise<void> {
|
|
272
|
+
await update($, turns, (l) => (l ?? []).map((t) => (t.id === id ? fn(t) : t)))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// MCP servers added since setup go behind Backstep too. Claude Code has already
|
|
276
|
+
// started this session's servers, so the change takes effect from the next one.
|
|
277
|
+
async function wrapNewServers($: EngineInterface, cwd: string) {
|
|
278
|
+
const ran = await $.process.run(backstep(['wrap-servers', '--project', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
|
|
279
|
+
let changes: { name: string; done: boolean }[] = []
|
|
280
|
+
try {
|
|
281
|
+
changes = JSON.parse(ran?.stdout || '[]') as typeof changes
|
|
282
|
+
} catch {
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
const names = changes.filter((c) => c.done).map((c) => c.name)
|
|
286
|
+
if (names.length === 0) return
|
|
287
|
+
$.ui.toast(
|
|
288
|
+
`Backstep: ${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} now behind Backstep, so ${names.length === 1 ? 'its' : 'their'} changes can be undone. Restart Claude Code to start using that.`,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// As a session starts: connects what the project's own settings allow, then
|
|
293
|
+
// says what Claude reaches that Backstep could undo once connected, so nobody
|
|
294
|
+
// presses ctrl+z expecting it to come back.
|
|
295
|
+
async function checkCoverage($: EngineInterface, cwd: string) {
|
|
296
|
+
// Supabase is only ever noticed here, never connected: that's the person's
|
|
297
|
+
// call. Firebase is added once someone is signed in to it.
|
|
298
|
+
const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
|
|
299
|
+
try {
|
|
300
|
+
for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
|
|
301
|
+
if (c.service === 'supabase' && c.state === 'ask') void askSupabase($, cwd)
|
|
302
|
+
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.')
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
// Backstep couldn't be reached: nothing to say.
|
|
306
|
+
}
|
|
307
|
+
const ran = await $.process.run(backstep(['status', '--quick', '--json', '--dir', cwd])).catch(() => null)
|
|
308
|
+
let items: { name: string; level: string; detail: string }[] = []
|
|
309
|
+
try {
|
|
310
|
+
items = (JSON.parse(ran?.stdout || '{}') as { items?: typeof items }).items ?? []
|
|
311
|
+
} catch {
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
// Supabase has its own question above, asked only where a project uses it.
|
|
315
|
+
const missing = [...new Set(items.filter((i) => i.level === 'connect' && !/supabase/i.test(i.name)).map((i) => i.name))]
|
|
316
|
+
if (missing.length === 0) return
|
|
317
|
+
$.ui.toast(`Backstep can't undo Claude's changes to ${missing.join(', ')} yet. They become undoable from the next Claude Code session.`)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const ASK = {
|
|
321
|
+
connect: 'Connect',
|
|
322
|
+
later: 'Not now',
|
|
323
|
+
never: "Don't ask for this project",
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Asked when Claude Code opens a project that uses Supabase and Backstep isn't
|
|
327
|
+
// connected to it; the answer is the only thing that connects it.
|
|
328
|
+
async function askSupabase($: EngineInterface, cwd: string) {
|
|
329
|
+
const answer = await $.ui
|
|
330
|
+
.ask("This project uses Supabase, but Backstep isn't connected to it, so Claude's changes to its database can't be undone. Connect it?", {
|
|
331
|
+
header: 'Backstep',
|
|
332
|
+
options: [ASK.connect, ASK.later, ASK.never],
|
|
333
|
+
})
|
|
334
|
+
.catch(() => null)
|
|
335
|
+
if (answer === ASK.never) {
|
|
336
|
+
await $.process.run(backstep(['connect', 'supabase', '--dir', cwd, '--dismiss'])).catch(() => null)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
if (answer !== ASK.connect) return
|
|
340
|
+
const ran = await $.process.run(backstep(['connect', 'supabase', '--dir', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
|
|
341
|
+
let s: Connected | undefined
|
|
342
|
+
try {
|
|
343
|
+
s = (JSON.parse(ran?.stdout || '[]') as Connected[])[0]
|
|
344
|
+
} catch {
|
|
345
|
+
s = undefined
|
|
346
|
+
}
|
|
347
|
+
if (s?.state === 'connected' || s?.state === 'covered') {
|
|
348
|
+
$.ui.toast(`Backstep ${s.detail}. Claude's changes to its database can now be undone.`)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
// Nothing in the project to connect with: a sign-in is needed, and Claude walks the person through it.
|
|
352
|
+
$.ui.toast('Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const CONNECT = 'mcp__backstep__connect'
|
|
356
|
+
|
|
357
|
+
const CONNECT_TOOL = {
|
|
358
|
+
name: 'connect',
|
|
359
|
+
description:
|
|
360
|
+
"Connect Backstep (the undo for this session's changes) to the services the project uses, so changes Claude makes to them can be undone with ctrl+z. " +
|
|
361
|
+
'Use it when the user asks to connect Backstep, or to make their database or Firebase undoable. ' +
|
|
362
|
+
"Supabase: finds the database's connection string in the project's .env files or its local `supabase start` database. " +
|
|
363
|
+
'When it reports it needs a sign-in, tell the user how to make an access token (it says where), ask them to paste it, then call this again with connection_string. ' +
|
|
364
|
+
'Never repeat a token or connection string back: they are secrets. ' +
|
|
365
|
+
"Firebase: adds Firebase's MCP server to the project when Claude can't reach Firebase yet; nothing else is needed.",
|
|
366
|
+
inputSchema: {
|
|
367
|
+
type: 'object',
|
|
368
|
+
properties: {
|
|
369
|
+
service: { type: 'string', enum: ['all', 'supabase', 'firebase'], description: 'what to connect; all when the user names nothing' },
|
|
370
|
+
connection_string: {
|
|
371
|
+
type: 'string',
|
|
372
|
+
description: 'what the user gave to connect Supabase: an access token (sbp_…, connects every project) or a database connection string (postgresql://…)',
|
|
373
|
+
},
|
|
374
|
+
project_ref: { type: 'string', description: 'the Supabase project ref to save it under, when the connection string has none' },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
type Connected = { service: string; state: string; detail: string }
|
|
380
|
+
|
|
381
|
+
async function connectServices($: EngineInterface, input: { service?: string; connection_string?: string; project_ref?: string }) {
|
|
382
|
+
const cwd = await $.session.cwd()
|
|
383
|
+
const service = input.service === 'supabase' || input.service === 'firebase' ? [input.service] : []
|
|
384
|
+
const ref = input.project_ref ? ['--project', input.project_ref] : []
|
|
385
|
+
// The connection string goes on stdin, never in an argv other processes can read.
|
|
386
|
+
const ran = await $.process
|
|
387
|
+
.run(backstep(['connect', ...service, ...ref, '--dir', cwd, '--json', '--stdin']), { stdin: input.connection_string ?? '', timeoutMs: 180_000 })
|
|
388
|
+
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
389
|
+
let out: Connected[] = []
|
|
390
|
+
try {
|
|
391
|
+
out = JSON.parse(ran.stdout || '[]') as Connected[]
|
|
392
|
+
} catch {
|
|
393
|
+
out = []
|
|
394
|
+
}
|
|
395
|
+
if (out.length === 0) return `Backstep couldn't connect: ${(ran.stderr || ran.stdout).trim().split('\n').at(-1) || 'it failed'}`
|
|
396
|
+
if (out.some((s) => s.state === 'connected' || s.state === 'added')) $.ui.toast('Backstep: connected. Changes Claude makes there can now be undone.')
|
|
397
|
+
return out.map((s) => `${s.service === 'supabase' ? 'Supabase' : 'Firebase'} (${s.state}): ${s.detail}`).join('\n')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export const register: Register = (on, options) => {
|
|
401
|
+
cli = String(options.cli ?? 'backstep')
|
|
402
|
+
|
|
403
|
+
on('session.start', async ($, e, next) => {
|
|
404
|
+
await $.tool.register(CONNECT_TOOL)
|
|
405
|
+
const r = await next(e)
|
|
406
|
+
// In the background: a session never waits on either.
|
|
407
|
+
void wrapNewServers($, e.cwd)
|
|
408
|
+
void checkCoverage($, e.cwd)
|
|
409
|
+
void $.process.run(backstep(['ping'])).catch(() => null)
|
|
410
|
+
return r
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
// Each prompt of the person's starts a turn; a pending undo note rides along
|
|
414
|
+
// as context only the model reads, so Claude knows its work was taken back.
|
|
415
|
+
on('prompt.submit', async ($, e, next) => {
|
|
416
|
+
if (e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
|
|
417
|
+
const startedAt = await $.clock.now()
|
|
418
|
+
await update($, turns, (l) => [...(l ?? []), newTurn(startedAt)].slice(-KEEP_TURNS))
|
|
419
|
+
const pending = await read($, note)
|
|
420
|
+
if (!pending) return next(e)
|
|
421
|
+
await update($, note, () => null)
|
|
422
|
+
return next({ ...e, context: [...(e.context ?? []), pending] })
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
on('tool.call', { tool: CONNECT }, async ($, e) => ({ result: await connectServices($, e as { service?: string; connection_string?: string; project_ref?: string }) }))
|
|
426
|
+
|
|
427
|
+
on('tool.call', async ($, e, next) => {
|
|
428
|
+
const tool = String(e.tool)
|
|
429
|
+
if (tool === CONNECT) return next(e)
|
|
430
|
+
if (EDIT_TOOLS.has(tool)) {
|
|
431
|
+
const input = e as { file_path?: string; notebook_path?: string }
|
|
432
|
+
const path = input.file_path ?? input.notebook_path
|
|
433
|
+
if (path) {
|
|
434
|
+
const t = await current($)
|
|
435
|
+
const cwd = await $.session.cwd()
|
|
436
|
+
const sid = await $.session.id()
|
|
437
|
+
// A failed snapshot never blocks the edit; it just won't be undoable.
|
|
438
|
+
const snap = await $.process
|
|
439
|
+
.run(backstep(['snap', '--session', sid, '--tool', tool, '--cwd', cwd, '--', path]))
|
|
440
|
+
.catch(() => null)
|
|
441
|
+
if (snap?.exitCode === 0) {
|
|
442
|
+
await bump($, t.id, (x) => ({ ...x, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} else if (tool === 'Bash') {
|
|
446
|
+
const command = String((e as { command?: unknown }).command ?? '')
|
|
447
|
+
const t = await current($)
|
|
448
|
+
const cwd = await $.session.cwd()
|
|
449
|
+
const sid = await $.session.id()
|
|
450
|
+
// Never blocks the command: if Backstep can't be reached it just won't be undoable.
|
|
451
|
+
const ran = await $.process
|
|
452
|
+
.run(backstep(['shell', '--session', sid, '--cwd', cwd, '--', command]), { timeoutMs: 60_000 })
|
|
453
|
+
.catch(() => null)
|
|
454
|
+
let plan: ShellPlan | null = null
|
|
455
|
+
try {
|
|
456
|
+
plan = JSON.parse(ran?.stdout || 'null') as ShellPlan | null
|
|
457
|
+
} catch {
|
|
458
|
+
plan = null
|
|
459
|
+
}
|
|
460
|
+
if (plan?.kind === 'outbound') {
|
|
461
|
+
// What it changed here comes back with the project snapshot; what it sent out doesn't (a push is offered back after undo).
|
|
462
|
+
await bump($, t.id, (x) => ({ ...x, shell: (x.shell ?? 0) + (plan.undoable ? 1 : 0), lost: (x.lost ?? 0) + 1 }))
|
|
463
|
+
} else if (plan?.kind === 'write') {
|
|
464
|
+
await bump($, t.id, (x) => (plan.undoable ? { ...x, shell: (x.shell ?? 0) + 1 } : { ...x, lost: (x.lost ?? 0) + 1 }))
|
|
465
|
+
}
|
|
466
|
+
// A push: once it's run, note what it sent, so undo can offer to take it back there.
|
|
467
|
+
if (plan?.id && PUSH.test(command)) {
|
|
468
|
+
const r = await next(e)
|
|
469
|
+
await $.process.run(backstep(['shell-end', plan.id])).catch(() => null)
|
|
470
|
+
return r
|
|
471
|
+
}
|
|
472
|
+
} else if (tool.startsWith('mcp__')) {
|
|
473
|
+
return watchMcp($, e as unknown as Record<string, unknown>, next as unknown as Next)
|
|
474
|
+
}
|
|
475
|
+
return next(e)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
479
|
+
if (e.props.hasSurvey) return next(e)
|
|
480
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
481
|
+
const r = await read($, rewind)
|
|
482
|
+
|
|
483
|
+
if (r?.phase === 'running') {
|
|
484
|
+
return (
|
|
485
|
+
<Box>
|
|
486
|
+
<Text color="success" bold>↺ Rewinding </Text>
|
|
487
|
+
<Text color="success">{bar(r.frame)}</Text>
|
|
488
|
+
<Text dimColor> undoing the last turn…</Text>
|
|
489
|
+
</Box>
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (r) {
|
|
494
|
+
const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : 'success'
|
|
495
|
+
const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
|
|
496
|
+
const visible = r.items.slice(0, Math.min(r.shown, 6))
|
|
497
|
+
const more = r.items.length - 6
|
|
498
|
+
return (
|
|
499
|
+
<Box flexDirection="column">
|
|
500
|
+
<Text color={tone} bold>
|
|
501
|
+
{glyph} {r.message}
|
|
502
|
+
</Text>
|
|
503
|
+
{visible.map((i, n) => (
|
|
504
|
+
<Text key={`i${n}`} color={i.ok ? undefined : 'error'} dimColor={i.ok}>
|
|
505
|
+
{' '}
|
|
506
|
+
{i.ok ? '↺ ' : '✗ '}
|
|
507
|
+
{i.label}
|
|
508
|
+
</Text>
|
|
509
|
+
))}
|
|
510
|
+
{r.shown >= 6 && more > 0 ? <Text dimColor>{` and ${more} more`}</Text> : null}
|
|
511
|
+
</Box>
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Idle: a quiet line naming what one press would take back. The button
|
|
516
|
+
// has to be on screen for the chord to reach it.
|
|
517
|
+
const target = (await read($, turns)).findLast(hasWork)
|
|
518
|
+
if (!target) return next(e)
|
|
519
|
+
return (
|
|
520
|
+
<Box>
|
|
521
|
+
<Text color="success">↺ </Text>
|
|
522
|
+
<Text dimColor>Last turn: {summary(target)} </Text>
|
|
523
|
+
<Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void undoLastTurn($)}>
|
|
524
|
+
ctrl+z to undo
|
|
525
|
+
</Button>
|
|
526
|
+
</Box>
|
|
527
|
+
)
|
|
528
|
+
})
|
|
529
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// One turn: everything Claude did between two of the person's prompts.
|
|
2
|
+
export type Turn = {
|
|
3
|
+
id: string
|
|
4
|
+
startedAt: number
|
|
5
|
+
edits: number
|
|
6
|
+
files: string[]
|
|
7
|
+
mcp: number
|
|
8
|
+
// Shell commands snapshotted before they ran, so undo takes them back.
|
|
9
|
+
shell: number
|
|
10
|
+
// Shell commands and tool calls whose effects can't be taken back: they reached outside the machine with no undo rule, or the project was too big to snapshot.
|
|
11
|
+
lost: number
|
|
12
|
+
undone: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type RewindItem = { label: string; ok: boolean }
|
|
16
|
+
|
|
17
|
+
// The animation above the prompt while a turn is being rewound.
|
|
18
|
+
export type Rewind = {
|
|
19
|
+
phase: 'running' | 'done' | 'failed' | 'empty'
|
|
20
|
+
frame: number
|
|
21
|
+
shown: number
|
|
22
|
+
items: RewindItem[]
|
|
23
|
+
message: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare module 'claude-code' {
|
|
27
|
+
interface PluginState {
|
|
28
|
+
backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null }
|
|
29
|
+
}
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backstep",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "node src/cli.ts",
|
|
7
|
+
"ui": "node --disable-warning=ExperimentalWarning src/cli.ts ui",
|
|
8
|
+
"demo": "node --disable-warning=ExperimentalWarning examples/demo-db.ts",
|
|
9
|
+
"build": "tsc -p tsconfig.json && cp src/ui/index.html dist/ui/index.html && chmod +x dist/cli.js",
|
|
10
|
+
"test": "node --disable-warning=ExperimentalWarning --test test/*.test.ts",
|
|
11
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"license": "UNLICENSED",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
17
|
+
"pg": "^8.23.1",
|
|
18
|
+
"zod": "^4.6.7"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@electric-sql/pglite": "^0.5.12",
|
|
22
|
+
"@electric-sql/pglite-socket": "^0.2.15",
|
|
23
|
+
"@modelcontextprotocol/server-filesystem": "^2.0.1",
|
|
24
|
+
"@types/node": "^26.6.5",
|
|
25
|
+
"@types/pg": "^8.23.1",
|
|
26
|
+
"typescript": "^7.0.2"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"backstep": "dist/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22.13"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"mod",
|
|
37
|
+
"README.md"
|
|
38
|
+
],
|
|
39
|
+
"keywords": [
|
|
40
|
+
"mcp",
|
|
41
|
+
"undo",
|
|
42
|
+
"claude-code",
|
|
43
|
+
"agents",
|
|
44
|
+
"rollback"
|
|
45
|
+
],
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/bzells11/backstep.git"
|
|
49
|
+
}
|
|
50
|
+
}
|