snapback-tui 0.4.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 +60 -0
- package/bin/darwin-arm64/snapback +0 -0
- package/bin/darwin-x64/snapback +0 -0
- package/bin/linux-arm64/snapback +0 -0
- package/bin/linux-x64/snapback +0 -0
- package/cli.js +286 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# snapback
|
|
2
|
+
|
|
3
|
+
Browse, search, and resume **Claude Code** sessions from a terminal UI.
|
|
4
|
+
|
|
5
|
+
This is the npm distribution of [`snapback`](https://github.com/ilfroloff/snapback),
|
|
6
|
+
a single self-contained Rust [ratatui](https://ratatui.rs) TUI. It ships prebuilt
|
|
7
|
+
binaries, so installing needs **no Rust toolchain**.
|
|
8
|
+
|
|
9
|
+
> Published as **`snapback-tui`** because the `snapback` name on npm is taken by
|
|
10
|
+
> an unrelated package. The installed commands are still `snapback` and `sb`.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npx snapback-tui install
|
|
16
|
+
# or
|
|
17
|
+
bunx snapback-tui install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That copies the native `snapback` and `sb` binaries to `~/.local/bin`, after
|
|
21
|
+
which you run them directly — no npx, and no Node in the process tree:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
snapback # browse the CURRENT folder's sessions
|
|
25
|
+
sb # same thing (short alias)
|
|
26
|
+
snapback -a # browse EVERY folder's sessions, grouped repo → branch
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
To install somewhere else, set `SNAPBACK_INSTALL_DIR`:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
SNAPBACK_INSTALL_DIR=/usr/local/bin npx snapback-tui install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
To uninstall, delete the two binaries — `install` prints the exact command.
|
|
36
|
+
|
|
37
|
+
### Run without installing
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npx snapback-tui # runs the TUI straight from the package
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Handy for a look, but `install` is the better path: it takes Node out from
|
|
44
|
+
between your terminal and a program that owns raw mode and hands the terminal to
|
|
45
|
+
`claude` and back.
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- **`claude` on your `PATH`** — snapback resumes into it.
|
|
50
|
+
- macOS (arm64/x64) or Linux (x64/arm64). On other platforms, build from source:
|
|
51
|
+
`cargo install --git https://github.com/ilfroloff/snapback`
|
|
52
|
+
|
|
53
|
+
## Docs
|
|
54
|
+
|
|
55
|
+
Full feature list, key map, and configuration:
|
|
56
|
+
**[github.com/ilfroloff/snapback](https://github.com/ilfroloff/snapback)**
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
Apache-2.0
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/cli.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// npm entry point for snapback, published as `snapback-tui`.
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS PACKAGE EXISTS: installing snapback otherwise needs a Rust toolchain
|
|
7
|
+
// (`cargo install --git ...`). This hands out the PREBUILT binaries instead, so
|
|
8
|
+
// `npx snapback-tui install` / `bunx snapback-tui install` works on a machine
|
|
9
|
+
// that has never seen rustup.
|
|
10
|
+
//
|
|
11
|
+
// WHY IT CARRIES EVERY PLATFORM AT ONCE rather than the esbuild-style
|
|
12
|
+
// optionalDependencies split: the binaries are ~1.8M each, so all four together
|
|
13
|
+
// are ~4MB — the split would save ~2MB per install and cost five packages to
|
|
14
|
+
// keep version-synced on every release, plus npm's known optionalDeps/lockfile
|
|
15
|
+
// bugs. YAGNI: esbuild needs that pattern because its binaries are ~10M x 20
|
|
16
|
+
// platforms. We are two orders of magnitude away from that problem.
|
|
17
|
+
//
|
|
18
|
+
// WHY IT RUNS NO LIFECYCLE SCRIPTS, deliberately: bun blocks `postinstall` for
|
|
19
|
+
// untrusted packages by default (only the top-500 npm packages are auto-trusted),
|
|
20
|
+
// so the usual "download the binary in postinstall" design would silently no-op
|
|
21
|
+
// under `bunx`. Everything here happens in this `bin` entry, which bunx invokes
|
|
22
|
+
// directly and therefore never blocks.
|
|
23
|
+
//
|
|
24
|
+
// WHY `install` IS THE BLESSED PATH rather than bare `npx snapback-tui`: it puts
|
|
25
|
+
// the native binaries on PATH, so the TUI thereafter runs with NO node in the
|
|
26
|
+
// process tree. That matters more here than for a typical CLI — snapback takes
|
|
27
|
+
// raw mode and an alt screen, and spawns `claude` as a child that hands the
|
|
28
|
+
// terminal back. Every process between the terminal and the TUI is one more
|
|
29
|
+
// thing that can leak a mode or swallow a signal. Bare `npx` is supported for a
|
|
30
|
+
// try-before-you-install run; see `runTui` for what that costs.
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const os = require('node:os');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { spawn } = require('node:child_process');
|
|
36
|
+
|
|
37
|
+
// The names to put on PATH. `cargo install` produces both, so this matches what
|
|
38
|
+
// a from-source install gives you.
|
|
39
|
+
const INSTALL_NAMES = ['snapback', 'sb'];
|
|
40
|
+
|
|
41
|
+
// ...but only ONE binary is shipped per platform, and both names are copies of
|
|
42
|
+
// it. That is not a shortcut, it is what the crate itself guarantees: src/main.rs
|
|
43
|
+
// and src/bin/sb.rs are the same 250-byte shim calling `snapback::run()`, and
|
|
44
|
+
// "no `argv[0]` dispatch, so `sb` and `snapback` are the same program under two
|
|
45
|
+
// names" is stated as a deliberate contract in src/bin/sb.rs and src/lib.rs.
|
|
46
|
+
// Shipping the second copy would double the tarball (6.2MB -> 3.1MB measured) to
|
|
47
|
+
// carry the same logic twice. The release workflow pins the contract before
|
|
48
|
+
// relying on it, by diffing the two real binaries' behaviour on every build.
|
|
49
|
+
const SHIPPED_BIN_NAME = 'snapback';
|
|
50
|
+
|
|
51
|
+
// Where the per-platform binaries sit inside the published tarball. Populated by
|
|
52
|
+
// .github/workflows/npm-release.yml, NOT committed — see npm/.gitignore.
|
|
53
|
+
const BIN_ROOT = path.join(__dirname, 'bin');
|
|
54
|
+
|
|
55
|
+
// The platforms the release workflow builds, as `${process.platform}-${process.arch}`.
|
|
56
|
+
// This list and the workflow's build matrix are ONE fact in two files: adding a
|
|
57
|
+
// target means adding it to both, or `install` reports a platform as supported
|
|
58
|
+
// and then fails to find its binary.
|
|
59
|
+
const SUPPORTED_PLATFORMS = ['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64'];
|
|
60
|
+
|
|
61
|
+
// Default install target. `~/.local/bin` is the de facto user-level bin dir on
|
|
62
|
+
// both macOS and Linux and is already on PATH in most setups; when it is not, we
|
|
63
|
+
// say so rather than silently installing somewhere inert.
|
|
64
|
+
const DEFAULT_INSTALL_DIR = path.join(os.homedir(), '.local', 'bin');
|
|
65
|
+
|
|
66
|
+
// Env override for the install dir, for anyone who keeps user binaries elsewhere.
|
|
67
|
+
const INSTALL_DIR_ENV = 'SNAPBACK_INSTALL_DIR';
|
|
68
|
+
|
|
69
|
+
// rwxr-xr-x. Copied files inherit the source mode, but the tarball round trip is
|
|
70
|
+
// not something to bet the executable bit on, so it is set explicitly.
|
|
71
|
+
const MODE_EXEC = 0o755;
|
|
72
|
+
|
|
73
|
+
// Shell rc file per shell, for the "not on PATH" hint. Bare `sh`/unknown shells
|
|
74
|
+
// fall through to a generic hint rather than a wrong filename.
|
|
75
|
+
const SHELL_RC = {
|
|
76
|
+
zsh: '~/.zshrc',
|
|
77
|
+
bash: '~/.bashrc',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Pure helpers
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/** `${platform}-${arch}` key naming a build target. */
|
|
85
|
+
function platformKey(platform, arch) {
|
|
86
|
+
return `${platform}-${arch}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether the release workflow builds a binary for this key. */
|
|
90
|
+
function isSupported(key) {
|
|
91
|
+
return SUPPORTED_PLATFORMS.includes(key);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether `dir` is already an entry in a PATH-shaped string.
|
|
96
|
+
*
|
|
97
|
+
* Resolves both sides so `~/.local/bin`, `/Users/x/.local/bin` and a trailing
|
|
98
|
+
* slash all compare equal — a false "not on PATH" would send the user to edit an
|
|
99
|
+
* rc file that already has the line.
|
|
100
|
+
*/
|
|
101
|
+
function dirOnPath(dir, pathEnv) {
|
|
102
|
+
const target = path.resolve(dir);
|
|
103
|
+
return String(pathEnv || '')
|
|
104
|
+
.split(path.delimiter)
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.some((entry) => {
|
|
107
|
+
try {
|
|
108
|
+
return path.resolve(entry) === target;
|
|
109
|
+
} catch {
|
|
110
|
+
// A malformed PATH entry is not a reason to fail the install.
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The rc file to suggest for a given `$SHELL`, or null if we cannot tell. */
|
|
117
|
+
function shellRcFor(shellPath) {
|
|
118
|
+
if (!shellPath) return null;
|
|
119
|
+
return SHELL_RC[path.basename(String(shellPath))] || null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Impure drivers
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Absolute path to the bundled binary for this host.
|
|
128
|
+
*
|
|
129
|
+
* Fails LOUDLY, with the reason and a route forward. An installer that guesses
|
|
130
|
+
* on an unsupported host would hand back a binary that cannot exec, and the
|
|
131
|
+
* error would surface later wearing a worse disguise.
|
|
132
|
+
*/
|
|
133
|
+
function resolveBinary() {
|
|
134
|
+
const key = platformKey(process.platform, process.arch);
|
|
135
|
+
|
|
136
|
+
if (!isSupported(key)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`snapback has no prebuilt binary for ${key}.\n` +
|
|
139
|
+
`Prebuilt: ${SUPPORTED_PLATFORMS.join(', ')}.\n` +
|
|
140
|
+
`Build from source instead (needs the Rust toolchain):\n` +
|
|
141
|
+
` cargo install --git https://github.com/ilfroloff/snapback`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const binPath = path.join(BIN_ROOT, key, SHIPPED_BIN_NAME);
|
|
146
|
+
if (!fs.existsSync(binPath)) {
|
|
147
|
+
// Supported platform, missing file => the published tarball is malformed
|
|
148
|
+
// (the build matrix and SUPPORTED_PLATFORMS drifted apart). Say that,
|
|
149
|
+
// rather than blaming the user's machine.
|
|
150
|
+
throw new Error(
|
|
151
|
+
`This snapback-tui package is missing its ${key} binary (expected ${binPath}).\n` +
|
|
152
|
+
`That is a packaging bug, not a problem on your end — please report it:\n` +
|
|
153
|
+
` https://github.com/ilfroloff/snapback/issues`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return binPath;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Copy one binary into place.
|
|
161
|
+
*
|
|
162
|
+
* Writes a temp file and renames it, rather than copying onto the destination.
|
|
163
|
+
* Two reasons, both real: a plain copy onto a RUNNING binary fails with ETXTBSY
|
|
164
|
+
* on Linux, and a copy that dies midway leaves a truncated, executable file
|
|
165
|
+
* behind. rename(2) is atomic within a filesystem, so the destination is either
|
|
166
|
+
* the old binary or the new one, never half of one.
|
|
167
|
+
*/
|
|
168
|
+
function installBinary(srcPath, dstPath) {
|
|
169
|
+
const tmpPath = `${dstPath}.tmp-${process.pid}`;
|
|
170
|
+
try {
|
|
171
|
+
fs.copyFileSync(srcPath, tmpPath);
|
|
172
|
+
fs.chmodSync(tmpPath, MODE_EXEC);
|
|
173
|
+
fs.renameSync(tmpPath, dstPath);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
try {
|
|
176
|
+
fs.rmSync(tmpPath, { force: true });
|
|
177
|
+
} catch {
|
|
178
|
+
// Best-effort cleanup; the original error is the one worth reporting.
|
|
179
|
+
}
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `install` — put the native binaries on PATH. */
|
|
185
|
+
function runInstall() {
|
|
186
|
+
const targetDir = process.env[INSTALL_DIR_ENV] || DEFAULT_INSTALL_DIR;
|
|
187
|
+
|
|
188
|
+
// Resolve BEFORE writing anything, so an unsupported platform or a malformed
|
|
189
|
+
// package fails having changed nothing on disk.
|
|
190
|
+
const srcPath = resolveBinary();
|
|
191
|
+
|
|
192
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
193
|
+
for (const name of INSTALL_NAMES) {
|
|
194
|
+
installBinary(srcPath, path.join(targetDir, name));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log(`Installed ${INSTALL_NAMES.join(', ')} to ${targetDir}`);
|
|
198
|
+
|
|
199
|
+
if (dirOnPath(targetDir, process.env.PATH)) {
|
|
200
|
+
console.log('\nRun it:\n snapback # this folder\'s sessions\n sb -a # every folder, grouped');
|
|
201
|
+
} else {
|
|
202
|
+
const rc = shellRcFor(process.env.SHELL);
|
|
203
|
+
console.log(
|
|
204
|
+
`\nWARNING: ${targetDir} is not on your PATH, so the commands above will not resolve yet.` +
|
|
205
|
+
`\nAdd it${rc ? ` (${rc})` : ''}:` +
|
|
206
|
+
`\n export PATH="${targetDir}:$PATH"`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(`\nUninstall:\n rm ${INSTALL_NAMES.map((n) => path.join(targetDir, n)).join(' ')}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Bare `npx snapback-tui [args]` — run the TUI straight from the package.
|
|
215
|
+
*
|
|
216
|
+
* A try-before-you-install convenience, NOT the blessed path: this leaves node
|
|
217
|
+
* sitting between the terminal and a program that owns raw mode, the alt screen,
|
|
218
|
+
* and a `claude` child. Two things follow, and both are load-bearing:
|
|
219
|
+
*
|
|
220
|
+
* - stdio is INHERITED, never piped. The TUI needs the real tty; piping would
|
|
221
|
+
* also deadlock the `claude` hand-off once a pipe filled.
|
|
222
|
+
* - SIGINT/SIGTSTP are IGNORED HERE and left to the child. Ctrl-C and Ctrl-Z
|
|
223
|
+
* reach the whole foreground process group, so node gets them too; on the
|
|
224
|
+
* default handler node would exit FIRST and hand back a terminal still in raw
|
|
225
|
+
* mode with the alt screen up, stealing the restore the TUI does on its way
|
|
226
|
+
* out. Ignoring them lets the child run its own teardown and lets us report
|
|
227
|
+
* its real exit status.
|
|
228
|
+
*/
|
|
229
|
+
function runTui(args) {
|
|
230
|
+
const child = spawn(resolveBinary(), args, { stdio: 'inherit' });
|
|
231
|
+
|
|
232
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGTSTP']) {
|
|
233
|
+
process.on(sig, () => {});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
child.on('error', (err) => {
|
|
237
|
+
console.error(`Failed to run snapback: ${err.message}`);
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
child.on('exit', (code, signal) => {
|
|
242
|
+
// Report the child's status honestly: its exit code, or the shell's
|
|
243
|
+
// 128+signum convention when a signal killed it. Never a flat 0 — snapback
|
|
244
|
+
// distinguishes a clean quit from a failed hand-off by exit status, and a
|
|
245
|
+
// wrapper that flattens that is lying about what happened.
|
|
246
|
+
process.exitCode = signal ? 128 + (os.constants.signals[signal] || 0) : code ?? 1;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function main() {
|
|
251
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
// `install` is intercepted here and never reaches the binary. Safe: snapback
|
|
255
|
+
// takes no positional arguments and ignores unrecognized ones, so there is
|
|
256
|
+
// no `snapback install` being shadowed. Note the asymmetry that follows —
|
|
257
|
+
// the INSTALLED `snapback install` just launches the TUI, ignoring the word.
|
|
258
|
+
// Only the npm wrapper installs, which is why cli.rs's USAGE does not
|
|
259
|
+
// mention it.
|
|
260
|
+
if (command === 'install') {
|
|
261
|
+
runInstall();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
runTui(command === undefined ? [] : [command, ...rest]);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.error(err.message);
|
|
267
|
+
process.exitCode = 1;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Only run when invoked as the command; `require`d (by scripts/preflight.js) this
|
|
272
|
+
// is a module exporting the facts below, so the preflight's idea of what must be
|
|
273
|
+
// in the tarball cannot drift from the installer's idea of what it will look for.
|
|
274
|
+
if (require.main === module) {
|
|
275
|
+
main();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
INSTALL_NAMES,
|
|
280
|
+
SHIPPED_BIN_NAME,
|
|
281
|
+
BIN_ROOT,
|
|
282
|
+
SUPPORTED_PLATFORMS,
|
|
283
|
+
platformKey,
|
|
284
|
+
isSupported,
|
|
285
|
+
dirOnPath,
|
|
286
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "snapback-tui",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Browse, search, and resume Claude Code sessions from a ratatui TUI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude",
|
|
7
|
+
"claude-code",
|
|
8
|
+
"tui",
|
|
9
|
+
"sessions",
|
|
10
|
+
"resume",
|
|
11
|
+
"cli"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/ilfroloff/snapback#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/ilfroloff/snapback/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/ilfroloff/snapback.git",
|
|
20
|
+
"directory": "npm"
|
|
21
|
+
},
|
|
22
|
+
"license": "Apache-2.0",
|
|
23
|
+
"author": "Illia Fralou",
|
|
24
|
+
"type": "commonjs",
|
|
25
|
+
"bin": {
|
|
26
|
+
"snapback-tui": "cli.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"cli.js",
|
|
30
|
+
"bin/"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"os": [
|
|
36
|
+
"darwin",
|
|
37
|
+
"linux"
|
|
38
|
+
],
|
|
39
|
+
"cpu": [
|
|
40
|
+
"x64",
|
|
41
|
+
"arm64"
|
|
42
|
+
],
|
|
43
|
+
"preferUnplugged": true,
|
|
44
|
+
"scripts": {
|
|
45
|
+
"prepublishOnly": "node ./scripts/preflight.js"
|
|
46
|
+
}
|
|
47
|
+
}
|