backstep 0.1.26 → 0.1.27
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 +3 -1
- package/dist/telemetry.js +5 -2
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +47 -0
- package/mod/hooks/register.tsx +82 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,6 +67,8 @@ With Claude Code: `claude mcp add files -- backstep wrap --name files -- <the se
|
|
|
67
67
|
|
|
68
68
|
The undo key is ctrl+u (not ctrl+z: Claude Code suspends itself on ctrl+z in a terminal before anything else sees the key, so it can't be used). The first press asks "Undo Claude's last turn?" above the prompt and a second press within 5 seconds undoes, so a stray press does nothing. Change the key whenever you like: ask Claude ("change my Backstep undo key to ctrl+q"), or run `backstep key ctrl+q`. The line above the prompt is always there, even with nothing to undo, because the key only reaches Backstep while it's on screen; otherwise a press would do nothing.
|
|
69
69
|
|
|
70
|
+
In the Claude Code desktop app, keys don't reach Backstep, so the line above the message box has an **Undo last turn** button to click instead (it asks once, then undoes). Typing `/backstep-undo` works everywhere and undoes straight away.
|
|
71
|
+
|
|
70
72
|
Backstep checks npm for a newer version as each Claude Code session starts and asks before updating (Update, Not now, or Skip this version; `backstep update` does it by hand, `BACKSTEP_UPDATE_CHECK=0` turns the check off).
|
|
71
73
|
|
|
72
74
|
Other commands: `backstep ui`, `backstep log`, `backstep undo <action-id>`, `backstep undo --run <run-id>`, `backstep connect [supabase|firebase]` (connects what the project uses, the same as asking Claude), `backstep disconnect supabase <ref>`.
|
|
@@ -119,7 +121,7 @@ Everything is journaled to `~/.backstep/journal.db` (SQLite, mode 0600; override
|
|
|
119
121
|
|
|
120
122
|
## Telemetry
|
|
121
123
|
|
|
122
|
-
`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`.
|
|
124
|
+
`backstep setup` sends one anonymous `install` event the first time on a computer (and an `update` event each time after, as updates run setup again), 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`.
|
|
123
125
|
|
|
124
126
|
Turn it off with `BACKSTEP_TELEMETRY=0`, or the standard `DO_NOT_TRACK=1`.
|
|
125
127
|
|
package/dist/telemetry.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// An anonymous count of installs and
|
|
1
|
+
// An anonymous count of installs, updates and installs still in use, so we know how
|
|
2
2
|
// many people use Backstep. Each event carries a random id made on this
|
|
3
3
|
// machine, the Backstep version, the OS and the Node version. Never paths,
|
|
4
4
|
// commands, server names or anything from the journal.
|
|
@@ -62,7 +62,10 @@ async function send(event) {
|
|
|
62
62
|
// Offline, blocked, or slow: skip it.
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
-
|
|
65
|
+
// Setup runs on a first install and again on every update. Only the first, on a
|
|
66
|
+
// computer that has never sent anything (no install-id yet), counts as an
|
|
67
|
+
// install; every later setup is an update, so installs stay new installs.
|
|
68
|
+
export const sendInstall = () => send(existsSync(join(homeDir(), "install-id")) ? "update" : "install");
|
|
66
69
|
export const sendUninstall = () => send("uninstall");
|
|
67
70
|
// At most once a day, as Claude Code starts with Backstep's mod.
|
|
68
71
|
export async function sendActive() {
|
|
@@ -1129,3 +1129,50 @@ test('when signing in is required and nobody is, ctrl+u says how to sign in inst
|
|
|
1129
1129
|
expect(toasts.at(-1)).toContain('npx backstep login')
|
|
1130
1130
|
await ui.unmount()
|
|
1131
1131
|
})
|
|
1132
|
+
|
|
1133
|
+
test('in the desktop app the band says Undo (a click), since ctrl+u never reaches it there', { options: { cli: CLI } }, async ($, on) => {
|
|
1134
|
+
mock.clock(on, { now: 5000 })
|
|
1135
|
+
engine(on, { exitCode: 0, stdout: '[]' })
|
|
1136
|
+
await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
|
|
1137
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
|
|
1138
|
+
|
|
1139
|
+
const desk = await $.ui.mount({ ...BAND, surface: 'desktop' })
|
|
1140
|
+
expect((await desk.find({ type: 'Button', key: 'undo' }))?.text).toBe('◀ Undo last turn')
|
|
1141
|
+
expect(JSON.stringify(await desk.find({ type: 'Box', text: /Undo last turn/ }))).not.toContain('ctrl')
|
|
1142
|
+
await desk.press({ key: 'undo' })
|
|
1143
|
+
expect(await desk.find({ type: 'Text', text: /Undo Claude's last turn\?/ })).toBeTruthy()
|
|
1144
|
+
expect((await desk.find({ type: 'Button', key: 'undo' }))?.text).toBe('Yes, undo it')
|
|
1145
|
+
expect((await desk.find({ type: 'Button', key: 'cancel' }))?.text).toBe('Cancel')
|
|
1146
|
+
await desk.unmount()
|
|
1147
|
+
|
|
1148
|
+
const term = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1149
|
+
expect((await term.find({ type: 'Button', key: 'undo' }))?.text).toContain('again to undo')
|
|
1150
|
+
await term.unmount()
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
test('/backstep-undo undoes the last turn straight away, and says when there is nothing to undo', { options: { cli: CLI } }, async ($, on) => {
|
|
1154
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
1155
|
+
const { runs } = engine(on, { exitCode: 0, stdout: JSON.stringify([{ id: 'c1', tool: 'Edit', ok: true, detail: 'restored /repo/src/a.ts' }]) })
|
|
1156
|
+
|
|
1157
|
+
expect(JSON.stringify(await $.command.run({ command: 'backstep-undo' } as never))).toContain('Nothing to undo yet')
|
|
1158
|
+
|
|
1159
|
+
await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
|
|
1160
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
|
|
1161
|
+
const ran = $.command.run({ command: 'backstep-undo' } as never)
|
|
1162
|
+
await clock.advance(1000)
|
|
1163
|
+
const r = await ran
|
|
1164
|
+
expect(runs.some((a) => a[3] === 'undo')).toBe(true)
|
|
1165
|
+
expect(JSON.stringify(r)).toContain('Rewound 1 change from the last turn.')
|
|
1166
|
+
})
|
|
1167
|
+
|
|
1168
|
+
test('in the desktop app, the update is offered the same way and says not to close Claude Code', { options: { cli: CLI } }, async ($, on) => {
|
|
1169
|
+
const { asked, logs, statuses } = updates(on, 'Update')
|
|
1170
|
+
await $.session.start({ cwd: '/repo', surface: 'desktop', hasPerson: true } as never)
|
|
1171
|
+
await settle()
|
|
1172
|
+
expect(asked).toEqual(['Backstep 0.2.0 is out (you have 0.1.2). Update now?'])
|
|
1173
|
+
const said = logs.join('\n')
|
|
1174
|
+
expect(said).toContain('type /backstep-undo')
|
|
1175
|
+
expect(said).toContain("Don't close Claude Code until it says it's done")
|
|
1176
|
+
expect(statuses[0]).toContain("don't close Claude Code")
|
|
1177
|
+
expect(said).not.toContain('terminal')
|
|
1178
|
+
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -281,6 +281,18 @@ async function pressUndo($: EngineInterface) {
|
|
|
281
281
|
})
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
// /backstep-undo: the command is the confirmation, so it undoes straight away.
|
|
285
|
+
async function undoByCommand($: EngineInterface): Promise<string> {
|
|
286
|
+
if (await read($, rewind)) return 'Backstep is already undoing a turn.'
|
|
287
|
+
const off = await read($, blocked)
|
|
288
|
+
if (off) return `${MARK_TEXT} ${off}`
|
|
289
|
+
if (!(await read($, turns)).some(hasWork)) return "Nothing to undo yet: Claude hasn't changed anything Backstep can take back."
|
|
290
|
+
await update($, armed, () => null)
|
|
291
|
+
const { before } = await undoTargets($)
|
|
292
|
+
await undoLastTurn($, before ? 2 : 1)
|
|
293
|
+
return (await read($, rewind))?.message ?? 'Done.'
|
|
294
|
+
}
|
|
295
|
+
|
|
284
296
|
// Undoes the last `count` turns that changed something, newest first.
|
|
285
297
|
async function undoLastTurn($: EngineInterface, count = 1) {
|
|
286
298
|
if (await read($, rewind)) return
|
|
@@ -396,7 +408,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
396
408
|
|
|
397
409
|
if (done) {
|
|
398
410
|
const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
|
|
399
|
-
const key = await keysLabel($)
|
|
411
|
+
const key = surface === 'terminal' ? await keysLabel($) : 'the Undo button or /backstep-undo'
|
|
400
412
|
await update($, note, () =>
|
|
401
413
|
`[Backstep] The user pressed undo (${key}) on your ${taken.length > 1 ? `last ${taken.length} turns` : 'previous turn'}. These changes were reverted:\n${list}\n` +
|
|
402
414
|
(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` : '') +
|
|
@@ -686,7 +698,7 @@ async function checkAccess($: EngineInterface): Promise<boolean> {
|
|
|
686
698
|
// the minimum, then
|
|
687
699
|
}
|
|
688
700
|
const answer = await $.ui
|
|
689
|
-
.ask(`This version of Backstep (${a.current}) is no longer supported, so ${await keysLabel($)} is off until it updates. Update to ${latest} now?`, {
|
|
701
|
+
.ask(`This version of Backstep (${a.current}) is no longer supported, so ${surface === 'terminal' ? await keysLabel($) : 'undo'} is off until it updates. Update to ${latest} now?`, {
|
|
690
702
|
header: 'Backstep',
|
|
691
703
|
options: ['Update now', 'Not now'],
|
|
692
704
|
})
|
|
@@ -721,9 +733,10 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
|
|
|
721
733
|
const u = { current, latest }
|
|
722
734
|
// Said where it stays (the conversation) and where it's seen while it runs (the status line).
|
|
723
735
|
const from = u.current ? ` from ${u.current}` : ''
|
|
724
|
-
|
|
725
|
-
$.ui.
|
|
726
|
-
$.ui.
|
|
736
|
+
const win = surface === 'terminal' ? 'this terminal' : 'Claude Code'
|
|
737
|
+
$.ui.log(`${MARK_TEXT} Updating Backstep${from} to ${u.latest}… Don't close ${win} until it says it's done (about a minute).`)
|
|
738
|
+
$.ui.toast(`Updating Backstep to ${u.latest}. Don't close ${win} until it's done.`, { timeoutMs: 15_000 })
|
|
739
|
+
$.ui.status(`◀◀ Updating Backstep to ${u.latest}… don't close ${win}`)
|
|
727
740
|
const done = await $.process
|
|
728
741
|
.run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 600_000 })
|
|
729
742
|
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
@@ -735,7 +748,7 @@ async function installUpdate($: EngineInterface, current: string | undefined, la
|
|
|
735
748
|
}
|
|
736
749
|
$.ui.status(undefined)
|
|
737
750
|
if (r.ok) {
|
|
738
|
-
$.ui.log(`${MARK_TEXT} Backstep is updated to ${u.latest}${u.current ? ` (you had ${u.current})` : ''}. It's safe to close
|
|
751
|
+
$.ui.log(`${MARK_TEXT} Backstep is updated to ${u.latest}${u.current ? ` (you had ${u.current})` : ''}. It's safe to close ${win} now. Restart Claude Code to start using ${u.latest}.`)
|
|
739
752
|
$.ui.toast(`Backstep is updated to ${u.latest}. Restart Claude Code to use it.`)
|
|
740
753
|
return true
|
|
741
754
|
} else {
|
|
@@ -866,15 +879,21 @@ async function uninstallBackstep($: EngineInterface): Promise<string> {
|
|
|
866
879
|
return `Backstep is removed. Restart Claude Code to finish.\n${lines}`
|
|
867
880
|
}
|
|
868
881
|
|
|
882
|
+
// Where this session is drawn: the terminal, or an app (the desktop app, VS Code) where there's no terminal to mention.
|
|
883
|
+
let surface = 'terminal'
|
|
884
|
+
|
|
869
885
|
export const register: Register = (on, options) => {
|
|
870
886
|
cli = String(options.cli ?? 'backstep')
|
|
871
887
|
|
|
872
888
|
on('session.start', async ($, e, next) => {
|
|
889
|
+
surface = e.surface ?? 'terminal'
|
|
873
890
|
await $.tool.register(CONNECT_TOOL)
|
|
874
891
|
await $.tool.register(KEY_TOOL)
|
|
875
892
|
await $.tool.register(UNINSTALL_TOOL)
|
|
876
893
|
// A command that can't be added never holds up the session: asking Claude still uninstalls.
|
|
877
894
|
await $.command.register({ name: 'uninstall-backstep', description: 'Remove Backstep and everything it set up from this computer' }).catch(() => null)
|
|
895
|
+
// Undo by command too: the desktop app has no way for ctrl+u to reach Backstep yet.
|
|
896
|
+
await $.command.register({ name: 'backstep-undo', description: "Undo Claude's last turn with Backstep" }).catch(() => null)
|
|
878
897
|
const r = await next(e)
|
|
879
898
|
const boundOut = await $.process.run(backstep(['key', '--json'])).catch(() => null)
|
|
880
899
|
try {
|
|
@@ -884,7 +903,11 @@ export const register: Register = (on, options) => {
|
|
|
884
903
|
// keep the default
|
|
885
904
|
}
|
|
886
905
|
// Said first thing, so nobody has to wonder whether it's running.
|
|
887
|
-
$.ui.log(
|
|
906
|
+
$.ui.log(
|
|
907
|
+
e.surface === 'terminal'
|
|
908
|
+
? `✓ Backstep connected · press ${await keysLabel($)} to undo Claude's last turn`
|
|
909
|
+
: "✓ Backstep connected · click Undo last turn above the message box, or type /backstep-undo, to undo Claude's last turn",
|
|
910
|
+
)
|
|
888
911
|
// In the background: a session never waits on either.
|
|
889
912
|
void wrapNewServers($, e.cwd)
|
|
890
913
|
// One question at a time: Supabase first, then whether this version may run, then any update.
|
|
@@ -917,6 +940,7 @@ export const register: Register = (on, options) => {
|
|
|
917
940
|
on('tool.call', { tool: UNINSTALL }, async ($) => ({ result: await uninstallBackstep($) }))
|
|
918
941
|
|
|
919
942
|
on('command.run', { command: 'uninstall-backstep' }, async ($) => ({ text: await uninstallBackstep($) }))
|
|
943
|
+
on('command.run', { command: 'backstep-undo' }, async ($) => ({ text: await undoByCommand($) }))
|
|
920
944
|
|
|
921
945
|
on('tool.call', async ($, e, next) => {
|
|
922
946
|
const tool = String(e.tool)
|
|
@@ -1066,6 +1090,57 @@ export const register: Register = (on, options) => {
|
|
|
1066
1090
|
|
|
1067
1091
|
const { target, before } = await undoTargets($)
|
|
1068
1092
|
const label = (await read($, keys)).map(keyLabel).join(' / ')
|
|
1093
|
+
|
|
1094
|
+
// The desktop app (and any app that isn't the terminal): ctrl+u never reaches
|
|
1095
|
+
// Backstep there, so it's real buttons to click and no key is mentioned.
|
|
1096
|
+
const appBand = async (target: Turn | undefined, before: Turn | undefined) => {
|
|
1097
|
+
const cancel = (
|
|
1098
|
+
<Button key="cancel" variant="secondary" onPress={() => void update($, armed, () => null)}>
|
|
1099
|
+
Cancel
|
|
1100
|
+
</Button>
|
|
1101
|
+
)
|
|
1102
|
+
if (target && (await read($, armed)) !== null) {
|
|
1103
|
+
return (
|
|
1104
|
+
<Box flexDirection="column">
|
|
1105
|
+
<Box>
|
|
1106
|
+
{mark()}
|
|
1107
|
+
<Text color="warning" bold>{before ? 'That turn only saved and pushed to git.' : "Undo Claude's last turn?"}</Text>
|
|
1108
|
+
</Box>
|
|
1109
|
+
<Text dimColor>{before ? ` Undo it and the turn before it${quoted(before, 50)}?` : ` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
|
|
1110
|
+
<Box marginTop={1}>
|
|
1111
|
+
<Text>{' '}</Text>
|
|
1112
|
+
<Button key="undo" variant="primary" onPress={() => void pressUndo($)}>
|
|
1113
|
+
{before ? 'Yes, undo both' : 'Yes, undo it'}
|
|
1114
|
+
</Button>
|
|
1115
|
+
<Text>{' '}</Text>
|
|
1116
|
+
{before ? (
|
|
1117
|
+
<Button key="just" variant="secondary" onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
|
|
1118
|
+
Just the push
|
|
1119
|
+
</Button>
|
|
1120
|
+
) : null}
|
|
1121
|
+
{before ? <Text>{' '}</Text> : null}
|
|
1122
|
+
{cancel}
|
|
1123
|
+
</Box>
|
|
1124
|
+
</Box>
|
|
1125
|
+
)
|
|
1126
|
+
}
|
|
1127
|
+
return (
|
|
1128
|
+
<Box>
|
|
1129
|
+
{mark()}
|
|
1130
|
+
<Box flexShrink={1} minWidth={0}>
|
|
1131
|
+
<Text dimColor wrap="truncate-end">{target ? `Last turn:${quoted(target, 28)} · ${summary(target)}` : 'Backstep is running · nothing to undo yet'}</Text>
|
|
1132
|
+
</Box>
|
|
1133
|
+
<Box flexShrink={0} marginLeft={2}>
|
|
1134
|
+
<Button key="undo" variant="primary" onPress={() => void pressUndo($)}>
|
|
1135
|
+
◀ Undo last turn
|
|
1136
|
+
</Button>
|
|
1137
|
+
</Box>
|
|
1138
|
+
</Box>
|
|
1139
|
+
)
|
|
1140
|
+
}
|
|
1141
|
+
// Only the terminal hands ctrl+u to the button; anywhere else (the desktop app) it's a click.
|
|
1142
|
+
const keyed = e.surface === 'terminal'
|
|
1143
|
+
if (!keyed) return appBand(target, before)
|
|
1069
1144
|
if (target && (await read($, armed)) !== null) {
|
|
1070
1145
|
// Laid out a line at a time, so nothing gets squeezed into columns on a narrow terminal.
|
|
1071
1146
|
const cancel = (
|