@yyyeader/claude-recall 1.0.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/LICENSE +21 -0
- package/README.md +143 -0
- package/bin/claude-recall.js +218 -0
- package/commands/recall.md +25 -0
- package/install-command.sh +13 -0
- package/package.json +49 -0
- package/src/formatter.js +71 -0
- package/src/index.js +4 -0
- package/src/parser.js +46 -0
- package/src/resolver.js +49 -0
- package/src/scanner.js +117 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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,143 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<h1 align="center">claude-recall</h1>
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>Never lose a Claude Code session again.</strong>
|
|
5
|
+
</p>
|
|
6
|
+
<p align="center">
|
|
7
|
+
Search, find, and resume any Claude Code conversation across all your projects — instantly.
|
|
8
|
+
</p>
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/claude-recall"><img src="https://img.shields.io/npm/v/claude-recall.svg" alt="npm version"></a>
|
|
11
|
+
<a href="https://github.com/yyyeader/claude-recall/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/claude-recall.svg" alt="license"></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/claude-recall"><img src="https://img.shields.io/npm/dm/claude-recall.svg" alt="downloads"></a>
|
|
13
|
+
</p>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## The Problem
|
|
19
|
+
|
|
20
|
+
You've been deep in a Claude Code session — debugging, architecting, iterating. You close the terminal, switch projects, and when you come back:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
$ claude --resume abc123
|
|
24
|
+
No conversation found with session ID: abc123
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Claude Code's `/resume` only searches the **current directory**. If you're not in the exact same folder where the session was created, it's invisible.
|
|
28
|
+
|
|
29
|
+
You know the session exists somewhere in `~/.claude/projects/`, but good luck finding it manually across dozens of encoded directories.
|
|
30
|
+
|
|
31
|
+
## The Solution
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g claude-recall
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
claude-recall debug # Find that debugging session from last week
|
|
39
|
+
claude-recall # Browse everything with fzf
|
|
40
|
+
claude-recall -j | jq # Pipe JSON to your own tools
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`claude-recall` scans **all** your Claude Code sessions, lets you search by keyword, and gives you the exact command to resume — from any directory.
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
### 1. Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install -g claude-recall
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 2. Add the shell wrapper (recommended)
|
|
54
|
+
|
|
55
|
+
Add to your `~/.zshrc` or `~/.bashrc`:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cr() {
|
|
59
|
+
local cmd
|
|
60
|
+
cmd=$(claude-recall "$@" 2>/dev/null | tail -1)
|
|
61
|
+
if [ -n "$cmd" ] && echo "$cmd" | grep -q "^cd "; then
|
|
62
|
+
eval "$cmd"
|
|
63
|
+
fi
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3. Use it
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cr # Browse all sessions with fzf
|
|
71
|
+
cr e2b # Search + select + auto cd + resume
|
|
72
|
+
cr terraform # Find that infra session from days ago
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
One command. Search, select, resume. Done.
|
|
76
|
+
|
|
77
|
+
### Claude Code `/recall` command
|
|
78
|
+
|
|
79
|
+
Want to search sessions from *inside* Claude Code?
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Install the slash command
|
|
83
|
+
claude-recall-install
|
|
84
|
+
# Or manually:
|
|
85
|
+
cp node_modules/claude-recall/commands/recall.md ~/.claude/commands/
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
You: /recall authentication
|
|
92
|
+
Claude: Found 3 sessions matching "authentication":
|
|
93
|
+
1. 2024-03-10 /Users/you/myapp — "help me add JWT auth to the API..."
|
|
94
|
+
2. ...
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## All Options
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
claude-recall [keyword] Interactive search (fzf)
|
|
101
|
+
claude-recall -l [keyword] List mode (no interaction)
|
|
102
|
+
claude-recall -j [keyword] JSON output (for scripting)
|
|
103
|
+
claude-recall -p <project> Filter by project name
|
|
104
|
+
claude-recall -n <number> Limit results (default: 50)
|
|
105
|
+
claude-recall -h Help
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## How It Works
|
|
109
|
+
|
|
110
|
+
Claude Code stores sessions as JSONL files in `~/.claude/projects/`, with directory names like:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
-Users-you-projects-my-cool-app → /Users/you/projects/my-cool-app
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The catch? `-` could be a path separator OR part of a directory name (`e2b-infra`, `my-cool-app`). Simple string replacement breaks.
|
|
117
|
+
|
|
118
|
+
`claude-recall` uses a **greedy path resolution algorithm**: it tries the longest possible directory name first, checks if it exists on disk, and falls back to shorter segments. This correctly resolves ambiguous paths like:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
-Users-you-work-e2b-infra → /Users/you/work/e2b-infra (not /work/e2b/infra)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Then it stream-parses each JSONL file, extracts user messages as summaries, and presents everything through fzf for instant fuzzy search.
|
|
125
|
+
|
|
126
|
+
## Requirements
|
|
127
|
+
|
|
128
|
+
- **Node.js** >= 18
|
|
129
|
+
- **[fzf](https://github.com/junegunn/fzf)** — optional but recommended (falls back to built-in selector)
|
|
130
|
+
|
|
131
|
+
## Contributing
|
|
132
|
+
|
|
133
|
+
Issues and PRs welcome. This is a simple tool — the best contributions are bug fixes, platform compatibility improvements, and better session parsing.
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
<p align="center">
|
|
142
|
+
<sub>Built because <code>/resume</code> kept saying "No conversation found" one too many times.</sub>
|
|
143
|
+
</p>
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { scanSessions } from "../src/scanner.js";
|
|
7
|
+
import { formatTable, formatJSON, formatFzf } from "../src/formatter.js";
|
|
8
|
+
|
|
9
|
+
// Parse CLI arguments
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const flags = {
|
|
12
|
+
list: false,
|
|
13
|
+
json: false,
|
|
14
|
+
limit: 50,
|
|
15
|
+
project: null,
|
|
16
|
+
help: false,
|
|
17
|
+
keyword: [],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
switch (args[i]) {
|
|
22
|
+
case "--list":
|
|
23
|
+
case "-l":
|
|
24
|
+
flags.list = true;
|
|
25
|
+
break;
|
|
26
|
+
case "--json":
|
|
27
|
+
case "-j":
|
|
28
|
+
flags.json = true;
|
|
29
|
+
break;
|
|
30
|
+
case "--limit":
|
|
31
|
+
case "-n":
|
|
32
|
+
flags.limit = parseInt(args[++i], 10) || 50;
|
|
33
|
+
break;
|
|
34
|
+
case "--project":
|
|
35
|
+
case "-p":
|
|
36
|
+
flags.project = args[++i];
|
|
37
|
+
break;
|
|
38
|
+
case "--help":
|
|
39
|
+
case "-h":
|
|
40
|
+
flags.help = true;
|
|
41
|
+
break;
|
|
42
|
+
default:
|
|
43
|
+
if (!args[i].startsWith("-")) {
|
|
44
|
+
flags.keyword.push(args[i]);
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (flags.help) {
|
|
51
|
+
console.log(`claude-recall - Search and resume Claude Code sessions
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
claude-recall [keyword] Interactive search with fzf
|
|
55
|
+
claude-recall -l [keyword] List sessions (no interaction)
|
|
56
|
+
claude-recall -j [keyword] Output as JSON
|
|
57
|
+
claude-recall -p <project> Filter by project name
|
|
58
|
+
claude-recall -n <number> Limit results (default: 50)
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
-l, --list List mode (no fzf)
|
|
62
|
+
-j, --json JSON output
|
|
63
|
+
-p, --project Filter by project name
|
|
64
|
+
-n, --limit Max number of sessions to scan
|
|
65
|
+
-h, --help Show this help
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
claude-recall Browse all recent sessions
|
|
69
|
+
claude-recall e2b Search sessions mentioning "e2b"
|
|
70
|
+
claude-recall -l claudia List sessions about "claudia"
|
|
71
|
+
claude-recall -j --limit 10 Export 10 most recent sessions as JSON`);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const keyword = flags.keyword.join(" ") || undefined;
|
|
76
|
+
|
|
77
|
+
// Scan sessions
|
|
78
|
+
const sessions = await scanSessions({
|
|
79
|
+
keyword,
|
|
80
|
+
project: flags.project,
|
|
81
|
+
limit: flags.limit,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (sessions.length === 0) {
|
|
85
|
+
console.error(keyword ? `No sessions matching "${keyword}"` : "No sessions found");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// JSON mode
|
|
90
|
+
if (flags.json) {
|
|
91
|
+
console.log(formatJSON(sessions));
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// List mode
|
|
96
|
+
if (flags.list) {
|
|
97
|
+
console.log(formatTable(sessions));
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Interactive mode: try fzf, fallback to built-in selector
|
|
102
|
+
const selected = await selectSession(sessions, keyword);
|
|
103
|
+
if (!selected) process.exit(0);
|
|
104
|
+
|
|
105
|
+
console.log(`\n\x1b[36m📂 ${selected.workDir}\x1b[0m`);
|
|
106
|
+
console.log(`\x1b[2m🔗 ${selected.sessionId}\x1b[0m\n`);
|
|
107
|
+
|
|
108
|
+
if (!existsSync(selected.workDir)) {
|
|
109
|
+
console.error(`⚠️ Directory does not exist: ${selected.workDir}`);
|
|
110
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
111
|
+
const answer = await new Promise((resolve) => {
|
|
112
|
+
rl.question("Still try to resume? (y/N) ", resolve);
|
|
113
|
+
});
|
|
114
|
+
rl.close();
|
|
115
|
+
if (answer.toLowerCase() !== "y") process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Output the resume command — user's shell will execute it
|
|
119
|
+
// When used with shell function wrapper, this enables cd + resume
|
|
120
|
+
console.log(`cd "${selected.workDir}" && claude --resume "${selected.sessionId}"`);
|
|
121
|
+
|
|
122
|
+
// ---
|
|
123
|
+
|
|
124
|
+
async function selectSession(sessions, initialQuery) {
|
|
125
|
+
// Try fzf first
|
|
126
|
+
if (hasFzf()) {
|
|
127
|
+
return selectWithFzf(sessions, initialQuery);
|
|
128
|
+
}
|
|
129
|
+
return selectWithBuiltin(sessions);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function hasFzf() {
|
|
133
|
+
try {
|
|
134
|
+
execFileSync("which", ["fzf"], { stdio: "ignore" });
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function selectWithFzf(sessions, initialQuery) {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
const input = sessions
|
|
144
|
+
.map((s, i) => {
|
|
145
|
+
const time = formatTimeShort(s.modifiedAt);
|
|
146
|
+
const dir = s.workDir.length > 42 ? "..." + s.workDir.slice(-39) : s.workDir;
|
|
147
|
+
const summary = (s.summary || "").slice(0, 80);
|
|
148
|
+
return `${i + 1}│${time} │ ${dir.padEnd(42)} │ ${summary}`;
|
|
149
|
+
})
|
|
150
|
+
.join("\n");
|
|
151
|
+
|
|
152
|
+
const fzfArgs = [
|
|
153
|
+
"--height=80%",
|
|
154
|
+
"--border",
|
|
155
|
+
"--header= Time │ Directory │ Summary",
|
|
156
|
+
"--no-sort",
|
|
157
|
+
"--ansi",
|
|
158
|
+
"--delimiter=│",
|
|
159
|
+
"--with-nth=2..",
|
|
160
|
+
];
|
|
161
|
+
if (initialQuery) {
|
|
162
|
+
fzfArgs.push(`--query=${initialQuery}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const fzf = spawn("fzf", fzfArgs, {
|
|
166
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
let output = "";
|
|
170
|
+
fzf.stdout.on("data", (d) => (output += d.toString()));
|
|
171
|
+
|
|
172
|
+
fzf.on("close", (code) => {
|
|
173
|
+
if (code !== 0 || !output.trim()) {
|
|
174
|
+
resolve(null);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const idx = parseInt(output.trim().split("│")[0], 10) - 1;
|
|
178
|
+
resolve(sessions[idx] || null);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
fzf.stdin.write(input);
|
|
182
|
+
fzf.stdin.end();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function selectWithBuiltin(sessions) {
|
|
187
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
188
|
+
|
|
189
|
+
console.error("\nSelect a session:\n");
|
|
190
|
+
const displayed = sessions.slice(0, 20);
|
|
191
|
+
displayed.forEach((s, i) => {
|
|
192
|
+
const time = formatTimeShort(s.modifiedAt);
|
|
193
|
+
const summary = (s.summary || "").slice(0, 60);
|
|
194
|
+
console.error(` \x1b[33m${String(i + 1).padStart(3)}\x1b[0m ${time} ${summary}`);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (sessions.length > 20) {
|
|
198
|
+
console.error(`\n ... and ${sessions.length - 20} more (use fzf for full list)`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const answer = await new Promise((resolve) => {
|
|
202
|
+
rl.question("\nEnter number (or q to quit): ", resolve);
|
|
203
|
+
});
|
|
204
|
+
rl.close();
|
|
205
|
+
|
|
206
|
+
if (answer === "q" || answer === "") return null;
|
|
207
|
+
const idx = parseInt(answer, 10) - 1;
|
|
208
|
+
return displayed[idx] || null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function formatTimeShort(date) {
|
|
212
|
+
const y = date.getFullYear();
|
|
213
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
214
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
215
|
+
const h = String(date.getHours()).padStart(2, "0");
|
|
216
|
+
const min = String(date.getMinutes()).padStart(2, "0");
|
|
217
|
+
return `${y}-${m}-${d} ${h}:${min}`;
|
|
218
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Search and resume Claude Code sessions across all projects
|
|
3
|
+
allowed-tools: Bash(claude-recall:*), Bash(node:*)
|
|
4
|
+
argument-hint: [keyword]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Search Claude Code sessions across all projects. The user wants to find and resume a previous session.
|
|
8
|
+
|
|
9
|
+
Run the search command:
|
|
10
|
+
|
|
11
|
+
!`claude-recall --json $ARGUMENTS 2>/dev/null | head -100`
|
|
12
|
+
|
|
13
|
+
Present the results to the user as a clear numbered table with these columns:
|
|
14
|
+
- # (index)
|
|
15
|
+
- Time (when the session was last modified)
|
|
16
|
+
- Directory (the project directory)
|
|
17
|
+
- Summary (first user messages from the session)
|
|
18
|
+
|
|
19
|
+
Ask the user which session they want to resume. Once they choose, tell them the exact command to run:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
cd <workDir> && claude --resume <sessionId>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If no results are found, let the user know and suggest they try a different keyword.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Install the /recall command for Claude Code
|
|
3
|
+
|
|
4
|
+
set -e
|
|
5
|
+
|
|
6
|
+
COMMANDS_DIR="$HOME/.claude/commands"
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
|
|
9
|
+
mkdir -p "$COMMANDS_DIR"
|
|
10
|
+
cp "$SCRIPT_DIR/commands/recall.md" "$COMMANDS_DIR/recall.md"
|
|
11
|
+
|
|
12
|
+
echo "Installed /recall command to $COMMANDS_DIR/recall.md"
|
|
13
|
+
echo "Use /recall [keyword] in Claude Code to search sessions."
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yyyeader/claude-recall",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Search and resume Claude Code sessions across all projects",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-recall": "bin/claude-recall.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node bin/claude-recall.js --list"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"claude",
|
|
15
|
+
"claude-code",
|
|
16
|
+
"claude-code-session",
|
|
17
|
+
"session",
|
|
18
|
+
"search",
|
|
19
|
+
"resume",
|
|
20
|
+
"cli",
|
|
21
|
+
"ai",
|
|
22
|
+
"anthropic",
|
|
23
|
+
"developer-tools",
|
|
24
|
+
"productivity",
|
|
25
|
+
"fzf",
|
|
26
|
+
"terminal"
|
|
27
|
+
],
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/yyyeader/claude-recall.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/yyyeader/claude-recall#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/yyyeader/claude-recall/issues"
|
|
35
|
+
},
|
|
36
|
+
"author": "",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"bin/",
|
|
43
|
+
"src/",
|
|
44
|
+
"commands/",
|
|
45
|
+
"install-command.sh",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
]
|
|
49
|
+
}
|
package/src/formatter.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format session data for terminal or JSON output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const RESET = "\x1b[0m";
|
|
6
|
+
const DIM = "\x1b[2m";
|
|
7
|
+
const CYAN = "\x1b[36m";
|
|
8
|
+
const GREEN = "\x1b[32m";
|
|
9
|
+
const YELLOW = "\x1b[33m";
|
|
10
|
+
|
|
11
|
+
function truncate(str, maxLen) {
|
|
12
|
+
if (str.length <= maxLen) return str;
|
|
13
|
+
return str.slice(0, maxLen - 3) + "...";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatTime(date) {
|
|
17
|
+
const y = date.getFullYear();
|
|
18
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
19
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
20
|
+
const h = String(date.getHours()).padStart(2, "0");
|
|
21
|
+
const min = String(date.getMinutes()).padStart(2, "0");
|
|
22
|
+
return `${y}-${m}-${d} ${h}:${min}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Format sessions as a colored terminal table.
|
|
27
|
+
*/
|
|
28
|
+
export function formatTable(sessions) {
|
|
29
|
+
if (sessions.length === 0) return "No sessions found.";
|
|
30
|
+
|
|
31
|
+
const lines = sessions.map((s) => {
|
|
32
|
+
const time = formatTime(s.modifiedAt);
|
|
33
|
+
const dir = truncate(s.workDir, 45);
|
|
34
|
+
const summary = truncate(s.summary || "", 70);
|
|
35
|
+
return `${DIM}${time}${RESET} ${CYAN}${dir.padEnd(45)}${RESET} ${summary}`;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const header = `${YELLOW}${"Time".padEnd(18)}${"Directory".padEnd(47)}Summary${RESET}`;
|
|
39
|
+
return [header, ...lines].join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Format sessions as JSON.
|
|
44
|
+
*/
|
|
45
|
+
export function formatJSON(sessions) {
|
|
46
|
+
return JSON.stringify(
|
|
47
|
+
sessions.map((s) => ({
|
|
48
|
+
sessionId: s.sessionId,
|
|
49
|
+
workDir: s.workDir,
|
|
50
|
+
modifiedAt: s.modifiedAt.toISOString(),
|
|
51
|
+
summary: s.summary,
|
|
52
|
+
})),
|
|
53
|
+
null,
|
|
54
|
+
2
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Format sessions for fzf input (tab-separated, one per line).
|
|
60
|
+
* Format: index\ttime\tworkDir\tsessionId\tsummary
|
|
61
|
+
*/
|
|
62
|
+
export function formatFzf(sessions) {
|
|
63
|
+
return sessions
|
|
64
|
+
.map((s, i) => {
|
|
65
|
+
const time = formatTime(s.modifiedAt);
|
|
66
|
+
const dir = truncate(s.workDir, 45);
|
|
67
|
+
const summary = truncate(s.summary || "", 80);
|
|
68
|
+
return `${i + 1}\t${time}\t${dir}\t${s.sessionId}\t${summary}`;
|
|
69
|
+
})
|
|
70
|
+
.join("\n");
|
|
71
|
+
}
|
package/src/index.js
ADDED
package/src/parser.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract a summary from a Claude Code session JSONL file.
|
|
6
|
+
* Reads the first few user messages and concatenates their text content.
|
|
7
|
+
*/
|
|
8
|
+
export async function extractSummary(filePath, maxMessages = 3) {
|
|
9
|
+
const texts = [];
|
|
10
|
+
|
|
11
|
+
const rl = createInterface({
|
|
12
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
13
|
+
crlfDelay: Infinity,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
for await (const line of rl) {
|
|
17
|
+
if (texts.length >= maxMessages) break;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const entry = JSON.parse(line);
|
|
21
|
+
if (entry.type !== "user") continue;
|
|
22
|
+
|
|
23
|
+
const content = entry.message?.content;
|
|
24
|
+
if (!content) continue;
|
|
25
|
+
|
|
26
|
+
let text;
|
|
27
|
+
if (typeof content === "string") {
|
|
28
|
+
text = content;
|
|
29
|
+
} else if (Array.isArray(content)) {
|
|
30
|
+
// Extract text blocks, skip tool_result blocks
|
|
31
|
+
text = content
|
|
32
|
+
.filter((b) => b.type === "text" && b.text)
|
|
33
|
+
.map((b) => b.text)
|
|
34
|
+
.join(" ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (text && text.trim()) {
|
|
38
|
+
texts.push(text.trim());
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Skip malformed lines
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return texts.join(" ").replace(/\s+/g, " ").slice(0, 300);
|
|
46
|
+
}
|
package/src/resolver.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve an encoded Claude Code project directory name back to a real filesystem path.
|
|
5
|
+
*
|
|
6
|
+
* Claude Code encodes project paths by replacing `/` with `-`:
|
|
7
|
+
* /Users/fuxianda/VMWokrSpace/e2b-infra → -Users-fuxianda-VMWokrSpace-e2b-infra
|
|
8
|
+
*
|
|
9
|
+
* The challenge is that directory names themselves may contain hyphens,
|
|
10
|
+
* so we use a greedy algorithm: try the longest possible segment first,
|
|
11
|
+
* check if it exists on disk, and fall back to shorter segments.
|
|
12
|
+
*/
|
|
13
|
+
export function resolvePath(encoded) {
|
|
14
|
+
// Strip leading dash
|
|
15
|
+
if (encoded.startsWith("-")) {
|
|
16
|
+
encoded = encoded.slice(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const parts = encoded.split("-");
|
|
20
|
+
let current = "/";
|
|
21
|
+
let i = 0;
|
|
22
|
+
|
|
23
|
+
while (i < parts.length) {
|
|
24
|
+
let found = false;
|
|
25
|
+
const remaining = parts.length - i;
|
|
26
|
+
|
|
27
|
+
// Greedy: try longest candidate first
|
|
28
|
+
for (let tryLen = remaining; tryLen >= 1; tryLen--) {
|
|
29
|
+
const candidate = parts.slice(i, i + tryLen).join("-");
|
|
30
|
+
const testPath = current + candidate;
|
|
31
|
+
|
|
32
|
+
if (existsSync(testPath)) {
|
|
33
|
+
current = testPath + "/";
|
|
34
|
+
i += tryLen;
|
|
35
|
+
found = true;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!found) {
|
|
41
|
+
// No match — use single segment as path component
|
|
42
|
+
current += parts[i] + "/";
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Remove trailing slash
|
|
48
|
+
return current.replace(/\/+$/, "");
|
|
49
|
+
}
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { resolvePath } from "./resolver.js";
|
|
5
|
+
import { extractSummary } from "./parser.js";
|
|
6
|
+
|
|
7
|
+
const CLAUDE_DIR = join(homedir(), ".claude", "projects");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Scan all Claude Code sessions across all projects.
|
|
11
|
+
* Returns an array of session objects sorted by modification time (newest first).
|
|
12
|
+
*/
|
|
13
|
+
export async function scanSessions({ keyword, project, limit } = {}) {
|
|
14
|
+
let projectDirs;
|
|
15
|
+
try {
|
|
16
|
+
projectDirs = readdirSync(CLAUDE_DIR, { withFileTypes: true }).filter(
|
|
17
|
+
(d) => d.isDirectory()
|
|
18
|
+
);
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sessions = [];
|
|
24
|
+
|
|
25
|
+
for (const dir of projectDirs) {
|
|
26
|
+
const projectEncoded = dir.name;
|
|
27
|
+
const projectPath = join(CLAUDE_DIR, projectEncoded);
|
|
28
|
+
|
|
29
|
+
// List JSONL files directly under the project dir (skip subagents)
|
|
30
|
+
let files;
|
|
31
|
+
try {
|
|
32
|
+
files = readdirSync(projectPath)
|
|
33
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
34
|
+
.map((f) => join(projectPath, f));
|
|
35
|
+
} catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Also check one level deeper for session directories (session-id/session.jsonl pattern)
|
|
40
|
+
try {
|
|
41
|
+
const subdirs = readdirSync(projectPath, { withFileTypes: true }).filter(
|
|
42
|
+
(d) => d.isDirectory() && d.name !== "subagents"
|
|
43
|
+
);
|
|
44
|
+
for (const subdir of subdirs) {
|
|
45
|
+
try {
|
|
46
|
+
const subFiles = readdirSync(join(projectPath, subdir.name))
|
|
47
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
48
|
+
.map((f) => join(projectPath, subdir.name, f));
|
|
49
|
+
files.push(...subFiles);
|
|
50
|
+
} catch {
|
|
51
|
+
// skip
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// skip
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const workDir = resolvePath(projectEncoded);
|
|
59
|
+
|
|
60
|
+
// Filter by project name if specified
|
|
61
|
+
if (project) {
|
|
62
|
+
const projectLower = project.toLowerCase();
|
|
63
|
+
if (
|
|
64
|
+
!projectEncoded.toLowerCase().includes(projectLower) &&
|
|
65
|
+
!workDir.toLowerCase().includes(projectLower)
|
|
66
|
+
) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
try {
|
|
73
|
+
const stat = statSync(file);
|
|
74
|
+
const sessionId = basename(file, ".jsonl");
|
|
75
|
+
|
|
76
|
+
// Skip subagent files
|
|
77
|
+
if (file.includes("/subagents/")) continue;
|
|
78
|
+
|
|
79
|
+
sessions.push({
|
|
80
|
+
sessionId,
|
|
81
|
+
projectEncoded,
|
|
82
|
+
workDir,
|
|
83
|
+
filePath: file,
|
|
84
|
+
modifiedAt: stat.mtime,
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
// skip
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Sort by modification time, newest first
|
|
93
|
+
sessions.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
|
94
|
+
|
|
95
|
+
// Extract summaries (in parallel, batched)
|
|
96
|
+
const batch = limit ? sessions.slice(0, limit) : sessions;
|
|
97
|
+
await Promise.all(
|
|
98
|
+
batch.map(async (s) => {
|
|
99
|
+
s.summary = await extractSummary(s.filePath);
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Filter by keyword if specified
|
|
104
|
+
let result = batch;
|
|
105
|
+
if (keyword) {
|
|
106
|
+
const kw = keyword.toLowerCase();
|
|
107
|
+
result = batch.filter(
|
|
108
|
+
(s) =>
|
|
109
|
+
s.summary.toLowerCase().includes(kw) ||
|
|
110
|
+
s.workDir.toLowerCase().includes(kw) ||
|
|
111
|
+
s.projectEncoded.toLowerCase().includes(kw) ||
|
|
112
|
+
s.sessionId.toLowerCase().includes(kw)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|