claude-search 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.
- package/README.md +249 -0
- package/bin/claude-search.js +69 -0
- package/package.json +38 -0
- package/src/search.js +492 -0
package/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# claude-search
|
|
2
|
+
|
|
3
|
+
Full-text search across all your [Claude Code](https://claude.ai/code) session history — find past conversations, extract code snippets, inspect session metadata, and jump straight back into any session.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
claude-search "redis connection error" --since "2 weeks ago"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
Claude Code stores every conversation as a `.jsonl` file under `~/.claude/projects/`. Each line is a JSON record — a user message, assistant reply, tool call, or metadata event. `claude-search` reads those files directly, no server required.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Node.js 18+
|
|
20
|
+
- Claude Code CLI installed (`claude` in your PATH)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g claude-search
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Verify it works:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
claude-search --help
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Development
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm test # run the unit test suite (Node.js 18+ built-in test runner)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Tests cover `parseSince`, `extractCodeBlocks`, `projectName`, and `loadMessages` (including corrupt-line handling). No extra dependencies required.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## User Journeys
|
|
49
|
+
|
|
50
|
+
### 1. "I remember solving this problem — where did I do it?"
|
|
51
|
+
|
|
52
|
+
You recall discussing a tricky bug but can't remember which project or session.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
claude-search "segmentation fault"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Output groups matches by session, newest first, with the project name and date:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
myapp › a1b2c3d4 · Feb 20, 2026
|
|
62
|
+
· claude --resume a1b2c3d4-...full-uuid...
|
|
63
|
+
──────────────────────────────────────────────────────────
|
|
64
|
+
User what's causing this segmentation fault in the C extension?
|
|
65
|
+
Assistant The issue is a dangling pointer in line 42 of ext.c — you're
|
|
66
|
+
freeing `buf` before the callback fires…
|
|
67
|
+
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Each session header includes the exact `claude --resume` command — copy it to jump straight back in.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
### 2. "Find code I wrote for this, not just the discussion"
|
|
75
|
+
|
|
76
|
+
You want the actual implementation, not surrounding chat.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
claude-search "rate limiter" --code-only
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Only messages whose fenced code blocks contain the query are shown, rendered with language labels:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
api-service › f3e2d1c0 · Feb 18, 2026
|
|
86
|
+
· claude --resume f3e2d1c0-...
|
|
87
|
+
──────────────────────────────────────────────────────────
|
|
88
|
+
┌─ python
|
|
89
|
+
│ class RateLimiter:
|
|
90
|
+
│ def __init__(self, max_calls, period):
|
|
91
|
+
│ self.calls = deque()
|
|
92
|
+
│ self.max_calls = max_calls
|
|
93
|
+
│ self.period = period
|
|
94
|
+
└──────────────────────────────────────
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
### 3. "Only show me recent sessions — I don't need old noise"
|
|
100
|
+
|
|
101
|
+
Your search returns 40 matches. Most are from months ago and irrelevant.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
claude-search "authentication" --since "2 weeks ago" --limit 10
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`--since` accepts natural language or ISO dates:
|
|
108
|
+
|
|
109
|
+
| Value | Meaning |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `"3 days ago"` | Last 3 days |
|
|
112
|
+
| `"1 week ago"` | Last 7 days |
|
|
113
|
+
| `"2 months ago"` | Last ~60 days |
|
|
114
|
+
| `"2024-01-15"` | On or after Jan 15 2024 |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
### 4. "I want to jump straight back into that session"
|
|
119
|
+
|
|
120
|
+
You find the session you were looking for and want to resume it immediately.
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
claude-search "docker compose" --open
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Shows search results as normal, then automatically runs `claude --resume <session-id>` on the top match, opening it in your terminal.
|
|
127
|
+
|
|
128
|
+
Or use the resume command printed under every session header:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
claude --resume a1b2c3d4-3307-4fc4-992a-42ba0ca49246
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### 5. "Give me a summary of what happened in that session"
|
|
137
|
+
|
|
138
|
+
You have a session ID (from search results or `~/.claude/projects/`) and want metadata before deciding whether to resume.
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
claude-search session a1b2c3d4-3307-4fc4-992a-42ba0ca49246
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
myapp
|
|
146
|
+
repo github.com/you/myapp
|
|
147
|
+
session a1b2c3d4-3307-4fc4-992a-42ba0ca49246
|
|
148
|
+
file ~/.claude/projects/-Users-you-myapp/a1b2c3d4-....jsonl
|
|
149
|
+
started Feb 20, 2026 at 9:12:04 AM
|
|
150
|
+
ended Feb 20, 2026 at 11:45:30 AM
|
|
151
|
+
turns 42 user · 43 assistant · 187 total records
|
|
152
|
+
resume claude --resume a1b2c3d4-3307-4fc4-992a-42ba0ca49246
|
|
153
|
+
|
|
154
|
+
── first prompt ─────────────────────────────────────────────────────
|
|
155
|
+
Help me debug the rate limiter — it's allowing twice the configured
|
|
156
|
+
requests per second under load…
|
|
157
|
+
|
|
158
|
+
── last prompt ──────────────────────────────────────────────────────
|
|
159
|
+
Great, now write tests for the token bucket implementation.
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
### 6. "Search only within a specific project"
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
claude-search "migration" --project myapp
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`--project` does a partial, case-insensitive match on the directory name Claude Code uses for that project.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
### 7. "Show me more surrounding context"
|
|
175
|
+
|
|
176
|
+
By default, 1 message before and after each match is shown. Increase it:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
claude-search "the fix" --context 3
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
### 8. "Was this AI reasoning correct? Show me its thinking"
|
|
185
|
+
|
|
186
|
+
When Claude uses extended thinking, reasoning blocks are stored in the session. Surface them:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
claude-search "O(n²)" --reasoning
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Shows up to 3 lines of the thinking block before and after the matched line, with the hit line highlighted with `▶`.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## All Options
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
claude-search [options] <query>
|
|
200
|
+
claude-search session <session-id>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Search options
|
|
204
|
+
|
|
205
|
+
| Flag | Default | Description |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| `-d, --dir <path>` | `~/.claude/projects` | Sessions directory to search |
|
|
208
|
+
| `-l, --limit <n>` | `20` | Max matches to show |
|
|
209
|
+
| `-p, --project <name>` | — | Filter by project name (partial match) |
|
|
210
|
+
| `-C, --context <n>` | `1` | Context messages around each match |
|
|
211
|
+
| `-s, --case-sensitive` | `false` | Case-sensitive search |
|
|
212
|
+
| `--since <when>` | — | Only sessions after this date |
|
|
213
|
+
| `--code-only` | `false` | Only show code blocks containing the match |
|
|
214
|
+
| `--reasoning` | `false` | Show AI reasoning/thinking around the match |
|
|
215
|
+
| `--open` | `false` | Open the top matching session in Claude Code |
|
|
216
|
+
|
|
217
|
+
### Subcommands
|
|
218
|
+
|
|
219
|
+
| Command | Description |
|
|
220
|
+
|---|---|
|
|
221
|
+
| `session <id>` | Show metadata and first/last prompts for a session |
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Output anatomy
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
myapp › a1b2c3d4 · Feb 20, 2026 [github.com/you/myapp]
|
|
229
|
+
· claude --resume a1b2c3d4-3307-4fc4-992a-42ba0ca49246
|
|
230
|
+
──────────────────────────────────────────────────────────────────────
|
|
231
|
+
User (context message before the match)
|
|
232
|
+
Assistant …matched text with the query highlighted…
|
|
233
|
+
┌─ typescript
|
|
234
|
+
│ // code block found in that message
|
|
235
|
+
└────────────────────────────────────
|
|
236
|
+
User (context message after the match)
|
|
237
|
+
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
- **Project name** — decoded from Claude's directory slug
|
|
241
|
+
- **Session ID** — first 8 chars shown in header; full UUID in the resume command
|
|
242
|
+
- **Git remote** — auto-detected if the project directory is a git repo
|
|
243
|
+
- **Resume command** — printed under every session header; paste directly into your terminal
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
MIT
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { search, parseSince, sessionDetails } from '../src/search.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_DIR = join(homedir(), '.claude', 'projects');
|
|
8
|
+
|
|
9
|
+
// ── session subcommand ──────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.command('session <id>')
|
|
13
|
+
.description('Show details for a specific session and how to resume it')
|
|
14
|
+
.option('-d, --dir <path>', 'Sessions directory', DEFAULT_DIR)
|
|
15
|
+
.action(async (id, opts) => {
|
|
16
|
+
await sessionDetails(id, { sessionsDir: opts.dir });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// ── search (default) ────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
program
|
|
22
|
+
.name('claude-search')
|
|
23
|
+
.description('Search across all your Claude Code session history')
|
|
24
|
+
.argument('[query]', 'Text to search for')
|
|
25
|
+
.option('-d, --dir <path>', 'Sessions directory to search', DEFAULT_DIR)
|
|
26
|
+
.option('-l, --limit <n>', 'Max matches to show', '20')
|
|
27
|
+
.option('-p, --project <name>', 'Filter by project name (partial match)')
|
|
28
|
+
.option('-C, --context <n>', 'Context messages around each match', '1')
|
|
29
|
+
.option('-s, --case-sensitive', 'Case-sensitive search')
|
|
30
|
+
.option('--since <when>', 'Only sessions after this date (e.g. "2 weeks ago", "2024-01-15")')
|
|
31
|
+
.option('--code-only', 'Only show code blocks containing the match')
|
|
32
|
+
.option('--reasoning', 'Show AI reasoning/thinking lines around the match')
|
|
33
|
+
.option('--open', 'Open the first matching session in Claude Code')
|
|
34
|
+
.action(async (query, opts) => {
|
|
35
|
+
if (!query) program.help();
|
|
36
|
+
|
|
37
|
+
function positiveInt(val, flag) {
|
|
38
|
+
const n = parseInt(val, 10);
|
|
39
|
+
if (isNaN(n) || n < 1) {
|
|
40
|
+
console.error(`Error: ${flag} must be a positive integer (got "${val}")`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let since = null;
|
|
47
|
+
if (opts.since) {
|
|
48
|
+
try {
|
|
49
|
+
since = parseSince(opts.since);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`Error: ${err.message}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await search(query, {
|
|
57
|
+
sessionsDir: opts.dir,
|
|
58
|
+
limit: positiveInt(opts.limit, '--limit'),
|
|
59
|
+
project: opts.project ?? null,
|
|
60
|
+
context: positiveInt(opts.context, '--context'),
|
|
61
|
+
caseSensitive: opts.caseSensitive ?? false,
|
|
62
|
+
since,
|
|
63
|
+
codeOnly: opts.codeOnly ?? false,
|
|
64
|
+
showReasoning: opts.reasoning ?? false,
|
|
65
|
+
open: opts.open ?? false,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-search",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Search across all your Claude Code session history",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-search": "bin/claude-search.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node bin/claude-search.js",
|
|
15
|
+
"test": "node --test test/search.test.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"anthropic",
|
|
21
|
+
"search",
|
|
22
|
+
"history",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/pi-netizen/claude-search.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/pi-netizen/claude-search#readme",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"chalk": "^5.3.0",
|
|
33
|
+
"commander": "^13.1.0"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/search.js
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { readFile, readdir, access } from 'fs/promises';
|
|
2
|
+
import { join, basename, dirname } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { execFile } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export function extractText(content) {
|
|
13
|
+
if (typeof content === 'string') return content;
|
|
14
|
+
if (!Array.isArray(content)) return '';
|
|
15
|
+
return content
|
|
16
|
+
.filter((b) => b.type === 'text')
|
|
17
|
+
.map((b) => b.text ?? '')
|
|
18
|
+
.join('\n')
|
|
19
|
+
.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract fenced code blocks from message content.
|
|
24
|
+
* Returns array of { lang, code } objects.
|
|
25
|
+
*/
|
|
26
|
+
export function extractCodeBlocks(content) {
|
|
27
|
+
const text = extractText(content);
|
|
28
|
+
const blocks = [];
|
|
29
|
+
const fence = /```(\w*)\n([\s\S]*?)```/g;
|
|
30
|
+
let m;
|
|
31
|
+
while ((m = fence.exec(text)) !== null) {
|
|
32
|
+
blocks.push({ lang: m[1] || 'text', code: m[2].trimEnd() });
|
|
33
|
+
}
|
|
34
|
+
return blocks;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Convert a project directory slug back to a readable name.
|
|
39
|
+
* e.g. "-Users-piyushk-toptal-maestro" → "toptal-maestro"
|
|
40
|
+
*/
|
|
41
|
+
export function projectName(sessionsDir, filePath) {
|
|
42
|
+
const rel = filePath.slice(sessionsDir.length + 1);
|
|
43
|
+
const dir = rel.split('/')[0];
|
|
44
|
+
const homePrefix = '-' + homedir().slice(1).replace(/\//g, '-') + '-';
|
|
45
|
+
return dir.startsWith(homePrefix) ? dir.slice(homePrefix.length) : dir;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Try to resolve the git remote URL for a project directory slug.
|
|
50
|
+
*
|
|
51
|
+
* Slugs are absolute paths with every '/' replaced by '-', e.g.:
|
|
52
|
+
* "-Users-piyushk-Projects-my-app" → /Users/piyushk/Projects/my-app
|
|
53
|
+
*
|
|
54
|
+
* The ambiguity: we can't tell a path separator from a literal hyphen in a
|
|
55
|
+
* directory name. Strategy: strip the known home-dir prefix, then walk the
|
|
56
|
+
* remaining slug character-by-character, checking each possible split against
|
|
57
|
+
* the real filesystem. First existing directory wins.
|
|
58
|
+
*/
|
|
59
|
+
async function resolveGitRemote(sessionsDir, filePath) {
|
|
60
|
+
const rel = filePath.slice(sessionsDir.length + 1);
|
|
61
|
+
const slug = rel.split('/')[0];
|
|
62
|
+
const home = homedir();
|
|
63
|
+
|
|
64
|
+
// The slug starts with the home dir encoded as '-Users-name-...-'
|
|
65
|
+
const homeSlug = home.slice(1).replace(/\//g, '-'); // e.g. 'Users/piyushk' → 'Users-piyushk'
|
|
66
|
+
const prefix = '-' + homeSlug + '-';
|
|
67
|
+
|
|
68
|
+
if (!slug.startsWith(prefix)) return null;
|
|
69
|
+
|
|
70
|
+
// Remainder after the home prefix: e.g. 'Projects-my-app'
|
|
71
|
+
const remainder = slug.slice(prefix.length);
|
|
72
|
+
const parts = remainder.split('-');
|
|
73
|
+
|
|
74
|
+
// Try every possible grouping of parts as path segments (greedy, depth-first).
|
|
75
|
+
// For 'Projects-my-app': try ['Projects/my-app'], ['Projects', 'my-app'], etc.
|
|
76
|
+
const candidate = await findExistingPath(home, parts);
|
|
77
|
+
if (!candidate) return null;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await access(join(candidate, '.git'));
|
|
81
|
+
const { stdout } = await execFileAsync('git', [
|
|
82
|
+
'-C', candidate, 'remote', 'get-url', 'origin',
|
|
83
|
+
], { timeout: 2000 });
|
|
84
|
+
return stdout.trim() || null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Given a base directory and an array of slug parts (split on '-'),
|
|
92
|
+
* reconstruct the real filesystem path by trying all ways to group
|
|
93
|
+
* consecutive parts into a single directory name.
|
|
94
|
+
*
|
|
95
|
+
* Returns the first path (deepest match) that exists on disk, or null.
|
|
96
|
+
*/
|
|
97
|
+
async function findExistingPath(base, parts) {
|
|
98
|
+
if (parts.length === 0) return base;
|
|
99
|
+
|
|
100
|
+
// Build progressively longer first-segment candidates
|
|
101
|
+
for (let take = 1; take <= parts.length; take++) {
|
|
102
|
+
const segment = parts.slice(0, take).join('-');
|
|
103
|
+
const candidate = join(base, segment);
|
|
104
|
+
|
|
105
|
+
let exists = false;
|
|
106
|
+
try { await access(candidate); exists = true; } catch { /* noop */ }
|
|
107
|
+
|
|
108
|
+
if (exists) {
|
|
109
|
+
// Recurse into the remaining parts
|
|
110
|
+
const deeper = await findExistingPath(candidate, parts.slice(take));
|
|
111
|
+
if (deeper !== null) return deeper;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse a human-friendly "since" string into a Date.
|
|
119
|
+
* Supports: "2 weeks ago", "3 days ago", "1 month ago", "2024-01-15"
|
|
120
|
+
*/
|
|
121
|
+
export function parseSince(since) {
|
|
122
|
+
if (!since) return null;
|
|
123
|
+
|
|
124
|
+
// ISO / locale date literal
|
|
125
|
+
const asDate = new Date(since);
|
|
126
|
+
if (!isNaN(asDate.getTime()) && since.includes('-')) return asDate;
|
|
127
|
+
|
|
128
|
+
// "N unit ago"
|
|
129
|
+
const relative = since.match(/^(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago$/i);
|
|
130
|
+
if (!relative) throw new Error(`Cannot parse --since value: "${since}"`);
|
|
131
|
+
|
|
132
|
+
const [, n, unit] = relative;
|
|
133
|
+
const now = new Date();
|
|
134
|
+
const amount = parseInt(n, 10);
|
|
135
|
+
const u = unit.toLowerCase();
|
|
136
|
+
|
|
137
|
+
if (u === 'second') now.setSeconds(now.getSeconds() - amount);
|
|
138
|
+
else if (u === 'minute') now.setMinutes(now.getMinutes() - amount);
|
|
139
|
+
else if (u === 'hour') now.setHours(now.getHours() - amount);
|
|
140
|
+
else if (u === 'day') now.setDate(now.getDate() - amount);
|
|
141
|
+
else if (u === 'week') now.setDate(now.getDate() - amount * 7);
|
|
142
|
+
else if (u === 'month') now.setMonth(now.getMonth() - amount);
|
|
143
|
+
else if (u === 'year') now.setFullYear(now.getFullYear() - amount);
|
|
144
|
+
|
|
145
|
+
return now;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Return a short snippet of `text` centred around the first occurrence
|
|
150
|
+
* of `query`, with ellipses where text was trimmed.
|
|
151
|
+
*/
|
|
152
|
+
function snippet(text, query, radius = 120) {
|
|
153
|
+
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
|
154
|
+
if (idx === -1) return text.slice(0, radius * 2);
|
|
155
|
+
const start = Math.max(0, idx - radius);
|
|
156
|
+
const end = Math.min(text.length, idx + query.length + radius);
|
|
157
|
+
return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Wrap every case-insensitive occurrence of query in yellow bold. */
|
|
161
|
+
function highlight(text, query) {
|
|
162
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
163
|
+
return text.replace(new RegExp(escaped, 'gi'), (m) => chalk.yellow.bold(m));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function formatDate(ts) {
|
|
167
|
+
if (!ts) return '';
|
|
168
|
+
return new Date(ts).toLocaleDateString('en-US', {
|
|
169
|
+
year: 'numeric', month: 'short', day: 'numeric',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── reasoning extraction ───────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Extract lines around the match from a thinking/reasoning block.
|
|
177
|
+
* Claude session records sometimes contain { type: 'thinking', thinking: '...' }
|
|
178
|
+
* content blocks. We grab `lineContext` lines before+after the query hit.
|
|
179
|
+
*/
|
|
180
|
+
function extractReasoningSnippet(content, query, lineContext = 3) {
|
|
181
|
+
if (!Array.isArray(content)) return null;
|
|
182
|
+
|
|
183
|
+
const thinkingBlock = content.find((b) => b.type === 'thinking' && b.thinking);
|
|
184
|
+
if (!thinkingBlock) return null;
|
|
185
|
+
|
|
186
|
+
const lines = thinkingBlock.thinking.split('\n');
|
|
187
|
+
const needle = query.toLowerCase();
|
|
188
|
+
const hitIdx = lines.findIndex((l) => l.toLowerCase().includes(needle));
|
|
189
|
+
if (hitIdx === -1) return null;
|
|
190
|
+
|
|
191
|
+
const from = Math.max(0, hitIdx - lineContext);
|
|
192
|
+
const to = Math.min(lines.length - 1, hitIdx + lineContext);
|
|
193
|
+
const slice = lines.slice(from, to + 1);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
lines: slice,
|
|
197
|
+
hitLine: hitIdx - from,
|
|
198
|
+
trimmedTop: from > 0,
|
|
199
|
+
trimmedBottom: to < lines.length - 1,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── file loading ───────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export async function loadMessages(filePath) {
|
|
206
|
+
const raw = await readFile(filePath, 'utf8');
|
|
207
|
+
const records = [];
|
|
208
|
+
for (const line of raw.split('\n')) {
|
|
209
|
+
const trimmed = line.trim();
|
|
210
|
+
if (!trimmed) continue;
|
|
211
|
+
try {
|
|
212
|
+
records.push(JSON.parse(trimmed));
|
|
213
|
+
} catch {
|
|
214
|
+
// skip corrupt / truncated lines (common when Claude crashes mid-write)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
records,
|
|
219
|
+
messages: records.filter((r) => r.type === 'user' || r.type === 'assistant'),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Find a session file by its ID (UUID filename without .jsonl).
|
|
225
|
+
* Searches recursively under sessionsDir.
|
|
226
|
+
*/
|
|
227
|
+
async function findSessionFile(sessionsDir, sessionId) {
|
|
228
|
+
const entries = await readdir(sessionsDir, { recursive: true });
|
|
229
|
+
const rel = entries.find((e) => e === `${sessionId}.jsonl` || e.endsWith(`/${sessionId}.jsonl`));
|
|
230
|
+
return rel ? join(sessionsDir, rel) : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── session details ────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
export async function sessionDetails(sessionId, {
|
|
236
|
+
sessionsDir = join(homedir(), '.claude', 'projects'),
|
|
237
|
+
} = {}) {
|
|
238
|
+
const filePath = await findSessionFile(sessionsDir, sessionId);
|
|
239
|
+
if (!filePath) {
|
|
240
|
+
console.error(chalk.red(`Session not found: ${sessionId}`));
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const { records, messages } = await loadMessages(filePath);
|
|
245
|
+
const proj = projectName(sessionsDir, filePath);
|
|
246
|
+
const firstTs = records[0]?.timestamp;
|
|
247
|
+
const lastTs = records[records.length - 1]?.timestamp;
|
|
248
|
+
const userMsgs = messages.filter((m) => (m.message?.role ?? m.type) === 'user');
|
|
249
|
+
const asstMsgs = messages.filter((m) => (m.message?.role ?? m.type) === 'assistant');
|
|
250
|
+
const firstPrompt = extractText(userMsgs[0]?.message?.content ?? '');
|
|
251
|
+
const lastPrompt = extractText(userMsgs[userMsgs.length - 1]?.message?.content ?? '');
|
|
252
|
+
const gitRemote = await resolveGitRemote(sessionsDir, filePath);
|
|
253
|
+
|
|
254
|
+
console.log('\n' + chalk.cyan.bold(proj));
|
|
255
|
+
if (gitRemote) console.log(chalk.dim(` repo `) + gitRemote.replace(/\.git$/, ''));
|
|
256
|
+
console.log(chalk.dim(` session `) + sessionId);
|
|
257
|
+
console.log(chalk.dim(` file `) + filePath);
|
|
258
|
+
console.log(chalk.dim(` started `) + formatDate(firstTs) + (firstTs ? chalk.dim(` at ${new Date(firstTs).toLocaleTimeString()}`) : ''));
|
|
259
|
+
console.log(chalk.dim(` ended `) + formatDate(lastTs) + (lastTs ? chalk.dim(` at ${new Date(lastTs).toLocaleTimeString()}`) : ''));
|
|
260
|
+
console.log(chalk.dim(` turns `) + `${userMsgs.length} user · ${asstMsgs.length} assistant · ${records.length} total records`);
|
|
261
|
+
console.log(chalk.dim(` resume `) + chalk.yellow(`claude --resume ${sessionId}`));
|
|
262
|
+
|
|
263
|
+
if (firstPrompt) {
|
|
264
|
+
console.log('\n' + chalk.dim('── first prompt ') + chalk.dim('─'.repeat(53)));
|
|
265
|
+
console.log(' ' + firstPrompt.slice(0, 300) + (firstPrompt.length > 300 ? '…' : ''));
|
|
266
|
+
}
|
|
267
|
+
if (lastPrompt && lastPrompt !== firstPrompt) {
|
|
268
|
+
console.log('\n' + chalk.dim('── last prompt ') + chalk.dim('─'.repeat(54)));
|
|
269
|
+
console.log(' ' + lastPrompt.slice(0, 300) + (lastPrompt.length > 300 ? '…' : ''));
|
|
270
|
+
}
|
|
271
|
+
console.log('');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── core search ────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
export async function search(query, {
|
|
277
|
+
sessionsDir = join(homedir(), '.claude', 'projects'),
|
|
278
|
+
limit = 20,
|
|
279
|
+
project = null,
|
|
280
|
+
context = 1,
|
|
281
|
+
caseSensitive = false,
|
|
282
|
+
since = null, // Date | null
|
|
283
|
+
codeOnly = false, // boolean
|
|
284
|
+
showReasoning = false, // boolean
|
|
285
|
+
open = false, // boolean — launch first match in claude
|
|
286
|
+
} = {}) {
|
|
287
|
+
let entries;
|
|
288
|
+
try {
|
|
289
|
+
entries = await readdir(sessionsDir, { recursive: true });
|
|
290
|
+
} catch {
|
|
291
|
+
console.error(`Cannot read sessions directory: ${sessionsDir}`);
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
const files = entries.filter((e) => e.endsWith('.jsonl')).map((e) => join(sessionsDir, e));
|
|
295
|
+
process.stderr.write(chalk.dim(`Scanning ${files.length} session files…\n`));
|
|
296
|
+
|
|
297
|
+
const needle = caseSensitive ? query : query.toLowerCase();
|
|
298
|
+
const matches = [];
|
|
299
|
+
|
|
300
|
+
// Cache git remotes per project dir — keyed by dir, resolved lazily once.
|
|
301
|
+
const remoteCache = new Map();
|
|
302
|
+
|
|
303
|
+
// Scan files in parallel with a concurrency cap to avoid fd exhaustion.
|
|
304
|
+
const CONCURRENCY = 20;
|
|
305
|
+
const queue = [...files];
|
|
306
|
+
|
|
307
|
+
async function worker() {
|
|
308
|
+
while (queue.length > 0) {
|
|
309
|
+
const filePath = queue.shift();
|
|
310
|
+
const proj = projectName(sessionsDir, filePath);
|
|
311
|
+
if (project && !proj.toLowerCase().includes(project.toLowerCase())) continue;
|
|
312
|
+
|
|
313
|
+
let messages, records;
|
|
314
|
+
try {
|
|
315
|
+
({ messages, records } = await loadMessages(filePath));
|
|
316
|
+
} catch {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── temporal filter ────────────────────────────────────────────────
|
|
321
|
+
const sessionTs = records[0]?.timestamp;
|
|
322
|
+
if (since && sessionTs && new Date(sessionTs) < since) continue;
|
|
323
|
+
|
|
324
|
+
// ── project scoping: resolve git remote once per project dir ───────
|
|
325
|
+
const projDir = dirname(filePath);
|
|
326
|
+
if (!remoteCache.has(projDir)) {
|
|
327
|
+
remoteCache.set(projDir, resolveGitRemote(sessionsDir, filePath));
|
|
328
|
+
}
|
|
329
|
+
const gitRemote = await remoteCache.get(projDir);
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < messages.length; i++) {
|
|
332
|
+
const msg = messages[i];
|
|
333
|
+
const content = msg.message?.content;
|
|
334
|
+
const text = extractText(content);
|
|
335
|
+
const haystack = caseSensitive ? text : text.toLowerCase();
|
|
336
|
+
|
|
337
|
+
if (!haystack.includes(needle)) continue;
|
|
338
|
+
|
|
339
|
+
// ── code-only filter ─────────────────────────────────────────────
|
|
340
|
+
const codeBlocks = extractCodeBlocks(content);
|
|
341
|
+
if (codeOnly) {
|
|
342
|
+
const codeMatches = codeBlocks.filter((b) => {
|
|
343
|
+
const h = caseSensitive ? b.code : b.code.toLowerCase();
|
|
344
|
+
return h.includes(needle);
|
|
345
|
+
});
|
|
346
|
+
if (codeMatches.length === 0) continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── reasoning snapshot ───────────────────────────────────────────
|
|
350
|
+
const reasoning = showReasoning
|
|
351
|
+
? extractReasoningSnippet(content, query)
|
|
352
|
+
: null;
|
|
353
|
+
|
|
354
|
+
matches.push({
|
|
355
|
+
filePath,
|
|
356
|
+
project: proj,
|
|
357
|
+
gitRemote,
|
|
358
|
+
sessionId: basename(filePath, '.jsonl'),
|
|
359
|
+
timestamp: msg.timestamp ?? sessionTs,
|
|
360
|
+
role: msg.message?.role ?? msg.type,
|
|
361
|
+
text,
|
|
362
|
+
codeBlocks,
|
|
363
|
+
reasoning,
|
|
364
|
+
before: messages.slice(Math.max(0, i - context), i),
|
|
365
|
+
after: messages.slice(i + 1, i + 1 + context),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
|
372
|
+
|
|
373
|
+
matches.sort((a, b) => {
|
|
374
|
+
if (!a.timestamp && !b.timestamp) return 0;
|
|
375
|
+
if (!a.timestamp) return 1;
|
|
376
|
+
if (!b.timestamp) return -1;
|
|
377
|
+
return new Date(b.timestamp) - new Date(a.timestamp);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const displayed = matches.slice(0, limit);
|
|
381
|
+
printResults(displayed, query, matches.length, { codeOnly, showReasoning });
|
|
382
|
+
|
|
383
|
+
// ── --open: launch first match in Claude Code ────────────────────────────
|
|
384
|
+
if (open && displayed.length > 0) {
|
|
385
|
+
const firstId = displayed[0].sessionId;
|
|
386
|
+
console.log(chalk.dim(`Opening session ${firstId.slice(0, 8)}… (claude --resume ${firstId})\n`));
|
|
387
|
+
try {
|
|
388
|
+
const { spawn } = await import('child_process');
|
|
389
|
+
spawn('claude', ['--resume', firstId], { stdio: 'inherit', detached: true }).unref();
|
|
390
|
+
} catch {
|
|
391
|
+
console.error(chalk.red('Could not launch claude. Make sure it is in your PATH.'));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── output formatting ──────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
function printCtxMsg(msg) {
|
|
399
|
+
const role = msg.message?.role ?? msg.type;
|
|
400
|
+
const label = role === 'assistant'
|
|
401
|
+
? chalk.dim(' Assistant ')
|
|
402
|
+
: chalk.dim(' User ');
|
|
403
|
+
const text = extractText(msg.message?.content);
|
|
404
|
+
console.log(label + chalk.dim(text.slice(0, 140) + (text.length > 140 ? '…' : '')));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function printMatchMsg(role, text, query) {
|
|
408
|
+
const label = role === 'assistant'
|
|
409
|
+
? chalk.blue(' Assistant ')
|
|
410
|
+
: chalk.green(' User ');
|
|
411
|
+
console.log(label + highlight(snippet(text, query), query));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function printCodeBlocks(blocks, query) {
|
|
415
|
+
for (const { lang, code } of blocks) {
|
|
416
|
+
const header = chalk.magenta(` ┌─ ${lang || 'code'} `);
|
|
417
|
+
console.log(header);
|
|
418
|
+
const lines = code.split('\n');
|
|
419
|
+
for (const line of lines) {
|
|
420
|
+
console.log(chalk.dim(' │ ') + highlight(line, query));
|
|
421
|
+
}
|
|
422
|
+
console.log(chalk.dim(' └' + '─'.repeat(36)));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function printReasoning(reasoning) {
|
|
427
|
+
if (!reasoning) return;
|
|
428
|
+
if (reasoning.trimmedTop) console.log(chalk.dim(' ⋮ (reasoning truncated)'));
|
|
429
|
+
for (let i = 0; i < reasoning.lines.length; i++) {
|
|
430
|
+
const line = reasoning.lines[i];
|
|
431
|
+
const isHit = i === reasoning.hitLine;
|
|
432
|
+
const prefix = isHit ? chalk.yellow(' ▶ ') : chalk.dim(' ');
|
|
433
|
+
console.log(prefix + chalk.dim(line.slice(0, 160) + (line.length > 160 ? '…' : '')));
|
|
434
|
+
}
|
|
435
|
+
if (reasoning.trimmedBottom) console.log(chalk.dim(' ⋮ (reasoning truncated)'));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function printResults(matches, query, totalFound, { codeOnly, showReasoning }) {
|
|
439
|
+
if (matches.length === 0) {
|
|
440
|
+
console.log(chalk.yellow(`\nNo matches found for "${query}"`));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const extra = totalFound > matches.length ? chalk.dim(` — showing first ${matches.length}`) : '';
|
|
445
|
+
console.log(chalk.dim(`\n${totalFound} match${totalFound === 1 ? '' : 'es'} found${extra}\n`));
|
|
446
|
+
|
|
447
|
+
let lastFile = null;
|
|
448
|
+
|
|
449
|
+
for (const m of matches) {
|
|
450
|
+
if (m.filePath !== lastFile) {
|
|
451
|
+
const date = formatDate(m.timestamp);
|
|
452
|
+
|
|
453
|
+
// ── project header with optional git remote ───────────────────────
|
|
454
|
+
const remoteTag = m.gitRemote
|
|
455
|
+
? chalk.dim(` [${m.gitRemote.replace(/^https?:\/\//, '').replace(/\.git$/, '')}]`)
|
|
456
|
+
: '';
|
|
457
|
+
const resumeHint = chalk.dim(` · claude --resume ${m.sessionId}`);
|
|
458
|
+
console.log(
|
|
459
|
+
chalk.cyan.bold(m.project) +
|
|
460
|
+
chalk.dim(` › ${m.sessionId.slice(0, 8)} · ${date}`) +
|
|
461
|
+
remoteTag
|
|
462
|
+
);
|
|
463
|
+
console.log(resumeHint);
|
|
464
|
+
console.log(chalk.dim('─'.repeat(70)));
|
|
465
|
+
lastFile = m.filePath;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
for (const ctx of m.before) printCtxMsg(ctx);
|
|
469
|
+
|
|
470
|
+
if (codeOnly) {
|
|
471
|
+
// Only print code blocks that contain the query
|
|
472
|
+
const relevant = m.codeBlocks.filter((b) => {
|
|
473
|
+
const h = b.code.toLowerCase();
|
|
474
|
+
return h.includes(query.toLowerCase());
|
|
475
|
+
});
|
|
476
|
+
printCodeBlocks(relevant, query);
|
|
477
|
+
} else {
|
|
478
|
+
printMatchMsg(m.role, m.text, query);
|
|
479
|
+
if (m.codeBlocks.length > 0) printCodeBlocks(m.codeBlocks, query);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (showReasoning && m.reasoning) {
|
|
483
|
+
console.log(chalk.dim(' · reasoning:'));
|
|
484
|
+
printReasoning(m.reasoning);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
for (const ctx of m.after) printCtxMsg(ctx);
|
|
488
|
+
console.log(chalk.dim(' ' + '╌'.repeat(34)));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
console.log('');
|
|
492
|
+
}
|