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 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
+ }