backstep 0.1.10 → 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.
@@ -57,16 +57,66 @@ type Outcome = {
57
57
  detail: string
58
58
  run?: { server: string; tool: string; args: Record<string, unknown> }
59
59
  pushed?: { root: string; pushes: Pushed[] }
60
+ github?: GhMade[]
60
61
  }
61
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
+ }
62
110
 
63
111
  const PUSH_ASK = { yes: 'Push the undo', no: 'Leave it as it is' }
64
112
 
65
113
  // Undo rewound the code here; this asks before taking it back on GitHub too,
66
114
  // as a new commit that undoes Claude's (never a force push).
67
- async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
115
+ async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone = false) {
68
116
  for (const o of outcomes) {
69
117
  if (!o.ok || !o.pushed) continue
118
+ // The repository it went to is deleted: nothing left there to undo.
119
+ if (repoGone) continue
70
120
  for (const p of o.pushed.pushes) {
71
121
  const what =
72
122
  p.before === null
@@ -184,11 +234,12 @@ async function undoLastTurn($: EngineInterface, count = 1) {
184
234
  .map((line) => ({ label: line.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`), ok: true })),
185
235
  )
186
236
  const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
187
- // A push is offered back on GitHub below, so it isn't listed as lost.
237
+ // A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
188
238
  const offered = outcomes.some((o) => o.ok && o.pushed)
239
+ const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
189
240
  const kept = taken
190
241
  .flatMap((t) => t.lostItems ?? [])
191
- .filter((l) => !(offered && /pushes to a remote/.test(l)))
242
+ .filter((l) => !(offered && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
192
243
  .map((label) => ({ label, ok: false }))
193
244
  const items: RewindItem[] = [...undone, ...stopped, ...kept]
194
245
  const done = undone.length
@@ -228,17 +279,15 @@ async function undoLastTurn($: EngineInterface, count = 1) {
228
279
  )
229
280
  }
230
281
 
231
- // Asked once the band shows what came back.
232
- 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))
233
284
 
234
- // Reveal the restored items one by one, then fold the band away.
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.
235
287
  const reveal = $.clock.every(90, () => {
236
288
  void update($, rewind, (r) => (r && r.shown < r.items.length ? { ...r, shown: r.shown + 1 } : r))
237
289
  })
238
- $.clock.after(failed || lost ? 12_000 : 4200, () => {
239
- reveal.cancel()
240
- void update($, rewind, () => null)
241
- })
290
+ $.clock.after(90 * (items.length + 2), () => reveal.cancel())
242
291
  }
243
292
 
244
293
  type Next = (e: Record<string, unknown>) => Promise<{ deny?: string; isError?: true; result?: unknown; text?: string }>
@@ -332,11 +381,12 @@ const brief = (command: string) => {
332
381
  // Something in a turn that can't be undone, named, so undo can list it.
333
382
  const lostOne = (x: Turn, what: string): Turn => ({ ...x, lost: (x.lost ?? 0) + 1, lostItems: [...(x.lostItems ?? []), what].slice(-20) })
334
383
 
335
- // A turn named by what the person asked: ("commit and push it").
336
- const named = (t: Turn) => {
384
+ // A turn named by what the person asked: "commit and push it", cut to `max`.
385
+ const quoted = (t: Turn, max = 40) => {
337
386
  const p = (t.prompt ?? '').replace(/\s+/g, ' ').trim()
338
- return p ? ` ("${p.length > 40 ? `${p.slice(0, 39)}…` : p}")` : ''
387
+ return p ? ` "${p.length > max ? `${p.slice(0, max - 1)}…` : p}"` : ''
339
388
  }
389
+ const named = (t: Turn) => (t.prompt ? ` (${quoted(t).trim()})` : '')
340
390
 
341
391
  // Each part only saves to git (add, commit, push and friends), or only moves around.
342
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$)/
@@ -394,7 +444,7 @@ async function checkCoverage($: EngineInterface, cwd: string) {
394
444
  const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
395
445
  try {
396
446
  for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
397
- if (c.service === 'supabase' && c.state === 'ask') await askSupabase($, c.dir ?? cwd)
447
+ if (c.state === 'ask') await askDatabase($, c.dir ?? cwd, c.service)
398
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.')
399
449
  }
400
450
  } catch {
@@ -422,36 +472,45 @@ const ASK = {
422
472
  // Projects asked about this session: "Not now" means not again until the next one.
423
473
  const asked = new Set<string>()
424
474
 
425
- // Asked when Claude Code opens a project that uses Supabase, or Claude is about
426
- // to change one, and Backstep isn't connected to it; the answer is the only
427
- // thing that connects it.
428
- async function askSupabase($: EngineInterface, cwd: string) {
429
- if (asked.has(cwd)) return
430
- asked.add(cwd)
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
431
486
  const answer = await $.ui
432
- .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?", {
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?`, {
433
488
  header: 'Backstep',
434
489
  options: [ASK.connect, ASK.later, ASK.never],
435
490
  })
436
491
  .catch(() => null)
437
492
  if (answer === ASK.never) {
438
- await $.process.run(backstep(['connect', 'supabase', '--dir', cwd, '--dismiss'])).catch(() => null)
493
+ await $.process.run(backstep(['connect', service, '--dir', cwd, '--dismiss'])).catch(() => null)
439
494
  return
440
495
  }
441
496
  if (answer !== ASK.connect) return
442
- const ran = await $.process.run(backstep(['connect', 'supabase', '--dir', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
443
- let s: Connected | undefined
497
+ const ran = await $.process.run(backstep(['connect', service, '--dir', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
498
+ let all: Connected[] = []
444
499
  try {
445
- s = (JSON.parse(ran?.stdout || '[]') as Connected[])[0]
500
+ all = JSON.parse(ran?.stdout || '[]') as Connected[]
446
501
  } catch {
447
- s = undefined
502
+ all = []
448
503
  }
449
- if (s?.state === 'connected' || s?.state === 'covered') {
450
- $.ui.toast(`Backstep ${s.detail}. Claude's changes to its database can now be undone.`)
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}.`)
451
508
  return
452
509
  }
453
- // Nothing in the project to connect with: a sign-in is needed, and Claude walks the person through it.
454
- $.ui.toast('Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.')
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}`)
455
514
  }
456
515
 
457
516
  const UPDATE = { yes: 'Update', later: 'Not now', skip: 'Skip this version' }
@@ -596,6 +655,9 @@ export const register: Register = (on, options) => {
596
655
  on('prompt.submit', async ($, e, next) => {
597
656
  if (e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
598
657
  const startedAt = await $.clock.now()
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)
599
661
  await update($, turns, (l) => [...(l ?? []), newTurn(startedAt, e.text)].slice(-KEEP_TURNS))
600
662
  const pending = await read($, note)
601
663
  if (!pending) return next(e)
@@ -636,8 +698,8 @@ export const register: Register = (on, options) => {
636
698
  // so it can be saved before the command runs.
637
699
  const checked = await $.process.run(backstep(['db-check', '--cwd', cwd, '--', command])).catch(() => null)
638
700
  try {
639
- const ask = (JSON.parse(checked?.stdout || '{}') as { ask?: string | null }).ask
640
- if (ask) await askSupabase($, ask)
701
+ const { ask, service } = JSON.parse(checked?.stdout || '{}') as { ask?: string | null; service?: string | null }
702
+ if (ask) await askDatabase($, ask, service ?? 'supabase')
641
703
  } catch {
642
704
  // can't tell: run it as before
643
705
  }
@@ -665,9 +727,9 @@ export const register: Register = (on, options) => {
665
727
  )
666
728
  }
667
729
  // A push: once it's run, note what it sent, so undo can offer to take it back there.
668
- if (plan?.id && PUSH.test(command)) {
730
+ if (plan?.id && (PUSH.test(command) || GH_CREATE.test(command))) {
669
731
  const r = await next(e)
670
- await $.process.run(backstep(['shell-end', plan.id])).catch(() => null)
732
+ await $.process.run(backstep(['shell-end', plan.id]), { stdin: r.text ?? '' }).catch(() => null)
671
733
  return r
672
734
  }
673
735
  } else if (tool.startsWith('mcp__')) {
@@ -727,40 +789,53 @@ export const register: Register = (on, options) => {
727
789
  const { target, before } = await undoTargets($)
728
790
  const label = (await read($, keys)).map(keyLabel).join(' / ')
729
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
+ )
730
798
  // The last turn only committed and pushed: the work it saved is what the person most likely wants back.
731
799
  if (before) {
732
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
+ }
823
+ return (
824
+ <Box flexDirection="column">
733
825
  <Box>
734
826
  <Text bold>◀</Text>
735
827
  <Text color={PINK} bold>◀</Text>
736
- <Text color="warning" bold> That turn only saved and pushed to git. </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>
737
833
  <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
738
- {`${label} again to undo it and the turn before${named(before)}`}
739
- </Button>
740
- <Text dimColor> · </Text>
741
- <Button key="just" plain dimColor onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
742
- just the push
743
- </Button>
744
- <Text dimColor> · </Text>
745
- <Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
746
- cancel
834
+ {`${label} again to undo`}
747
835
  </Button>
836
+ <Text dimColor>{' · '}</Text>
837
+ {cancel}
748
838
  </Box>
749
- )
750
- }
751
- return (
752
- <Box>
753
- <Text bold>◀</Text>
754
- <Text color={PINK} bold>◀</Text>
755
- <Text color="warning" bold>{` Undo Claude's last turn${named(target)}? `}</Text>
756
- <Text dimColor>{summary(target)} </Text>
757
- <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
758
- {`${label} again to undo`}
759
- </Button>
760
- <Text dimColor> · </Text>
761
- <Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
762
- cancel
763
- </Button>
764
839
  </Box>
765
840
  )
766
841
  }
@@ -773,7 +848,7 @@ export const register: Register = (on, options) => {
773
848
  <Text bold>◀</Text>
774
849
  <Text color={PINK} bold>◀</Text>
775
850
  <Text> </Text>
776
- <Text dimColor>{target ? `Last turn${named(target)}: ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
851
+ <Text dimColor>{target ? `Last turn:${quoted(target, 28)} · ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
777
852
  <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
778
853
  {`${label} to undo`}
779
854
  </Button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.10",
3
+ "version": "0.1.13",
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",