backstep 0.1.9 → 0.1.13
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 +4 -0
- package/dist/adapters/firestore.js +160 -0
- package/dist/adapters/tree.js +100 -3
- package/dist/cli.js +79 -17
- package/dist/connect.js +166 -8
- package/dist/github.js +111 -0
- package/dist/shell.js +9 -0
- package/dist/supabase.js +17 -3
- package/dist/undo.js +13 -5
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +172 -8
- package/mod/hooks/register.tsx +252 -77
- package/mod/types/index.d.ts +6 -0
- package/package.json +1 -1
package/mod/hooks/register.tsx
CHANGED
|
@@ -19,6 +19,8 @@ const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as st
|
|
|
19
19
|
// Backstep's mark, as on the logo: the front rewind triangle in the terminal's
|
|
20
20
|
// own black or white, the one behind it in the logo's pink.
|
|
21
21
|
const PINK = '#ee7bb6'
|
|
22
|
+
// The mark where only plain text goes (a transcript line).
|
|
23
|
+
const MARK_TEXT = '◀◀'
|
|
22
24
|
// How long the "press again to undo" question waits before it lets go.
|
|
23
25
|
const CONFIRM_MS = 5000
|
|
24
26
|
|
|
@@ -55,23 +57,76 @@ type Outcome = {
|
|
|
55
57
|
detail: string
|
|
56
58
|
run?: { server: string; tool: string; args: Record<string, unknown> }
|
|
57
59
|
pushed?: { root: string; pushes: Pushed[] }
|
|
60
|
+
github?: GhMade[]
|
|
58
61
|
}
|
|
59
62
|
const PUSH = /\bgit\b[^;&|]*\bpush\b/
|
|
63
|
+
// A gh command that makes something on GitHub: noted once it runs, so undo can offer to take it away.
|
|
64
|
+
const GH_CREATE = /\bgh\s+(repo|issue|pr|release)\s+create\b/
|
|
65
|
+
type GhMade = { kind: 'repo' | 'issue' | 'pr' | 'release'; repo: string; number?: number; tag?: string; url: string }
|
|
66
|
+
const describeGh = (g: GhMade) =>
|
|
67
|
+
g.kind === 'repo' ? `the GitHub repository ${g.repo}` : g.kind === 'release' ? `the release ${g.tag} in ${g.repo}` : `${g.kind === 'pr' ? 'pull request' : 'issue'} #${g.number} in ${g.repo}`
|
|
68
|
+
|
|
69
|
+
// Runs a Backstep command that answers with { ok, detail }, however it fails.
|
|
70
|
+
async function jsonRun($: EngineInterface, args: string[], timeoutMs: number, stdin?: string): Promise<{ ok: boolean; detail: string; needs?: string }> {
|
|
71
|
+
const ran = await $.process
|
|
72
|
+
.run(backstep(args), { timeoutMs, ...(stdin !== undefined ? { stdin } : {}) })
|
|
73
|
+
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(ran.stdout) as { ok: boolean; detail: string; needs?: string }
|
|
76
|
+
} catch {
|
|
77
|
+
return { ok: false, detail: (ran.stderr || 'it failed').trim().split('\n').at(-1) ?? 'it failed' }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ghUndo = ($: EngineInterface, g: GhMade) => jsonRun($, ['gh-undo'], 120_000, JSON.stringify(g))
|
|
82
|
+
|
|
83
|
+
// Asks, for each thing Claude made on GitHub in the undone turns, whether to take it away there too.
|
|
84
|
+
// Returns the repositories deleted, whose pushes then need no undo of their own.
|
|
85
|
+
async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise<Set<string>> {
|
|
86
|
+
const gone = new Set<string>()
|
|
87
|
+
for (const g of outcomes.flatMap((o) => (o.ok && o.github ? o.github : []))) {
|
|
88
|
+
const yes = g.kind === 'repo' ? 'Delete it' : g.kind === 'issue' || g.kind === 'pr' ? 'Close it' : 'Delete it'
|
|
89
|
+
const answer = await $.ui
|
|
90
|
+
.ask(`Claude created ${describeGh(g)}. ${yes === 'Close it' ? 'Close' : 'Delete'} it on GitHub too?`, { header: 'Backstep', options: [yes, 'Leave it'] })
|
|
91
|
+
.catch(() => null)
|
|
92
|
+
if (answer !== yes) {
|
|
93
|
+
$.ui.log(`${MARK_TEXT} Not undone on GitHub: ${describeGh(g)} stays (you chose to leave it).`)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
let r = await ghUndo($, g)
|
|
97
|
+
// gh's sign-in needs one more permission: get it on GitHub's own page, then try again.
|
|
98
|
+
if (!r.ok && r.needs) {
|
|
99
|
+
$.ui.toast("GitHub needs your OK first. Backstep opened GitHub in your browser and copied the code: paste it there and press Authorize.", { timeoutMs: 15_000 })
|
|
100
|
+
$.ui.log(`${MARK_TEXT} GitHub needs your OK first: Backstep opened the approval page and copied the code to your clipboard. Paste it and press Authorize.`)
|
|
101
|
+
const granted = await jsonRun($, ['gh-auth', r.needs], 600_000)
|
|
102
|
+
r = granted.ok ? await ghUndo($, g) : { ok: false, detail: `GitHub didn't give the OK (${granted.detail}), so ${describeGh(g)} is still there` }
|
|
103
|
+
}
|
|
104
|
+
if (r.ok && g.kind === 'repo') gone.add(g.repo)
|
|
105
|
+
$.ui.log(r.ok ? `${MARK_TEXT} On GitHub: ${r.detail}.` : `${MARK_TEXT} Not undone on GitHub: ${r.detail}`)
|
|
106
|
+
$.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}`)
|
|
107
|
+
}
|
|
108
|
+
return gone
|
|
109
|
+
}
|
|
60
110
|
|
|
61
111
|
const PUSH_ASK = { yes: 'Push the undo', no: 'Leave it as it is' }
|
|
62
112
|
|
|
63
113
|
// Undo rewound the code here; this asks before taking it back on GitHub too,
|
|
64
114
|
// as a new commit that undoes Claude's (never a force push).
|
|
65
|
-
async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
|
|
115
|
+
async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone = false) {
|
|
66
116
|
for (const o of outcomes) {
|
|
67
117
|
if (!o.ok || !o.pushed) continue
|
|
118
|
+
// The repository it went to is deleted: nothing left there to undo.
|
|
119
|
+
if (repoGone) continue
|
|
68
120
|
for (const p of o.pushed.pushes) {
|
|
69
121
|
const what =
|
|
70
122
|
p.before === null
|
|
71
123
|
? `Claude pushed a new branch, ${p.branch}, to ${p.host}. Delete it there too?`
|
|
72
124
|
: `Claude pushed ${p.commits === 1 ? '1 commit' : `${p.commits} commits`} to ${p.branch} on ${p.host}. Undo ${p.commits === 1 ? 'it' : 'them'} there too?`
|
|
73
125
|
const answer = await $.ui.ask(what, { header: 'Backstep', options: [p.before === null ? 'Delete the branch' : PUSH_ASK.yes, PUSH_ASK.no] }).catch(() => null)
|
|
74
|
-
if (!answer || answer === PUSH_ASK.no)
|
|
126
|
+
if (!answer || answer === PUSH_ASK.no) {
|
|
127
|
+
$.ui.log(`${MARK_TEXT} Not undone on ${p.host}: Claude's ${p.before === null ? `branch ${p.branch}` : p.commits === 1 ? 'commit' : `${p.commits} commits`} stay there (you chose to leave it).`)
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
75
130
|
const ran = await $.process
|
|
76
131
|
.run(backstep(['revert-push', '--root', o.pushed.root]), { stdin: JSON.stringify(p), timeoutMs: 180_000 })
|
|
77
132
|
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
@@ -82,6 +137,7 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
|
|
|
82
137
|
// keep the error above
|
|
83
138
|
}
|
|
84
139
|
$.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}. Nothing was changed on ${p.host}.`)
|
|
140
|
+
$.ui.log(r.ok ? `${MARK_TEXT} On ${p.host}: ${r.detail}.` : `${MARK_TEXT} Not undone on ${p.host}: ${r.detail}. Nothing was changed there.`)
|
|
85
141
|
await update($, note, (n) =>
|
|
86
142
|
n ? `${n}\n${r.ok ? `On ${p.host}: ${r.detail}.` : `${p.host} was left as it was: ${r.detail}.`}` : n,
|
|
87
143
|
)
|
|
@@ -103,7 +159,8 @@ async function pressUndo($: EngineInterface) {
|
|
|
103
159
|
const now = await $.clock.now()
|
|
104
160
|
if (at !== null && now - at < CONFIRM_MS) {
|
|
105
161
|
await update($, armed, () => null)
|
|
106
|
-
|
|
162
|
+
const { before } = await undoTargets($)
|
|
163
|
+
return undoLastTurn($, before ? 2 : 1)
|
|
107
164
|
}
|
|
108
165
|
await update($, armed, () => now)
|
|
109
166
|
$.clock.after(CONFIRM_MS, () => {
|
|
@@ -111,9 +168,11 @@ async function pressUndo($: EngineInterface) {
|
|
|
111
168
|
})
|
|
112
169
|
}
|
|
113
170
|
|
|
114
|
-
|
|
171
|
+
// Undoes the last `count` turns that changed something, newest first.
|
|
172
|
+
async function undoLastTurn($: EngineInterface, count = 1) {
|
|
115
173
|
if (await read($, rewind)) return
|
|
116
|
-
const
|
|
174
|
+
const taken = (await read($, turns)).filter(hasWork).slice(-count)
|
|
175
|
+
const target = taken[0]
|
|
117
176
|
if (!target) return
|
|
118
177
|
|
|
119
178
|
await update($, rewind, (): Rewind => ({ phase: 'running', frame: 0, shown: 0, items: [], message: '' }))
|
|
@@ -165,17 +224,29 @@ async function undoLastTurn($: EngineInterface) {
|
|
|
165
224
|
const cut = outcomes.findIndex((o) => !o.ok)
|
|
166
225
|
if (cut !== -1) outcomes = outcomes.slice(0, cut + 1)
|
|
167
226
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
227
|
+
// Every change undone, one per line, and every one that wasn't, with why.
|
|
228
|
+
const undone: RewindItem[] = outcomes
|
|
229
|
+
.filter((o) => o.ok)
|
|
230
|
+
.flatMap((o) =>
|
|
231
|
+
(o.detail || o.tool)
|
|
232
|
+
.split('\n')
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.map((line) => ({ label: line.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`), ok: true })),
|
|
235
|
+
)
|
|
236
|
+
const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
|
|
237
|
+
// A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
|
|
238
|
+
const offered = outcomes.some((o) => o.ok && o.pushed)
|
|
239
|
+
const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
|
|
240
|
+
const kept = taken
|
|
241
|
+
.flatMap((t) => t.lostItems ?? [])
|
|
242
|
+
.filter((l) => !(offered && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
|
|
243
|
+
.map((label) => ({ label, ok: false }))
|
|
244
|
+
const items: RewindItem[] = [...undone, ...stopped, ...kept]
|
|
245
|
+
const done = undone.length
|
|
246
|
+
const failed = stopped.length > 0
|
|
247
|
+
const lost = kept.length
|
|
248
|
+
const which = taken.length > 1 ? `the last ${taken.length} turns` : 'the last turn'
|
|
249
|
+
const shell = lost ? ` ${plural(lost, 'thing')} couldn't be undone: listed below.` : ''
|
|
179
250
|
|
|
180
251
|
await update($, rewind, (): Rewind => ({
|
|
181
252
|
phase: failed ? 'failed' : items.length ? 'done' : 'empty',
|
|
@@ -185,32 +256,38 @@ async function undoLastTurn($: EngineInterface) {
|
|
|
185
256
|
message: failed
|
|
186
257
|
? `Stopped after ${plural(done, 'change')}: the next one couldn't be undone.`
|
|
187
258
|
: items.length
|
|
188
|
-
? `Rewound ${plural(done, 'change')} from
|
|
189
|
-
: `Nothing in
|
|
259
|
+
? `Rewound ${plural(done, 'change')} from ${which}.${shell}`
|
|
260
|
+
: `Nothing in ${which} could be undone.${shell}`,
|
|
190
261
|
}))
|
|
191
|
-
await bump($,
|
|
262
|
+
for (const t of taken) await bump($, t.id, (x) => ({ ...x, undone: !failed }))
|
|
263
|
+
|
|
264
|
+
// The whole account, kept in the conversation, where the band's few rows fold away.
|
|
265
|
+
const report = [
|
|
266
|
+
`${MARK_TEXT} Backstep undid ${plural(done, 'change')} from ${which}${taken.length === 1 ? named(target) : ''}${done ? ':' : '.'}`,
|
|
267
|
+
...undone.map((i) => ` ◀ ${i.label}`),
|
|
268
|
+
...(stopped.length || kept.length ? ["Not undone:", ...[...stopped, ...kept].map((i) => ` ✗ ${i.label}`)] : []),
|
|
269
|
+
]
|
|
270
|
+
$.ui.log(report.join('\n'))
|
|
192
271
|
|
|
193
272
|
if (done) {
|
|
194
273
|
const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
|
|
195
274
|
const key = await keysLabel($)
|
|
196
275
|
await update($, note, () =>
|
|
197
|
-
`[Backstep] The user pressed undo (${key}) on your previous turn. These changes were reverted:\n${list}\n` +
|
|
276
|
+
`[Backstep] The user pressed undo (${key}) on your ${taken.length > 1 ? `last ${taken.length} turns` : 'previous turn'}. These changes were reverted:\n${list}\n` +
|
|
198
277
|
(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` : '') +
|
|
199
278
|
`Treat them as not having happened, re-read files before editing them, and don't redo them unless asked.`,
|
|
200
279
|
)
|
|
201
280
|
}
|
|
202
281
|
|
|
203
|
-
// Asked once the band shows what came back.
|
|
204
|
-
void offerPushUndo($, outcomes)
|
|
282
|
+
// Asked once the band shows what came back: GitHub things first, then pushes.
|
|
283
|
+
void offerGithubUndo($, outcomes).then((gone) => offerPushUndo($, outcomes, gone.size > 0))
|
|
205
284
|
|
|
206
|
-
// Reveal the
|
|
285
|
+
// Reveal the items one by one. The list stays until the person's next
|
|
286
|
+
// message (it's in the conversation too), so nothing is missed.
|
|
207
287
|
const reveal = $.clock.every(90, () => {
|
|
208
288
|
void update($, rewind, (r) => (r && r.shown < r.items.length ? { ...r, shown: r.shown + 1 } : r))
|
|
209
289
|
})
|
|
210
|
-
$.clock.after(
|
|
211
|
-
reveal.cancel()
|
|
212
|
-
void update($, rewind, () => null)
|
|
213
|
-
})
|
|
290
|
+
$.clock.after(90 * (items.length + 2), () => reveal.cancel())
|
|
214
291
|
}
|
|
215
292
|
|
|
216
293
|
type Next = (e: Record<string, unknown>) => Promise<{ deny?: string; isError?: true; result?: unknown; text?: string }>
|
|
@@ -257,7 +334,7 @@ async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Ne
|
|
|
257
334
|
hint($, plan)
|
|
258
335
|
// Behind Backstep, or Backstep couldn't be reached: count it as before.
|
|
259
336
|
if (!plan || plan.kind === 'wrapped' || !plan.id) {
|
|
260
|
-
if (plan?.kind !== 'read') await bump($, t.id, (x) => ({ ...x, mcp: x.mcp + 1 }))
|
|
337
|
+
if (plan?.kind !== 'read') await bump($, t.id, (x) => ({ ...x, gitOnly: false, mcp: x.mcp + 1 }))
|
|
261
338
|
return next(e)
|
|
262
339
|
}
|
|
263
340
|
const r = await next(plan.args ? { ...e, ...plan.args } : e)
|
|
@@ -274,7 +351,9 @@ async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Ne
|
|
|
274
351
|
} catch {
|
|
275
352
|
undoable = false
|
|
276
353
|
}
|
|
277
|
-
if (!r.isError)
|
|
354
|
+
if (!r.isError) {
|
|
355
|
+
await bump($, t.id, (x) => (undoable ? { ...x, gitOnly: false, mcp: x.mcp + 1 } : lostOne({ ...x, gitOnly: false }, `${tool.replace(/^mcp__/, '').replace('__', ' ')}: ${plan.reason ?? 'there is no way to undo it'}`)))
|
|
356
|
+
}
|
|
278
357
|
return r
|
|
279
358
|
}
|
|
280
359
|
|
|
@@ -289,8 +368,41 @@ function backstep(args: string[]): string[] {
|
|
|
289
368
|
return cli === 'backstep' ? ['backstep', ...args] : ['node', '--disable-warning=ExperimentalWarning', cli, ...args]
|
|
290
369
|
}
|
|
291
370
|
|
|
292
|
-
function newTurn(startedAt: number): Turn {
|
|
293
|
-
return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false }
|
|
371
|
+
function newTurn(startedAt: number, prompt?: string): Turn {
|
|
372
|
+
return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false, ...(prompt ? { prompt } : {}) }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// A command as it reads in a list: without its leading `cd`, cut short.
|
|
376
|
+
const brief = (command: string) => {
|
|
377
|
+
const c = command.replace(/^\s*cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*&&\s*/, '').replace(/\s+/g, ' ').trim()
|
|
378
|
+
return c.length > 60 ? `${c.slice(0, 59)}…` : c
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Something in a turn that can't be undone, named, so undo can list it.
|
|
382
|
+
const lostOne = (x: Turn, what: string): Turn => ({ ...x, lost: (x.lost ?? 0) + 1, lostItems: [...(x.lostItems ?? []), what].slice(-20) })
|
|
383
|
+
|
|
384
|
+
// A turn named by what the person asked: "commit and push it", cut to `max`.
|
|
385
|
+
const quoted = (t: Turn, max = 40) => {
|
|
386
|
+
const p = (t.prompt ?? '').replace(/\s+/g, ' ').trim()
|
|
387
|
+
return p ? ` "${p.length > max ? `${p.slice(0, max - 1)}…` : p}"` : ''
|
|
388
|
+
}
|
|
389
|
+
const named = (t: Turn) => (t.prompt ? ` (${quoted(t).trim()})` : '')
|
|
390
|
+
|
|
391
|
+
// Each part only saves to git (add, commit, push and friends), or only moves around.
|
|
392
|
+
const GIT_SAVE = /^(git\s+(add|commit|push|status|log|diff|show|branch|remote|fetch|rev-parse|config)\b|cd\s|echo\s|true$)/
|
|
393
|
+
const onlySavesToGit = (command: string) =>
|
|
394
|
+
command
|
|
395
|
+
.split(/&&|\|\||;|\n/)
|
|
396
|
+
.map((p) => p.trim())
|
|
397
|
+
.filter(Boolean)
|
|
398
|
+
.every((p) => GIT_SAVE.test(p))
|
|
399
|
+
|
|
400
|
+
// What one press would take back: the last turn, and, when that turn only
|
|
401
|
+
// committed and pushed, the turn whose work it saved.
|
|
402
|
+
async function undoTargets($: EngineInterface): Promise<{ target: Turn | undefined; before: Turn | undefined }> {
|
|
403
|
+
const list = (await read($, turns)).filter(hasWork)
|
|
404
|
+
const target = list.at(-1)
|
|
405
|
+
return { target, before: target?.gitOnly ? list.at(-2) : undefined }
|
|
294
406
|
}
|
|
295
407
|
|
|
296
408
|
// The turn tool calls belong to; one opens if none is (a call before any prompt).
|
|
@@ -332,7 +444,7 @@ async function checkCoverage($: EngineInterface, cwd: string) {
|
|
|
332
444
|
const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
|
|
333
445
|
try {
|
|
334
446
|
for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
|
|
335
|
-
if (c.
|
|
447
|
+
if (c.state === 'ask') await askDatabase($, c.dir ?? cwd, c.service)
|
|
336
448
|
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.')
|
|
337
449
|
}
|
|
338
450
|
} catch {
|
|
@@ -360,36 +472,45 @@ const ASK = {
|
|
|
360
472
|
// Projects asked about this session: "Not now" means not again until the next one.
|
|
361
473
|
const asked = new Set<string>()
|
|
362
474
|
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
475
|
+
// How each database is named in the question, and what the answer connects.
|
|
476
|
+
const DB_NAME: Record<string, string> = { supabase: 'Supabase', postgres: 'a Postgres database', firebase: 'Firebase' }
|
|
477
|
+
|
|
478
|
+
// Asked when Claude Code opens a project that uses a database (Supabase, other
|
|
479
|
+
// Postgres, Firebase), or Claude is about to change one, and Backstep isn't
|
|
480
|
+
// connected to it; the answer is the only thing that connects it.
|
|
481
|
+
async function askDatabase($: EngineInterface, cwd: string, service = 'supabase') {
|
|
482
|
+
const key = `${service}:${cwd}`
|
|
483
|
+
if (asked.has(key)) return
|
|
484
|
+
asked.add(key)
|
|
485
|
+
const name = DB_NAME[service] ?? service
|
|
369
486
|
const answer = await $.ui
|
|
370
|
-
.ask(
|
|
487
|
+
.ask(`This project uses ${name}, but Backstep isn't connected to it, so Claude's changes to it can't be undone. Connect it?`, {
|
|
371
488
|
header: 'Backstep',
|
|
372
489
|
options: [ASK.connect, ASK.later, ASK.never],
|
|
373
490
|
})
|
|
374
491
|
.catch(() => null)
|
|
375
492
|
if (answer === ASK.never) {
|
|
376
|
-
await $.process.run(backstep(['connect',
|
|
493
|
+
await $.process.run(backstep(['connect', service, '--dir', cwd, '--dismiss'])).catch(() => null)
|
|
377
494
|
return
|
|
378
495
|
}
|
|
379
496
|
if (answer !== ASK.connect) return
|
|
380
|
-
const ran = await $.process.run(backstep(['connect',
|
|
381
|
-
let
|
|
497
|
+
const ran = await $.process.run(backstep(['connect', service, '--dir', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
|
|
498
|
+
let all: Connected[] = []
|
|
382
499
|
try {
|
|
383
|
-
|
|
500
|
+
all = JSON.parse(ran?.stdout || '[]') as Connected[]
|
|
384
501
|
} catch {
|
|
385
|
-
|
|
502
|
+
all = []
|
|
386
503
|
}
|
|
387
|
-
|
|
388
|
-
|
|
504
|
+
const s = all.find((c) => c.state === 'connected' || c.state === 'covered') ?? all.find((c) => c.state === 'needs')
|
|
505
|
+
if (s && (s.state === 'connected' || s.state === 'covered')) {
|
|
506
|
+
$.ui.toast(`Backstep ${s.detail}.`)
|
|
507
|
+
$.ui.log(`${MARK_TEXT} Backstep ${s.detail}.`)
|
|
389
508
|
return
|
|
390
509
|
}
|
|
391
|
-
//
|
|
392
|
-
|
|
510
|
+
// What's still needed, said where it stays.
|
|
511
|
+
const need = s?.detail ?? `ask Claude to "connect Backstep to ${service}"`
|
|
512
|
+
$.ui.log(`${MARK_TEXT} Not connected to ${name} yet: ${need}`)
|
|
513
|
+
$.ui.toast(service === 'supabase' ? 'Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.' : `Backstep isn't connected to ${name} yet: ${need}`)
|
|
393
514
|
}
|
|
394
515
|
|
|
395
516
|
const UPDATE = { yes: 'Update', later: 'Not now', skip: 'Skip this version' }
|
|
@@ -534,7 +655,10 @@ export const register: Register = (on, options) => {
|
|
|
534
655
|
on('prompt.submit', async ($, e, next) => {
|
|
535
656
|
if (e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
|
|
536
657
|
const startedAt = await $.clock.now()
|
|
537
|
-
|
|
658
|
+
// The last undo's list has been seen: the band goes back to the undo line.
|
|
659
|
+
const shown = await read($, rewind)
|
|
660
|
+
if (shown && shown.phase !== 'running') await update($, rewind, () => null)
|
|
661
|
+
await update($, turns, (l) => [...(l ?? []), newTurn(startedAt, e.text)].slice(-KEEP_TURNS))
|
|
538
662
|
const pending = await read($, note)
|
|
539
663
|
if (!pending) return next(e)
|
|
540
664
|
await update($, note, () => null)
|
|
@@ -560,7 +684,9 @@ export const register: Register = (on, options) => {
|
|
|
560
684
|
.run(backstep(['snap', '--session', sid, '--tool', tool, '--cwd', cwd, '--', path]))
|
|
561
685
|
.catch(() => null)
|
|
562
686
|
if (snap?.exitCode === 0) {
|
|
563
|
-
await bump($, t.id, (x) => ({ ...x, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
|
|
687
|
+
await bump($, t.id, (x) => ({ ...x, gitOnly: false, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
|
|
688
|
+
} else {
|
|
689
|
+
await bump($, t.id, (x) => lostOne({ ...x, gitOnly: false }, `edit to ${path.split('/').pop()}: Backstep couldn't save a copy first`))
|
|
564
690
|
}
|
|
565
691
|
}
|
|
566
692
|
} else if (tool === 'Bash') {
|
|
@@ -572,8 +698,8 @@ export const register: Register = (on, options) => {
|
|
|
572
698
|
// so it can be saved before the command runs.
|
|
573
699
|
const checked = await $.process.run(backstep(['db-check', '--cwd', cwd, '--', command])).catch(() => null)
|
|
574
700
|
try {
|
|
575
|
-
const ask =
|
|
576
|
-
if (ask) await
|
|
701
|
+
const { ask, service } = JSON.parse(checked?.stdout || '{}') as { ask?: string | null; service?: string | null }
|
|
702
|
+
if (ask) await askDatabase($, ask, service ?? 'supabase')
|
|
577
703
|
} catch {
|
|
578
704
|
// can't tell: run it as before
|
|
579
705
|
}
|
|
@@ -590,14 +716,20 @@ export const register: Register = (on, options) => {
|
|
|
590
716
|
}
|
|
591
717
|
if (plan?.kind === 'outbound') {
|
|
592
718
|
// What it changed here comes back with the project snapshot; what it sent out doesn't (a push is offered back after undo).
|
|
593
|
-
|
|
719
|
+
const git = onlySavesToGit(command)
|
|
720
|
+
await bump($, t.id, (x) =>
|
|
721
|
+
lostOne({ ...x, gitOnly: x.gitOnly !== false && git, shell: (x.shell ?? 0) + (plan.undoable ? 1 : 0) }, `${brief(command)}: ${plan.reason ?? 'it reached outside this computer'}`),
|
|
722
|
+
)
|
|
594
723
|
} else if (plan?.kind === 'write') {
|
|
595
|
-
|
|
724
|
+
const git = onlySavesToGit(command)
|
|
725
|
+
await bump($, t.id, (x) =>
|
|
726
|
+
plan.undoable ? { ...x, gitOnly: x.gitOnly !== false && git, shell: (x.shell ?? 0) + 1 } : lostOne({ ...x, gitOnly: false }, `${brief(command)}: ${plan.reason ?? "Backstep couldn't save a copy first"}`),
|
|
727
|
+
)
|
|
596
728
|
}
|
|
597
729
|
// A push: once it's run, note what it sent, so undo can offer to take it back there.
|
|
598
|
-
if (plan?.id && PUSH.test(command)) {
|
|
730
|
+
if (plan?.id && (PUSH.test(command) || GH_CREATE.test(command))) {
|
|
599
731
|
const r = await next(e)
|
|
600
|
-
await $.process.run(backstep(['shell-end', plan.id])).catch(() => null)
|
|
732
|
+
await $.process.run(backstep(['shell-end', plan.id]), { stdin: r.text ?? '' }).catch(() => null)
|
|
601
733
|
return r
|
|
602
734
|
}
|
|
603
735
|
} else if (tool.startsWith('mcp__')) {
|
|
@@ -626,41 +758,84 @@ export const register: Register = (on, options) => {
|
|
|
626
758
|
if (r) {
|
|
627
759
|
const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : undefined
|
|
628
760
|
const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
|
|
629
|
-
|
|
630
|
-
const
|
|
761
|
+
// What came back (the first few; the conversation has them all), then everything that didn't.
|
|
762
|
+
const shown = r.items.slice(0, r.shown)
|
|
763
|
+
const back = shown.filter((i) => i.ok)
|
|
764
|
+
const kept = shown.filter((i) => !i.ok)
|
|
765
|
+
const more = r.items.filter((i) => i.ok).length - 5
|
|
631
766
|
return (
|
|
632
767
|
<Box flexDirection="column">
|
|
633
768
|
<Text color={tone} bold>
|
|
634
769
|
{glyph} {r.message}
|
|
635
770
|
</Text>
|
|
636
|
-
{
|
|
637
|
-
<Text key={`
|
|
638
|
-
{' '}
|
|
639
|
-
{i.
|
|
771
|
+
{back.slice(0, 5).map((i, n) => (
|
|
772
|
+
<Text key={`b${n}`} dimColor>
|
|
773
|
+
{' ◀ '}
|
|
774
|
+
{i.label}
|
|
775
|
+
</Text>
|
|
776
|
+
))}
|
|
777
|
+
{back.length >= 5 && more > 0 ? <Text dimColor>{` and ${more} more (all listed in the conversation)`}</Text> : null}
|
|
778
|
+
{kept.length ? <Text bold>{' Not undone:'}</Text> : null}
|
|
779
|
+
{kept.slice(0, 6).map((i, n) => (
|
|
780
|
+
<Text key={`k${n}`} color="error">
|
|
781
|
+
{' ✗ '}
|
|
640
782
|
{i.label}
|
|
641
783
|
</Text>
|
|
642
784
|
))}
|
|
643
|
-
{r.shown >= 6 && more > 0 ? <Text dimColor>{` and ${more} more`}</Text> : null}
|
|
644
785
|
</Box>
|
|
645
786
|
)
|
|
646
787
|
}
|
|
647
788
|
|
|
648
|
-
const target =
|
|
789
|
+
const { target, before } = await undoTargets($)
|
|
649
790
|
const label = (await read($, keys)).map(keyLabel).join(' / ')
|
|
650
791
|
if (target && (await read($, armed)) !== null) {
|
|
792
|
+
// Laid out a line at a time, so nothing gets squeezed into columns on a narrow terminal.
|
|
793
|
+
const cancel = (
|
|
794
|
+
<Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
|
|
795
|
+
cancel
|
|
796
|
+
</Button>
|
|
797
|
+
)
|
|
798
|
+
// The last turn only committed and pushed: the work it saved is what the person most likely wants back.
|
|
799
|
+
if (before) {
|
|
800
|
+
return (
|
|
801
|
+
<Box flexDirection="column">
|
|
802
|
+
<Box>
|
|
803
|
+
<Text bold>◀</Text>
|
|
804
|
+
<Text color={PINK} bold>◀</Text>
|
|
805
|
+
<Text color="warning" bold>{' That turn only saved and pushed to git.'}</Text>
|
|
806
|
+
</Box>
|
|
807
|
+
<Text dimColor>{` Undo it and the turn before it${quoted(before, 50)}?`}</Text>
|
|
808
|
+
<Box>
|
|
809
|
+
<Text>{' '}</Text>
|
|
810
|
+
<Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
|
|
811
|
+
{`${label} again to undo both`}
|
|
812
|
+
</Button>
|
|
813
|
+
<Text dimColor>{' · '}</Text>
|
|
814
|
+
<Button key="just" plain dimColor onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
|
|
815
|
+
just the push
|
|
816
|
+
</Button>
|
|
817
|
+
<Text dimColor>{' · '}</Text>
|
|
818
|
+
{cancel}
|
|
819
|
+
</Box>
|
|
820
|
+
</Box>
|
|
821
|
+
)
|
|
822
|
+
}
|
|
651
823
|
return (
|
|
652
|
-
<Box>
|
|
653
|
-
<
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
824
|
+
<Box flexDirection="column">
|
|
825
|
+
<Box>
|
|
826
|
+
<Text bold>◀</Text>
|
|
827
|
+
<Text color={PINK} bold>◀</Text>
|
|
828
|
+
<Text color="warning" bold>{" Undo Claude's last turn?"}</Text>
|
|
829
|
+
</Box>
|
|
830
|
+
<Text dimColor>{` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
|
|
831
|
+
<Box>
|
|
832
|
+
<Text>{' '}</Text>
|
|
833
|
+
<Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
|
|
834
|
+
{`${label} again to undo`}
|
|
835
|
+
</Button>
|
|
836
|
+
<Text dimColor>{' · '}</Text>
|
|
837
|
+
{cancel}
|
|
838
|
+
</Box>
|
|
664
839
|
</Box>
|
|
665
840
|
)
|
|
666
841
|
}
|
|
@@ -673,7 +848,7 @@ export const register: Register = (on, options) => {
|
|
|
673
848
|
<Text bold>◀</Text>
|
|
674
849
|
<Text color={PINK} bold>◀</Text>
|
|
675
850
|
<Text> </Text>
|
|
676
|
-
<Text dimColor>{target ? `Last turn
|
|
851
|
+
<Text dimColor>{target ? `Last turn:${quoted(target, 28)} · ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
|
|
677
852
|
<Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
|
|
678
853
|
{`${label} to undo`}
|
|
679
854
|
</Button>
|
package/mod/types/index.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export type Turn = {
|
|
|
10
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
11
|
lost: number
|
|
12
12
|
undone: boolean
|
|
13
|
+
// What the person asked for, so undo can name the turn it would take back.
|
|
14
|
+
prompt?: string
|
|
15
|
+
// Every change was a git commit or push (saving the turn before's work), so undo offers to take both.
|
|
16
|
+
gitOnly?: boolean
|
|
17
|
+
// What in this turn can't be undone, and why, so undo can say so by name.
|
|
18
|
+
lostItems?: string[]
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export type RewindItem = { label: string; ok: boolean }
|