@vinh0809it/claude-sessions 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +130 -0
  3. package/index.js +933 -0
  4. package/package.json +35 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vinh0809it
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # claude-sessions
2
+
3
+ A zero-dependency terminal UI for browsing, resuming, moving, and deleting [Claude Code](https://claude.ai/code) sessions.
4
+
5
+ ```
6
+ claude-sessions path:/home/you/projects/acme
7
+ [.claude-main]
8
+ /home/you/projects/acme/api
9
+ > booking-overlap-check 2026-05-11 23:00
10
+ ekyc-japan-fallback 2026-05-11 22:52
11
+ [.claude-company]
12
+ /home/you/projects/acme/api
13
+ payment-status-check 2026-05-11 16:10
14
+ ↑↓/jk=nav /=search Enter=open n=new m=move d=del q=quit [42]
15
+ ```
16
+
17
+ ## Features
18
+
19
+ - **Auto-discovers** all `~/.claude*` config directories — works with multiple Claude Code profiles out of the box
20
+ - **Live search** across session titles and first messages; results show `[profile] project ›` inline
21
+ - **Open in new tab** — press Enter to resume a session in a new terminal tab (kitty, WezTerm, tmux, or custom)
22
+ - **New session** — press `n` to open a fresh Claude session in any profile and folder
23
+ - **Move sessions** — press `m` to move a session to a different profile, an existing folder, or a custom path
24
+ - **Delete sessions** with confirmation (removes both the `.jsonl` file and companion directory)
25
+ - **Filter by folder** — pass a path to scope the list to sessions in that directory tree
26
+ - Zero runtime dependencies — pure Node.js stdlib
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ # Global install (recommended)
32
+ npm install -g cli-claude-sessions
33
+
34
+ # Or run without installing
35
+ npx cli-claude-sessions
36
+
37
+ # Or manually
38
+ curl -fsSL https://raw.githubusercontent.com/vinh0809it/claude-sessions/main/index.js \
39
+ -o ~/.local/bin/claude-sessions && chmod +x ~/.local/bin/claude-sessions
40
+ ```
41
+
42
+ Requires Node.js 18+.
43
+
44
+ ## Usage
45
+
46
+ ```sh
47
+ claude-sessions # Browse all sessions (TUI)
48
+ claude-sessions ~/projects/acme # Filter to a specific folder
49
+ claude-sessions --named-only # Only show /rename'd sessions
50
+ claude-sessions --list # Plain list (pipe-friendly)
51
+ claude-sessions --json # JSON output
52
+ claude-sessions -d ~/.claude-work # Scan a specific config dir
53
+ ```
54
+
55
+ ### TUI keys
56
+
57
+ | Key | Action |
58
+ |-----|--------|
59
+ | `↑` `↓` / `j` `k` | Navigate |
60
+ | `PgUp` `PgDn` | Page up / down |
61
+ | `g` / `G` | Jump to top / bottom |
62
+ | `/` | Search by title or message content |
63
+ | `Esc` | Clear search |
64
+ | `Enter` | Open session in a new terminal tab |
65
+ | `n` | New session (choose profile + folder) |
66
+ | `m` | Move session to another profile / folder |
67
+ | `d` | Delete session (asks confirmation) |
68
+ | `q` / `Ctrl-C` | Quit |
69
+
70
+ ## Multiple profiles
71
+
72
+ Claude Code supports custom config directories via `CLAUDE_CONFIG_DIR`. `claude-sessions` auto-discovers **all** `~/.claude*` directories, so multiple profiles just work:
73
+
74
+ ```sh
75
+ # ~/.bashrc or ~/.zshrc
76
+ alias claude-main='CLAUDE_CONFIG_DIR=~/.claude-main claude'
77
+ alias claude-work='CLAUDE_CONFIG_DIR=~/.claude-work claude'
78
+ ```
79
+
80
+ Sessions are grouped by profile in the UI. Press `m` to move a session to a different profile or folder (including a custom typed path). Press `n` to open a new session in any profile and folder.
81
+
82
+ ## Open in new tab
83
+
84
+ Press `Enter` on any session to resume it in a new terminal tab. The terminal is auto-detected:
85
+
86
+ | Terminal | Support | Notes |
87
+ |----------|---------|-------|
88
+ | **Kitty** | ✅ | Requires `allow_remote_control yes` in `kitty.conf` |
89
+ | **WezTerm** | ✅ | Uses `wezterm cli spawn` |
90
+ | **tmux** | ✅ | Opens a new window in the current session |
91
+ | Others | ⚙️ | Use `CLAUDE_SESSIONS_OPEN_CMD` |
92
+
93
+ ### Custom open command
94
+
95
+ Set `CLAUDE_SESSIONS_OPEN_CMD` with placeholders `%cmd%`, `%title%`, and `%project%`:
96
+
97
+ ```sh
98
+ # Ghostty (or any terminal with a new-tab flag)
99
+ export CLAUDE_SESSIONS_OPEN_CMD="ghostty --command=bash -i -c %cmd%"
100
+
101
+ # iTerm2 via osascript
102
+ export CLAUDE_SESSIONS_OPEN_CMD="osascript -e 'tell app \"iTerm\" to create window with default profile command \"bash -i -c %cmd%\"'"
103
+
104
+ # Force a specific supported terminal
105
+ export CLAUDE_SESSIONS_TERMINAL=wezterm
106
+ ```
107
+
108
+ ## How sessions are stored
109
+
110
+ Claude Code stores sessions in `~/.claude*/projects/<encoded-path>/<uuid>.jsonl`. Each line is a JSON record. `claude-sessions` reads:
111
+
112
+ - `type: "custom-title"` → the name set by `/rename`
113
+ - First `type: "user"` message → shown as a fallback title
114
+ - `cwd` field → the actual project path (used for filtering)
115
+
116
+ ## Contributing
117
+
118
+ Issues and PRs welcome. The entire tool is a single `index.js` with no build step.
119
+
120
+ ```sh
121
+ git clone https://github.com/vinh0809it/claude-sessions
122
+ cd claude-sessions
123
+ node index.js # run directly
124
+ npm link # install globally from source
125
+ npm test # run the test suite (Node built-in test runner, no extra deps)
126
+ ```
127
+
128
+ ## License
129
+
130
+ [MIT](LICENSE)
package/index.js ADDED
@@ -0,0 +1,933 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * claude-sessions — Terminal UI for Claude Code sessions
6
+ *
7
+ * Usage:
8
+ * claude-sessions [folder] TUI, optionally filtered by folder path
9
+ * claude-sessions --list [folder] Plain list output
10
+ * claude-sessions --named-only Only /rename'd sessions
11
+ * claude-sessions --json JSON output
12
+ * claude-sessions -d <path> Scan a specific config dir
13
+ * claude-sessions --version Print version
14
+ * claude-sessions -h Help
15
+ *
16
+ * TUI keys:
17
+ * ↑ ↓ j k Navigate
18
+ * PgUp PgDn Page up / down
19
+ * g G Jump to top / bottom
20
+ * / Search (shows [config] project inline)
21
+ * Esc Clear search
22
+ * Enter Open session in a new terminal tab
23
+ * n New session
24
+ * m Move session to another config / folder
25
+ * d Delete session
26
+ * q Ctrl-C Quit
27
+ *
28
+ * Environment variables:
29
+ * CLAUDE_SESSIONS_TERMINAL Force terminal: kitty | wezterm | tmux
30
+ * CLAUDE_SESSIONS_OPEN_CMD Custom open command (placeholders: %cmd% %title% %project%)
31
+ */
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+ const os = require('os');
36
+ const { spawnSync } = require('child_process');
37
+
38
+ const VERSION = require('./package.json').version;
39
+
40
+ // ─── Config dir discovery ─────────────────────────────────────────────────────
41
+
42
+ function discoverConfigDirs() {
43
+ const home = os.homedir();
44
+ try {
45
+ return fs.readdirSync(home)
46
+ .filter(n => n === '.claude' || n.startsWith('.claude-'))
47
+ .map(n => path.join(home, n))
48
+ .filter(d => {
49
+ try { return fs.statSync(d).isDirectory() && fs.existsSync(path.join(d, 'projects')); }
50
+ catch { return false; }
51
+ })
52
+ .sort((a, b) => {
53
+ const na = path.basename(a), nb = path.basename(b);
54
+ if (na === '.claude') return 1;
55
+ if (nb === '.claude') return -1;
56
+ return na.localeCompare(nb);
57
+ });
58
+ } catch { return []; }
59
+ }
60
+
61
+ // ─── Args ─────────────────────────────────────────────────────────────────────
62
+
63
+ function parseArgs() {
64
+ const args = process.argv.slice(2);
65
+ const opts = { dir: null, namedOnly: false, json: false, list: false, help: false, version: false, filterPath: null };
66
+ for (let i = 0; i < args.length; i++) {
67
+ const a = args[i];
68
+ if (a === '-d' || a === '--dir') opts.dir = args[++i];
69
+ else if (a === '--named-only') opts.namedOnly = true;
70
+ else if (a === '--json') opts.json = true;
71
+ else if (a === '--list') opts.list = true;
72
+ else if (a === '--version' || a === '-v') opts.version = true;
73
+ else if (a === '-h' || a === '--help') opts.help = true;
74
+ else if (!a.startsWith('-')) opts.filterPath = path.resolve(a);
75
+ }
76
+ return opts;
77
+ }
78
+
79
+ // ─── Session reading ──────────────────────────────────────────────────────────
80
+
81
+ function readSession(jsonlPath) {
82
+ let title = null, firstUserMessage = null, cwd = null, timestamp = null;
83
+ try {
84
+ const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n');
85
+ for (const line of lines) {
86
+ if (!line) continue;
87
+ try {
88
+ const r = JSON.parse(line);
89
+ if (r.type === 'custom-title' && r.customTitle) title = r.customTitle;
90
+ if (!firstUserMessage && r.type === 'user' && r.message?.content) {
91
+ const c = r.message.content;
92
+ const text = typeof c === 'string' ? c : (c.find?.(x => x.type === 'text')?.text ?? '');
93
+ if (text) firstUserMessage = text.trim().replace(/\s+/g, ' ').slice(0, 80);
94
+ }
95
+ if (!cwd && r.cwd) cwd = r.cwd;
96
+ if (!timestamp && r.timestamp) timestamp = r.timestamp;
97
+ } catch { /* malformed line — skip */ }
98
+ }
99
+ } catch { /* unreadable file — return nulls */ }
100
+ return { title, firstUserMessage, cwd, timestamp };
101
+ }
102
+
103
+ function scanConfigDir(configDir, filterPath) {
104
+ const projectsDir = path.join(configDir, 'projects');
105
+ if (!fs.existsSync(projectsDir)) return [];
106
+ const results = [];
107
+
108
+ let projectEntries;
109
+ try { projectEntries = fs.readdirSync(projectsDir); } catch { return []; }
110
+
111
+ for (const pd of projectEntries) {
112
+ const pdFull = path.join(projectsDir, pd);
113
+ try { if (!fs.statSync(pdFull).isDirectory()) continue; } catch { continue; }
114
+
115
+ let files;
116
+ try {
117
+ files = fs.readdirSync(pdFull)
118
+ .filter(f => f.endsWith('.jsonl'))
119
+ .map(f => {
120
+ const p = path.join(pdFull, f);
121
+ return { file: f, mtime: fs.statSync(p).mtime, jsonlPath: p };
122
+ })
123
+ .sort((a, b) => b.mtime - a.mtime);
124
+ } catch { continue; }
125
+
126
+ // .project-path hint written at move time takes highest priority
127
+ let dirPathHint = null;
128
+ try {
129
+ const hint = fs.readFileSync(path.join(pdFull, '.project-path'), 'utf8').trim();
130
+ if (hint) dirPathHint = hint;
131
+ } catch {}
132
+
133
+ for (const { file, mtime, jsonlPath } of files) {
134
+ const { title, firstUserMessage, cwd, timestamp } = readSession(jsonlPath);
135
+ let project = cwd || pd;
136
+ if (dirPathHint) {
137
+ project = dirPathHint;
138
+ } else if (cwd && encodePathToDir(cwd) !== pd) {
139
+ // JSONL cwd is stale; find a sibling whose cwd encodes to this dir name
140
+ for (const sib of files) {
141
+ if (sib.jsonlPath === jsonlPath) continue;
142
+ const { cwd: sibCwd } = readSession(sib.jsonlPath);
143
+ if (sibCwd && encodePathToDir(sibCwd) === pd) { project = sibCwd; break; }
144
+ }
145
+ }
146
+ if (filterPath && !project.startsWith(filterPath)) continue;
147
+ results.push({
148
+ configDir: path.basename(configDir),
149
+ configDirPath: configDir,
150
+ project,
151
+ sessionId: file.replace('.jsonl', ''),
152
+ title,
153
+ firstMessage: firstUserMessage,
154
+ lastModified: mtime,
155
+ timestamp,
156
+ jsonlPath,
157
+ });
158
+ }
159
+ }
160
+ return results.sort((a, b) => b.lastModified - a.lastModified);
161
+ }
162
+
163
+ function loadSessions(opts) {
164
+ const dirs = opts.dir ? [path.resolve(opts.dir)] : discoverConfigDirs();
165
+ if (dirs.length === 0) process.stderr.write('No Claude Code config directories found in ~\n');
166
+ const all = dirs.flatMap(d => scanConfigDir(d, opts.filterPath));
167
+ return opts.namedOnly ? all.filter(s => s.title) : all;
168
+ }
169
+
170
+ // ─── Session operations ───────────────────────────────────────────────────────
171
+
172
+ function deleteSessionFiles(session) {
173
+ try { fs.unlinkSync(session.jsonlPath); } catch {}
174
+ const dir = session.jsonlPath.replace(/\.jsonl$/, '');
175
+ try {
176
+ if (fs.statSync(dir).isDirectory()) fs.rmSync(dir, { recursive: true, force: true });
177
+ } catch {}
178
+ }
179
+
180
+ function moveSession(session, targetConfigDirPath, targetProjectDirName, targetRawPath = null) {
181
+ const srcProjectDirName = path.basename(path.dirname(session.jsonlPath));
182
+ const destDirName = targetProjectDirName || srcProjectDirName;
183
+ const targetProjectDir = path.join(targetConfigDirPath, 'projects', destDirName);
184
+ fs.mkdirSync(targetProjectDir, { recursive: true });
185
+
186
+ const targetJsonl = path.join(targetProjectDir, path.basename(session.jsonlPath));
187
+ fs.renameSync(session.jsonlPath, targetJsonl);
188
+
189
+ const srcDir = session.jsonlPath.replace(/\.jsonl$/, '');
190
+ try {
191
+ if (fs.statSync(srcDir).isDirectory()) {
192
+ fs.renameSync(srcDir, targetJsonl.replace(/\.jsonl$/, ''));
193
+ }
194
+ } catch {}
195
+
196
+ if (targetRawPath) {
197
+ try { fs.writeFileSync(path.join(targetProjectDir, '.project-path'), targetRawPath, 'utf8'); } catch {}
198
+ }
199
+
200
+ session.configDir = path.basename(targetConfigDirPath);
201
+ session.configDirPath = targetConfigDirPath;
202
+ session.jsonlPath = targetJsonl;
203
+ }
204
+
205
+ function listProjectFolders(configDirPath) {
206
+ const projectsDir = path.join(configDirPath, 'projects');
207
+ if (!fs.existsSync(projectsDir)) return [];
208
+ try {
209
+ return fs.readdirSync(projectsDir)
210
+ .filter(d => { try { return fs.statSync(path.join(projectsDir, d)).isDirectory(); } catch { return false; } })
211
+ .map(d => {
212
+ let displayPath = d;
213
+ try {
214
+ const f = fs.readdirSync(path.join(projectsDir, d)).find(f => f.endsWith('.jsonl'));
215
+ if (f) {
216
+ const { cwd } = readSession(path.join(projectsDir, d, f));
217
+ if (cwd) displayPath = shortenPath(cwd);
218
+ }
219
+ } catch {}
220
+ return { projectDirName: d, displayPath };
221
+ })
222
+ .sort((a, b) => a.displayPath.localeCompare(b.displayPath));
223
+ } catch { return []; }
224
+ }
225
+
226
+ function encodePathToDir(absPath) {
227
+ return absPath.replace(/\//g, '-');
228
+ }
229
+
230
+ function tabComplete(input) {
231
+ const expanded = input.replace(/^~/, os.homedir());
232
+ const endsWithSlash = expanded.endsWith('/');
233
+ const baseDir = endsWithSlash ? (expanded.slice(0, -1) || '/') : path.dirname(expanded);
234
+ const prefix = endsWithSlash ? '' : path.basename(expanded);
235
+
236
+ let matches;
237
+ try {
238
+ matches = fs.readdirSync(baseDir)
239
+ .filter(n => n.startsWith(prefix) && (() => {
240
+ try { return fs.statSync(path.join(baseDir, n)).isDirectory(); } catch { return false; }
241
+ })())
242
+ .sort();
243
+ } catch { return { completed: input, matches: [] }; }
244
+
245
+ if (!matches.length) return { completed: input, matches: [] };
246
+
247
+ if (matches.length === 1) {
248
+ const completed = (path.join(baseDir, matches[0]) + '/').replace(os.homedir(), '~');
249
+ return { completed, matches: [] };
250
+ }
251
+
252
+ let common = matches[0];
253
+ for (const m of matches.slice(1)) {
254
+ let i = 0;
255
+ while (i < common.length && i < m.length && common[i] === m[i]) i++;
256
+ common = common.slice(0, i);
257
+ }
258
+
259
+ if (common.length > prefix.length) {
260
+ const completed = path.join(baseDir, common).replace(os.homedir(), '~');
261
+ return { completed, matches: matches.map(m => m + '/') };
262
+ }
263
+
264
+ return { completed: input, matches: matches.map(m => m + '/') };
265
+ }
266
+
267
+ // ─── Terminal detection & open ────────────────────────────────────────────────
268
+
269
+ function detectTerminal() {
270
+ const forced = process.env.CLAUDE_SESSIONS_TERMINAL;
271
+ if (forced) return forced;
272
+ if (process.env.KITTY_WINDOW_ID !== undefined) return 'kitty';
273
+ if (process.env.WEZTERM_PANE !== undefined) return 'wezterm';
274
+ if (process.env.TMUX) return 'tmux';
275
+ if (process.env.TERM_PROGRAM === 'iTerm.app') return 'iterm2';
276
+ return null;
277
+ }
278
+
279
+ function shellEsc(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
280
+
281
+ function trySpawn(cmd, args) {
282
+ const r = spawnSync(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] });
283
+ return r.status === 0
284
+ ? { ok: true }
285
+ : { ok: false, msg: r.stderr?.toString().trim() || `${cmd} exited with ${r.status}` };
286
+ }
287
+
288
+ function resolveProjectPath(session) {
289
+ const sessionDir = path.dirname(session.jsonlPath);
290
+ // Check hint file first (written at move time; survives restarts)
291
+ try {
292
+ const hint = fs.readFileSync(path.join(sessionDir, '.project-path'), 'utf8').trim();
293
+ if (hint && fs.existsSync(hint)) return hint;
294
+ } catch {}
295
+ // Fall back to a sibling session whose cwd resolves to an existing path
296
+ try {
297
+ const siblings = fs.readdirSync(sessionDir)
298
+ .filter(f => f.endsWith('.jsonl') && f !== path.basename(session.jsonlPath));
299
+ for (const sibling of siblings) {
300
+ const { cwd } = readSession(path.join(sessionDir, sibling));
301
+ if (cwd && fs.existsSync(cwd)) return cwd;
302
+ }
303
+ } catch {}
304
+ return fs.existsSync(session.project) ? session.project : os.homedir();
305
+ }
306
+
307
+ function launchInTerminal(shellCmd, title, projectPath) {
308
+ const customTpl = process.env.CLAUDE_SESSIONS_OPEN_CMD;
309
+ if (customTpl) {
310
+ const expanded = customTpl
311
+ .replace(/%cmd%/g, shellEsc(shellCmd))
312
+ .replace(/%title%/g, shellEsc(title))
313
+ .replace(/%project%/g, shellEsc(projectPath));
314
+ const r = spawnSync('bash', ['-c', expanded], { stdio: 'ignore' });
315
+ return r.status === 0 ? { ok: true } : { ok: false, msg: `Custom open command failed (exit ${r.status})` };
316
+ }
317
+
318
+ const term = detectTerminal();
319
+ switch (term) {
320
+ case 'kitty':
321
+ return trySpawn('kitty', ['@', 'launch', '--type=tab', `--tab-title=${title}`, 'bash', '-i', '-c', shellCmd]);
322
+ case 'wezterm':
323
+ return trySpawn('wezterm', ['cli', 'spawn', '--', 'bash', '-i', '-c', shellCmd]);
324
+ case 'tmux':
325
+ return trySpawn('tmux', ['new-window', '-n', title.slice(0, 20), `bash -i -c ${shellEsc(shellCmd)}`]);
326
+ default:
327
+ return { ok: false, msg: 'Terminal not supported. Set CLAUDE_SESSIONS_OPEN_CMD or CLAUDE_SESSIONS_TERMINAL.' };
328
+ }
329
+ }
330
+
331
+ function openSession(session) {
332
+ const projectPath = resolveProjectPath(session);
333
+ const title = session.title || session.sessionId.slice(0, 8);
334
+ const resumeCmd = `CLAUDE_CONFIG_DIR=${shellEsc(session.configDirPath)} claude -r ${session.sessionId}`;
335
+ const shellCmd = `cd ${shellEsc(projectPath)} && ${resumeCmd}; exec $SHELL`;
336
+ return launchInTerminal(shellCmd, title, projectPath);
337
+ }
338
+
339
+ function openNewSession(configDirPath, projectPath) {
340
+ const title = path.basename(projectPath);
341
+ const cmd = `CLAUDE_CONFIG_DIR=${shellEsc(configDirPath)} claude`;
342
+ const shellCmd = `cd ${shellEsc(projectPath)} && ${cmd}; exec $SHELL`;
343
+ return launchInTerminal(shellCmd, title, projectPath);
344
+ }
345
+
346
+ // ─── Utilities ────────────────────────────────────────────────────────────────
347
+
348
+ function fmtDate(d) {
349
+ return new Date(d).toLocaleString(undefined, { hour12: false });
350
+ }
351
+
352
+ function shortConfig(configDir) {
353
+ return path.basename(configDir).replace(/^\.claude-?/, '') || 'default';
354
+ }
355
+
356
+ function shortenPath(p) {
357
+ const home = os.homedir();
358
+ return p.startsWith(home) ? '~' + p.slice(home.length) : p;
359
+ }
360
+
361
+ // ─── Plain list output ────────────────────────────────────────────────────────
362
+
363
+ function ansi(code, s) { return process.stdout.isTTY ? `\x1b[${code}m${s}\x1b[0m` : s; }
364
+
365
+ function printList(sessions) {
366
+ if (!sessions.length) { console.log('No sessions found.'); return; }
367
+ let lastConfig = null, lastProject = null;
368
+ for (const s of sessions) {
369
+ if (s.configDir !== lastConfig) {
370
+ if (lastConfig) console.log();
371
+ console.log(ansi('1;36', `[${s.configDir}]`));
372
+ lastConfig = s.configDir; lastProject = null;
373
+ }
374
+ if (s.project !== lastProject) {
375
+ console.log(ansi('33', ` ${shortenPath(s.project)}`));
376
+ lastProject = s.project;
377
+ }
378
+ const label = s.title
379
+ ? ansi('1;32', s.title)
380
+ : ansi('2', `(${(s.firstMessage || 'untitled').slice(0, 60)}…)`);
381
+ console.log(` ${label} ${ansi('2', fmtDate(s.lastModified))}`);
382
+ console.log(ansi('2', ` id: ${s.sessionId}`));
383
+ }
384
+ }
385
+
386
+ // ─── TUI ──────────────────────────────────────────────────────────────────────
387
+
388
+ const ESC = '\x1b';
389
+ const clrLine = `${ESC}[2K`;
390
+ const clrScreen = `${ESC}[2J`;
391
+ const hideCursor = `${ESC}[?25l`;
392
+ const showCursor = `${ESC}[?25h`;
393
+ const altBuf = `${ESC}[?1049h`;
394
+ const normBuf = `${ESC}[?1049l`;
395
+
396
+ const mv = (r, c) => `${ESC}[${r};${c}H`;
397
+ const bold = s => `${ESC}[1m${s}${ESC}[0m`;
398
+ const dim = s => `${ESC}[2m${s}${ESC}[0m`;
399
+ const inv = s => `${ESC}[7m${s}${ESC}[0m`;
400
+ const fgc = (code, s) => `${ESC}[${code}m${s}${ESC}[0m`;
401
+ const bgc = (code, s) => `${ESC}[${code}m${s}${ESC}[0m`;
402
+ const trunc = (s, n) => n > 0 && s.length > n ? s.slice(0, n - 1) + '…' : s;
403
+ const pad = (s, n) => s.padEnd(n).slice(0, n);
404
+
405
+ // ─── Picker ───────────────────────────────────────────────────────────────────
406
+ // Shared profile → folder → input flow used by both move and new modes.
407
+ // step: 'profile' | 'folder' | 'input'
408
+
409
+ class Picker {
410
+ constructor({ headerLabel, profileLabel = 'Select profile', inputPrompt, buildProfileItems, buildFolderItems, onConfirmItem, onConfirmCustom }) {
411
+ this.step = 'profile';
412
+ this.items = [];
413
+ this.selIdx = 0;
414
+ this.scroll = 0;
415
+ this.config = null; // config dir path chosen at the profile step
416
+ this.input = '';
417
+ this.completions = [];
418
+
419
+ this._headerLabel = headerLabel;
420
+ this._profileLabel = profileLabel;
421
+ this._inputPrompt = inputPrompt;
422
+ this._buildProfileItems = buildProfileItems;
423
+ this._buildFolderItems = buildFolderItems;
424
+ this._onConfirmItem = onConfirmItem;
425
+ this._onConfirmCustom = onConfirmCustom;
426
+ }
427
+
428
+ enter() {
429
+ this.step = 'profile';
430
+ this.items = this._buildProfileItems();
431
+ this.selIdx = 0;
432
+ this.scroll = 0;
433
+ this.config = null;
434
+ this.input = '';
435
+ this.completions = [];
436
+ }
437
+
438
+ _enterFolderStep(configDirPath) {
439
+ this.config = configDirPath;
440
+ this.step = 'folder';
441
+ this.items = this._buildFolderItems(configDirPath);
442
+ this.selIdx = 0;
443
+ this.scroll = 0;
444
+ }
445
+
446
+ // Returns false when the picker should be dismissed (Esc at profile step).
447
+ handleKey(key) {
448
+ if (this.step === 'input') {
449
+ if (key === '\x1b') {
450
+ this.step = 'folder';
451
+ this.completions = [];
452
+ this.items = this._buildFolderItems(this.config);
453
+ } else if (key === '\t') {
454
+ const { completed, matches } = tabComplete(this.input || '~/');
455
+ this.input = completed;
456
+ this.completions = matches.length > 1 ? matches : [];
457
+ } else if (key === '\x7f') {
458
+ this.input = this.input.slice(0, -1);
459
+ this.completions = [];
460
+ } else if (key === '\r') {
461
+ const raw = this.input.trim();
462
+ this.completions = [];
463
+ if (raw) {
464
+ const absPath = raw.replace(/^~/, os.homedir()).replace(/\/+$/, '');
465
+ this._onConfirmCustom(absPath);
466
+ } else {
467
+ this.step = 'folder';
468
+ }
469
+ } else if (key.length === 1 && key >= ' ') {
470
+ this.input += key;
471
+ this.completions = [];
472
+ }
473
+ return true;
474
+ }
475
+
476
+ if (key === '\x1b') {
477
+ if (this.step === 'folder') {
478
+ this.step = 'profile';
479
+ this.items = this._buildProfileItems();
480
+ this.selIdx = 0;
481
+ this.scroll = 0;
482
+ } else {
483
+ return false; // cancel at profile step
484
+ }
485
+ return true;
486
+ }
487
+
488
+ if (key === '\x1b[A' || key === 'k') { this.selIdx = Math.max(0, this.selIdx - 1); return true; }
489
+ if (key === '\x1b[B' || key === 'j') { this.selIdx = Math.min(this.items.length - 1, this.selIdx + 1); return true; }
490
+
491
+ if (key === '\r') {
492
+ const item = this.items[this.selIdx];
493
+ if (!item) return true;
494
+ if (item.type === 'profile') { this._enterFolderStep(item.configDirPath); return true; }
495
+ if (item.type === 'new') { this.step = 'input'; this.input = '~/'; this.completions = []; return true; }
496
+ this._onConfirmItem(item);
497
+ }
498
+ return true;
499
+ }
500
+
501
+ render(out, W, H) {
502
+ const headerLabel = typeof this._headerLabel === 'function' ? this._headerLabel() : this._headerLabel;
503
+ out.push(mv(2, 1), clrLine, ` ${headerLabel}`);
504
+
505
+ out.push(mv(3, 1), clrLine);
506
+ const divider = dim('─'.repeat(Math.max(0, W - 2)));
507
+ if (this.step === 'profile') {
508
+ out.push(` ` + bold(this._profileLabel) + ` ` + divider);
509
+ } else {
510
+ out.push(` ` + bold('Select folder in ') + bold(fgc('96', `[${path.basename(this.config)}]`)) + ` ` + divider);
511
+ }
512
+
513
+ let listStart = 4;
514
+ if (this.step === 'input') {
515
+ out.push(mv(4, 1), clrLine, ` ${fgc('93', this._inputPrompt)} ${this.input}█`);
516
+ out.push(mv(5, 1), clrLine);
517
+ if (this.completions.length > 1) {
518
+ const cols = Math.max(1, Math.floor((W - 2) / 22));
519
+ const rows = Math.ceil(this.completions.length / cols);
520
+ for (let r = 0; r < Math.min(rows, H - 8); r++) {
521
+ out.push(mv(r + 5, 1), clrLine);
522
+ out.push(` ` + dim(this.completions.slice(r * cols, r * cols + cols).map(n => pad(n, 22)).join(' ')));
523
+ }
524
+ listStart = H;
525
+ } else {
526
+ out.push(dim(` Tab to complete path`));
527
+ listStart = 6;
528
+ }
529
+ }
530
+
531
+ const items = this.step === 'input' ? [] : this.items;
532
+ const maxVis = Math.max(0, H - listStart - 1);
533
+ if (this.selIdx < this.scroll) this.scroll = this.selIdx;
534
+ if (this.selIdx >= this.scroll + maxVis) this.scroll = this.selIdx - maxVis + 1;
535
+ if (this.scroll < 0) this.scroll = 0;
536
+
537
+ for (let r = 0; r < maxVis; r++) {
538
+ out.push(mv(listStart + r, 1), clrLine);
539
+ const item = items[this.scroll + r];
540
+ if (!item) continue;
541
+ const isSel = (this.scroll + r) === this.selIdx;
542
+ const label = item.type === 'same' ? dim(item.label)
543
+ : item.type === 'new' ? fgc('93', item.label)
544
+ : item.type === 'profile' ? bold(fgc('96', item.label))
545
+ : fgc('33', item.label);
546
+ out.push(isSel ? inv(pad(` ${item.label}`, W)) : ` ${label}`);
547
+ }
548
+ }
549
+ }
550
+
551
+ // ─── TUI ─────────────────────────────────────────────────────────────────────
552
+
553
+ class TUI {
554
+ constructor(sessions, filterPath) {
555
+ this.all = sessions;
556
+ this.filterPath = filterPath || '';
557
+ this.search = '';
558
+ this.mode = null; // null | 'search' | 'delete' | 'move' | 'new'
559
+ this.statusMsg = '';
560
+ this._statusTimer = null;
561
+ this.selIdx = 0;
562
+ this.scrollTop = 0;
563
+ this.items = [];
564
+ this.w = process.stdout.columns || 80;
565
+ this.h = process.stdout.rows || 24;
566
+
567
+ this.movePicker = this._createMovePicker();
568
+ this.newPicker = this._createNewPicker();
569
+ this.rebuild();
570
+ }
571
+
572
+ get listH() { return this.h - 2; }
573
+
574
+ _createMovePicker() {
575
+ return new Picker({
576
+ headerLabel: () => {
577
+ const s = this.selectedSession();
578
+ const label = s?.title || s?.firstMessage?.slice(0, 60) || s?.sessionId?.slice(0, 8) || '';
579
+ return `Moving: ` + (label ? fgc('92', `"${trunc(label, this.w - 12)}"`) : dim('(untitled)'));
580
+ },
581
+ profileLabel: 'Select target profile',
582
+ inputPrompt: 'New path:',
583
+ buildProfileItems: () => {
584
+ const s = this.selectedSession();
585
+ return discoverConfigDirs().map(d => ({
586
+ type: 'profile',
587
+ label: path.basename(d) + (d === s?.configDirPath ? ' (current)' : ''),
588
+ configDirPath: d,
589
+ }));
590
+ },
591
+ buildFolderItems: (configDirPath) => {
592
+ const s = this.selectedSession();
593
+ const isSameConfig = configDirPath === s?.configDirPath;
594
+ const srcDirName = s ? path.basename(path.dirname(s.jsonlPath)) : null;
595
+ const items = [];
596
+ if (!isSameConfig) {
597
+ items.push({ type: 'same', label: (s ? shortenPath(s.project) : '') + ' (keep same folder)', dirName: null });
598
+ }
599
+ listProjectFolders(configDirPath)
600
+ .filter(f => !(isSameConfig && f.projectDirName === srcDirName))
601
+ .forEach(f => items.push({ type: 'folder', label: f.displayPath, dirName: f.projectDirName }));
602
+ items.push({ type: 'new', label: '[type a new path…]', dirName: null });
603
+ return items;
604
+ },
605
+ onConfirmItem: (item) => this._doMove(item.dirName),
606
+ onConfirmCustom: (absPath) => this._doMove(encodePathToDir(absPath), absPath),
607
+ });
608
+ }
609
+
610
+ _createNewPicker() {
611
+ return new Picker({
612
+ headerLabel: fgc('92', 'New session'),
613
+ inputPrompt: 'Path:',
614
+ buildProfileItems: () => discoverConfigDirs().map(d => ({
615
+ type: 'profile', label: path.basename(d), configDirPath: d,
616
+ })),
617
+ buildFolderItems: (configDirPath) => [
618
+ ...listProjectFolders(configDirPath).map(f => ({
619
+ type: 'folder',
620
+ label: f.displayPath,
621
+ projectPath: f.displayPath.replace(/^~/, os.homedir()),
622
+ })),
623
+ { type: 'new', label: '[type a new path…]', projectPath: null },
624
+ ],
625
+ onConfirmItem: (item) => { if (item.projectPath) this._doNew(item.projectPath); },
626
+ onConfirmCustom: (absPath) => this._doNew(absPath),
627
+ });
628
+ }
629
+
630
+ // ── Item list ───────────────────────────────────────────────────────────────
631
+
632
+ rebuild() {
633
+ const q = this.search.toLowerCase();
634
+ const filtered = q
635
+ ? this.all.filter(s =>
636
+ (s.title || '').toLowerCase().includes(q) ||
637
+ (s.firstMessage || '').toLowerCase().includes(q) ||
638
+ s.project.toLowerCase().includes(q))
639
+ : this.all;
640
+
641
+ this.items = [];
642
+ let lastConfig = null;
643
+ for (const s of filtered) {
644
+ if (s.configDir !== lastConfig) {
645
+ this.items.push({ type: 'config', label: s.configDir });
646
+ lastConfig = s.configDir;
647
+ }
648
+ this.items.push({ type: 'session', session: s });
649
+ }
650
+
651
+ const total = this.sessionItems().length;
652
+ this.selIdx = Math.min(this.selIdx, Math.max(0, total - 1));
653
+ this.ensureVisible();
654
+ }
655
+
656
+ sessionItems() { return this.items.filter(i => i.type === 'session'); }
657
+ selectedSession() { return this.sessionItems()[this.selIdx]?.session ?? null; }
658
+
659
+ selItemRow() {
660
+ let si = 0;
661
+ for (let i = 0; i < this.items.length; i++) {
662
+ if (this.items[i].type === 'session') {
663
+ if (si === this.selIdx) return i;
664
+ si++;
665
+ }
666
+ }
667
+ return -1;
668
+ }
669
+
670
+ ensureVisible() {
671
+ const r = this.selItemRow();
672
+ if (r === -1) return;
673
+ if (r < this.scrollTop) this.scrollTop = r;
674
+ if (r >= this.scrollTop + this.listH) this.scrollTop = r - this.listH + 1;
675
+ if (this.scrollTop < 0) this.scrollTop = 0;
676
+ }
677
+
678
+ setStatus(msg, ms = 2500) {
679
+ this.statusMsg = msg;
680
+ clearTimeout(this._statusTimer);
681
+ this._statusTimer = setTimeout(() => { this.statusMsg = ''; this.render(); }, ms);
682
+ }
683
+
684
+ reloadAll() {
685
+ this.all = discoverConfigDirs().flatMap(d => scanConfigDir(d, this.filterPath || null));
686
+ }
687
+
688
+ _doMove(targetDirName, targetRawPath = null) {
689
+ const s = this.selectedSession();
690
+ if (!s) { this.mode = null; return; }
691
+ const sessionId = s.sessionId;
692
+ const targetConfig = this.movePicker.config;
693
+ try {
694
+ moveSession(s, targetConfig, targetDirName, targetRawPath);
695
+ this.mode = null;
696
+ this.reloadAll();
697
+ this.rebuild();
698
+ const idx = this.sessionItems().findIndex(i => i.session.sessionId === sessionId);
699
+ if (idx >= 0) { this.selIdx = idx; this.ensureVisible(); }
700
+ this.setStatus(`Moved to [${path.basename(targetConfig)}]`);
701
+ } catch (e) {
702
+ this.mode = null;
703
+ this.setStatus(`Move failed: ${e.message}`, 4000);
704
+ }
705
+ }
706
+
707
+ _doNew(projectPath) {
708
+ if (!projectPath) return;
709
+ const result = openNewSession(this.newPicker.config, projectPath);
710
+ this.mode = null;
711
+ this.setStatus(
712
+ result.ok ? `Opening new session in ${shortenPath(projectPath)}` : result.msg,
713
+ result.ok ? 2500 : 5000
714
+ );
715
+ }
716
+
717
+ // ── Render ──────────────────────────────────────────────────────────────────
718
+
719
+ render() {
720
+ const W = this.w, H = this.h;
721
+ const out = [hideCursor, altBuf, clrScreen, mv(1, 1)];
722
+
723
+ const filterInfo = this.filterPath ? ` path:${this.filterPath}` : '';
724
+ const searchInfo = this.search ? ` search:${this.search}` : '';
725
+ out.push(bgc('44', bold(fgc('97', pad(trunc(` claude-sessions${filterInfo}${searchInfo}`, W), W)))));
726
+
727
+ if (this.mode === 'move') {
728
+ this.movePicker.render(out, W, H);
729
+ } else if (this.mode === 'new') {
730
+ this.newPicker.render(out, W, H);
731
+ } else {
732
+ // Session list
733
+ const visible = this.items.slice(this.scrollTop, this.scrollTop + this.listH);
734
+ let si = this.items.slice(0, this.scrollTop).filter(i => i.type === 'session').length;
735
+
736
+ for (let r = 0; r < this.listH; r++) {
737
+ out.push(mv(r + 2, 1), clrLine);
738
+ const item = visible[r];
739
+ if (!item) continue;
740
+
741
+ if (item.type === 'config') {
742
+ out.push(bold(fgc('96', ` [${item.label}]`)));
743
+ } else {
744
+ const s = item.session;
745
+ const isSel = si === this.selIdx;
746
+ const title = s.title || (s.firstMessage ? `(${s.firstMessage})` : '(untitled)');
747
+ const project = shortenPath(s.project);
748
+ const dateStr = fmtDate(s.lastModified);
749
+ const cfgTag = this.search ? `[${shortConfig(s.configDir)}] ` : '';
750
+ const dateW = dateStr.length;
751
+ const avail = Math.max(12, W - 2 - cfgTag.length - 2 - 2 - dateW);
752
+ const titleW = Math.floor(avail * 0.5);
753
+ const projectW = avail - titleW;
754
+
755
+ if (isSel) {
756
+ const plain = ` ${cfgTag}${trunc(title, titleW).padEnd(titleW)} ${trunc(project, projectW).padEnd(projectW)} ${dateStr}`;
757
+ out.push(inv(pad(plain, W)));
758
+ } else {
759
+ out.push(
760
+ ` ${this.search ? fgc('96', cfgTag) : ''}` +
761
+ (s.title ? fgc('92', pad(trunc(title, titleW), titleW)) : dim(pad(trunc(title, titleW), titleW))) +
762
+ ` ${fgc('33', pad(trunc(project, projectW), projectW))}` +
763
+ ` ${dim(dateStr)}`
764
+ );
765
+ }
766
+ si++;
767
+ }
768
+ }
769
+ }
770
+
771
+ // Footer
772
+ out.push(mv(H, 1));
773
+ const sel = this.selectedSession();
774
+ const total = this.sessionItems().length;
775
+ let footer;
776
+
777
+ if (this.mode === 'delete') {
778
+ const name = sel?.title || sel?.firstMessage?.slice(0, 35) || 'this session';
779
+ footer = ` Delete "${trunc(name, 35)}"? y=yes n/Esc=cancel`;
780
+ } else if (this.mode === 'move') {
781
+ const step = this.movePicker.step;
782
+ footer = step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
783
+ : step === 'input' ? ` Tab=complete path Enter=confirm Esc=back`
784
+ : ` ↑↓/jk=navigate Enter=select Esc=back to profiles`;
785
+ } else if (this.mode === 'new') {
786
+ const step = this.newPicker.step;
787
+ footer = step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
788
+ : step === 'input' ? ` Tab=complete path Enter=confirm Esc=back`
789
+ : ` ↑↓/jk=navigate Enter=select folder Esc=back to profiles`;
790
+ } else if (this.mode === 'search') {
791
+ footer = ` SEARCH: ${this.search}█ Esc=cancel Enter=done`;
792
+ } else if (this.statusMsg) {
793
+ footer = ` ${this.statusMsg}`;
794
+ } else {
795
+ const ctx = sel ? ` ${dim(trunc(sel.project, W - 45))}` : '';
796
+ footer = ` ↑↓/jk=nav /=search Enter=open n=new m=move d=del q=quit [${total}]${ctx}`;
797
+ }
798
+
799
+ out.push(bgc('40', fgc('37', pad(trunc(footer, W), W))));
800
+ process.stdout.write(out.join(''));
801
+ }
802
+
803
+ // ── Input ───────────────────────────────────────────────────────────────────
804
+
805
+ handleKey(key) {
806
+ if (this.mode === 'delete') {
807
+ this.mode = null;
808
+ if (key === 'y' || key === 'Y') {
809
+ const s = this.selectedSession();
810
+ if (s) {
811
+ deleteSessionFiles(s);
812
+ this.all = this.all.filter(x => x.sessionId !== s.sessionId);
813
+ this.setStatus(`Deleted: ${s.title || s.sessionId.slice(0, 8)}`);
814
+ this.rebuild();
815
+ }
816
+ }
817
+ return true;
818
+ }
819
+
820
+ if (this.mode === 'move') {
821
+ if (this.movePicker.handleKey(key) === false) this.mode = null;
822
+ return true;
823
+ }
824
+
825
+ if (this.mode === 'new') {
826
+ if (this.newPicker.handleKey(key) === false) this.mode = null;
827
+ return true;
828
+ }
829
+
830
+ if (this.mode === 'search') {
831
+ if (key === '\x1b') { this.mode = null; this.search = ''; this.rebuild(); }
832
+ else if (key === '\r') { this.mode = null; }
833
+ else if (key === '\x7f') { this.search = this.search.slice(0, -1); this.rebuild(); }
834
+ else if (key.length === 1 && key >= ' ') { this.search += key; this.rebuild(); }
835
+ return true;
836
+ }
837
+
838
+ // Normal mode
839
+ const total = this.sessionItems().length;
840
+ const move = (delta) => {
841
+ this.selIdx = Math.max(0, Math.min(total - 1, this.selIdx + delta));
842
+ this.ensureVisible();
843
+ };
844
+
845
+ if (key === 'q' || key === '\x03') return false;
846
+
847
+ if (key === '/') { this.mode = 'search'; }
848
+ else if (key === '\x1b') { this.search = ''; this.rebuild(); }
849
+ else if (key === '\r') {
850
+ const s = this.selectedSession();
851
+ if (s) {
852
+ const result = openSession(s);
853
+ this.setStatus(
854
+ result.ok ? `Opened "${s.title || s.sessionId.slice(0, 8)}" in new tab` : result.msg,
855
+ result.ok ? 2500 : 5000
856
+ );
857
+ }
858
+ }
859
+ else if (key === 'd') { if (this.selectedSession()) this.mode = 'delete'; }
860
+ else if (key === 'n') { this.newPicker.enter(); this.mode = 'new'; }
861
+ else if (key === 'm') {
862
+ if (this.selectedSession()) { this.movePicker.enter(); this.mode = 'move'; }
863
+ else this.setStatus('No session selected.');
864
+ }
865
+ else if (key === '\x1b[A' || key === 'k') move(-1);
866
+ else if (key === '\x1b[B' || key === 'j') move(+1);
867
+ else if (key === '\x1b[5~') move(-this.listH);
868
+ else if (key === '\x1b[6~') move(+this.listH);
869
+ else if (key === 'g') { this.selIdx = 0; this.ensureVisible(); }
870
+ else if (key === 'G') { this.selIdx = Math.max(0, total - 1); this.ensureVisible(); }
871
+
872
+ return true;
873
+ }
874
+
875
+ cleanup() { process.stdout.write(showCursor + normBuf); }
876
+
877
+ start() {
878
+ process.stdin.setRawMode(true);
879
+ process.stdin.resume();
880
+ process.stdin.setEncoding('utf8');
881
+
882
+ process.stdout.on('resize', () => {
883
+ this.w = process.stdout.columns;
884
+ this.h = process.stdout.rows;
885
+ this.ensureVisible();
886
+ this.render();
887
+ });
888
+
889
+ this.render();
890
+
891
+ process.stdin.on('data', key => {
892
+ if (this.handleKey(key) === false) {
893
+ this.cleanup();
894
+ process.exit(0);
895
+ }
896
+ this.render();
897
+ });
898
+
899
+ process.on('exit', () => this.cleanup());
900
+ process.on('SIGTERM', () => { this.cleanup(); process.exit(0); });
901
+ }
902
+ }
903
+
904
+ // ─── Main ─────────────────────────────────────────────────────────────────────
905
+
906
+ function printHelp() {
907
+ const src = fs.readFileSync(__filename, 'utf8');
908
+ const match = src.match(/\/\*\*([\s\S]*?)\*\//);
909
+ if (match) console.log(match[0].replace(/\/\*\*|\*\//g, '').replace(/^ \* ?/gm, '').trim());
910
+ }
911
+
912
+ function main() {
913
+ const opts = parseArgs();
914
+ if (opts.version) { console.log(VERSION); return; }
915
+ if (opts.help) { printHelp(); return; }
916
+
917
+ const sessions = loadSessions(opts);
918
+
919
+ if (opts.json) {
920
+ const out = sessions.map(({ jsonlPath: _j, configDirPath: _c, ...rest }) => rest);
921
+ console.log(JSON.stringify(out, null, 2));
922
+ } else if (opts.list || !process.stdout.isTTY) {
923
+ printList(sessions);
924
+ } else {
925
+ new TUI(sessions, opts.filterPath).start();
926
+ }
927
+ }
928
+
929
+ if (require.main === module) {
930
+ main();
931
+ } else {
932
+ module.exports = { encodePathToDir, tabComplete, resolveProjectPath, moveSession, readSession };
933
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@vinh0809it/claude-sessions",
3
+ "version": "0.1.0",
4
+ "description": "Terminal UI for browsing, resuming, moving, and deleting Claude Code sessions",
5
+ "bin": {
6
+ "claude-sessions": "./index.js"
7
+ },
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
11
+ "keywords": [
12
+ "claude",
13
+ "claude-code",
14
+ "anthropic",
15
+ "cli",
16
+ "tui",
17
+ "terminal",
18
+ "sessions"
19
+ ],
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/vinh0809it/claude-sessions.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/vinh0809it/claude-sessions/issues"
27
+ },
28
+ "homepage": "https://github.com/vinh0809it/claude-sessions#readme",
29
+ "scripts": {
30
+ "test": "node --test test/*.js"
31
+ },
32
+ "files": [
33
+ "index.js"
34
+ ]
35
+ }