cc-query 0.2.0 → 0.3.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 +67 -0
- package/package.json +1 -1
- package/src/query-session.js +84 -31
- package/src/repl.js +15 -1
- package/src/session-loader.js +14 -4
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# cc-query
|
|
2
|
+
|
|
3
|
+
SQL REPL for querying Claude Code session data using DuckDB.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g cc-query
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Node.js 24+.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Query all projects
|
|
17
|
+
cc-query
|
|
18
|
+
|
|
19
|
+
# Query a specific project
|
|
20
|
+
cc-query ~/code/my-project
|
|
21
|
+
|
|
22
|
+
# Filter by session ID prefix
|
|
23
|
+
cc-query -s abc123 .
|
|
24
|
+
|
|
25
|
+
# Pipe queries (like psql)
|
|
26
|
+
echo "SELECT count(*) FROM messages;" | cc-query .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Available Views
|
|
30
|
+
|
|
31
|
+
- `messages` - All messages with parsed fields
|
|
32
|
+
- `user_messages` - User messages only
|
|
33
|
+
- `assistant_messages` - Assistant responses only
|
|
34
|
+
- `tool_calls` - Tool invocations from assistant messages
|
|
35
|
+
- `raw_messages` - Unparsed JSONL data
|
|
36
|
+
|
|
37
|
+
## REPL Commands
|
|
38
|
+
|
|
39
|
+
- `.help` - Show tables and example queries
|
|
40
|
+
- `.schema` - Show table schema
|
|
41
|
+
- `.quit` - Exit
|
|
42
|
+
|
|
43
|
+
## Skill (experimental)
|
|
44
|
+
|
|
45
|
+
This [example skill](examples/skills/reflect/SKILL.md) gives claude the ability and slash command `/reflect` to work with claude session history.
|
|
46
|
+
|
|
47
|
+
Why not a plugin? If you copy the skill you can reflect on it to adapt to your own usage.
|
|
48
|
+
|
|
49
|
+
For example you can ask questions like:
|
|
50
|
+
- Across all projects what bash commands return the most errors?
|
|
51
|
+
- Let's analyze the last session and identify how we might improve the claude.md file
|
|
52
|
+
- Gimme a summary of what we worked on this past week
|
|
53
|
+
- Let's go though our whole session history and identify repeated patterns that we could extract into skills
|
|
54
|
+
- Let's look at our use of cc-query tool calls to see how we might improve the reflect skill
|
|
55
|
+
|
|
56
|
+
### Test drive
|
|
57
|
+
|
|
58
|
+
To test drive this skill do something like this:
|
|
59
|
+
|
|
60
|
+
1. `npm i -g cc-query`
|
|
61
|
+
2. Clone this repo or otherwise fetch the `examples/skills/reflect` dir
|
|
62
|
+
3. `mkdir -p ~/.claude/skills && cp -R examples/skills/reflect ~/.claude/skills/`
|
|
63
|
+
4. run claude and use `/reflect [whatever you want]`
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
package/package.json
CHANGED
package/src/query-session.js
CHANGED
|
@@ -8,6 +8,59 @@ import { getSessionFiles } from "./session-loader.js";
|
|
|
8
8
|
* @property {number} projectCount
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Convert a result value to string for display
|
|
13
|
+
* @param {any} val
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
function valueToString(val) {
|
|
17
|
+
if (val === null || val === undefined) return "NULL";
|
|
18
|
+
if (typeof val === "bigint") return val.toString();
|
|
19
|
+
if (typeof val === "object") {
|
|
20
|
+
// Handle DuckDB timestamp objects (returned as {micros: bigint})
|
|
21
|
+
if ("micros" in val) {
|
|
22
|
+
const ms = Number(val.micros) / 1000;
|
|
23
|
+
return new Date(ms).toISOString().replace("T", " ").replace("Z", "");
|
|
24
|
+
}
|
|
25
|
+
// Handle DuckDB UUID objects (returned as {hugeint: string})
|
|
26
|
+
if ("hugeint" in val) {
|
|
27
|
+
// Convert 128-bit signed decimal to UUID hex string
|
|
28
|
+
// DuckDB XORs the high bit for sorting, so flip it back
|
|
29
|
+
let n = BigInt(val.hugeint);
|
|
30
|
+
if (n < 0n) n += 1n << 128n; // Convert from signed to unsigned
|
|
31
|
+
n ^= 1n << 127n; // Flip high bit (undo DuckDB's sort optimization)
|
|
32
|
+
const hex = n.toString(16).padStart(32, "0");
|
|
33
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
34
|
+
}
|
|
35
|
+
return JSON.stringify(val, (_, v) =>
|
|
36
|
+
typeof v === "bigint" ? v.toString() : v,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return String(val);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Format query results as TSV with header row
|
|
44
|
+
* @param {import("@duckdb/node-api").DuckDBResultReader} result
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function formatResultsTsv(result) {
|
|
48
|
+
const columnCount = result.columnCount;
|
|
49
|
+
if (columnCount === 0) return "";
|
|
50
|
+
|
|
51
|
+
const columnNames = [];
|
|
52
|
+
for (let i = 0; i < columnCount; i++) {
|
|
53
|
+
columnNames.push(result.columnName(i));
|
|
54
|
+
}
|
|
55
|
+
const rows = result.getRows();
|
|
56
|
+
|
|
57
|
+
const lines = [columnNames.join("\t")];
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
lines.push(row.map(valueToString).join("\t"));
|
|
60
|
+
}
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
11
64
|
/**
|
|
12
65
|
* Format query results as a table string
|
|
13
66
|
* @param {import("@duckdb/node-api").DuckDBResultReader} result
|
|
@@ -29,33 +82,7 @@ function formatResults(result) {
|
|
|
29
82
|
}
|
|
30
83
|
|
|
31
84
|
// Convert all values to strings and calculate column widths
|
|
32
|
-
const stringRows = rows.map((row) =>
|
|
33
|
-
row.map((val) => {
|
|
34
|
-
if (val === null || val === undefined) return "NULL";
|
|
35
|
-
if (typeof val === "bigint") return val.toString();
|
|
36
|
-
if (typeof val === "object") {
|
|
37
|
-
// Handle DuckDB timestamp objects (returned as {micros: bigint})
|
|
38
|
-
if ("micros" in val) {
|
|
39
|
-
const ms = Number(val.micros) / 1000;
|
|
40
|
-
return new Date(ms).toISOString().replace("T", " ").replace("Z", "");
|
|
41
|
-
}
|
|
42
|
-
// Handle DuckDB UUID objects (returned as {hugeint: string})
|
|
43
|
-
if ("hugeint" in val) {
|
|
44
|
-
// Convert 128-bit signed decimal to UUID hex string
|
|
45
|
-
// DuckDB XORs the high bit for sorting, so flip it back
|
|
46
|
-
let n = BigInt(val.hugeint);
|
|
47
|
-
if (n < 0n) n += 1n << 128n; // Convert from signed to unsigned
|
|
48
|
-
n ^= 1n << 127n; // Flip high bit (undo DuckDB's sort optimization)
|
|
49
|
-
const hex = n.toString(16).padStart(32, "0");
|
|
50
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
51
|
-
}
|
|
52
|
-
return JSON.stringify(val, (_, v) =>
|
|
53
|
-
typeof v === "bigint" ? v.toString() : v,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
return String(val);
|
|
57
|
-
}),
|
|
58
|
-
);
|
|
85
|
+
const stringRows = rows.map((row) => row.map(valueToString));
|
|
59
86
|
|
|
60
87
|
const widths = columnNames.map((name, i) => {
|
|
61
88
|
const maxDataWidth = Math.max(
|
|
@@ -91,13 +118,13 @@ function formatResults(result) {
|
|
|
91
118
|
export class QuerySession {
|
|
92
119
|
/** @type {import("@duckdb/node-api").DuckDBConnection | undefined} */
|
|
93
120
|
#connection;
|
|
94
|
-
/** @type {string} */
|
|
121
|
+
/** @type {string | string[]} */
|
|
95
122
|
#filePattern;
|
|
96
123
|
/** @type {QuerySessionInfo} */
|
|
97
124
|
#info;
|
|
98
125
|
|
|
99
126
|
/**
|
|
100
|
-
* @param {string} filePattern - Glob pattern for JSONL files
|
|
127
|
+
* @param {string | string[]} filePattern - Glob pattern(s) for JSONL files
|
|
101
128
|
* @param {QuerySessionInfo} info - Session counts
|
|
102
129
|
*/
|
|
103
130
|
constructor(filePattern, info) {
|
|
@@ -105,6 +132,19 @@ export class QuerySession {
|
|
|
105
132
|
this.#info = info;
|
|
106
133
|
}
|
|
107
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Format file pattern for use in DuckDB SQL
|
|
137
|
+
* @returns {string} SQL expression for the file pattern
|
|
138
|
+
*/
|
|
139
|
+
#formatFilePatternForSql() {
|
|
140
|
+
if (Array.isArray(this.#filePattern)) {
|
|
141
|
+
// DuckDB accepts a list of patterns: ['pattern1', 'pattern2']
|
|
142
|
+
const patterns = this.#filePattern.map((p) => `'${p}'`).join(", ");
|
|
143
|
+
return `[${patterns}]`;
|
|
144
|
+
}
|
|
145
|
+
return `'${this.#filePattern}'`;
|
|
146
|
+
}
|
|
147
|
+
|
|
108
148
|
/**
|
|
109
149
|
* Create a QuerySession from a project path
|
|
110
150
|
* @param {string | null} projectDir - Claude projects dir, or null for all
|
|
@@ -158,6 +198,19 @@ export class QuerySession {
|
|
|
158
198
|
return formatResults(result);
|
|
159
199
|
}
|
|
160
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Execute a SQL query and return TSV formatted string with header
|
|
203
|
+
* @param {string} sql
|
|
204
|
+
* @returns {Promise<string>} Query result as TSV
|
|
205
|
+
*/
|
|
206
|
+
async queryTsv(sql) {
|
|
207
|
+
if (!this.#connection) {
|
|
208
|
+
throw new Error("Session not initialized - use QuerySession.create()");
|
|
209
|
+
}
|
|
210
|
+
const result = await this.#connection.runAndReadAll(sql);
|
|
211
|
+
return formatResultsTsv(result);
|
|
212
|
+
}
|
|
213
|
+
|
|
161
214
|
/**
|
|
162
215
|
* Execute a SQL query and return raw rows
|
|
163
216
|
* @param {string} sql
|
|
@@ -290,7 +343,7 @@ export class QuerySession {
|
|
|
290
343
|
regexp_extract(filename, '/projects/([^/]+)/', 1) as project,
|
|
291
344
|
ordinality as rownum
|
|
292
345
|
FROM read_ndjson(
|
|
293
|
-
|
|
346
|
+
${this.#formatFilePatternForSql()},
|
|
294
347
|
filename=true,
|
|
295
348
|
ignore_errors=true,
|
|
296
349
|
columns={${columnsDef}}
|
|
@@ -342,7 +395,7 @@ export class QuerySession {
|
|
|
342
395
|
SELECT
|
|
343
396
|
(json->>'uuid')::UUID as uuid,
|
|
344
397
|
json as raw
|
|
345
|
-
FROM read_ndjson_objects(
|
|
398
|
+
FROM read_ndjson_objects(${this.#formatFilePatternForSql()}, ignore_errors=true)
|
|
346
399
|
WHERE json->>'uuid' IS NOT NULL AND length(json->>'uuid') > 0;
|
|
347
400
|
|
|
348
401
|
-- Tool uses: All tool calls with unnested content blocks
|
package/src/repl.js
CHANGED
|
@@ -188,6 +188,7 @@ async function readStdin() {
|
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
190
|
* Run queries from piped input (non-interactive mode)
|
|
191
|
+
* Uses TSV output format with --- separator between queries
|
|
191
192
|
* @param {QuerySession} qs
|
|
192
193
|
* @param {string} input
|
|
193
194
|
*/
|
|
@@ -198,12 +199,25 @@ async function runPipedQueries(qs, input) {
|
|
|
198
199
|
.map((s) => s.trim())
|
|
199
200
|
.filter((s) => s && s !== ";");
|
|
200
201
|
|
|
202
|
+
let isFirstOutput = true;
|
|
203
|
+
|
|
201
204
|
for (const stmt of statements) {
|
|
202
205
|
if (stmt.startsWith(".")) {
|
|
203
206
|
const shouldExit = await handleDotCommand(stmt, qs);
|
|
204
207
|
if (shouldExit) break;
|
|
205
208
|
} else {
|
|
206
|
-
|
|
209
|
+
try {
|
|
210
|
+
const result = await qs.queryTsv(stmt);
|
|
211
|
+
if (result) {
|
|
212
|
+
if (!isFirstOutput) {
|
|
213
|
+
console.log("---");
|
|
214
|
+
}
|
|
215
|
+
console.log(result);
|
|
216
|
+
isFirstOutput = false;
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(`Error: ${err instanceof Error ? err.message : err}`);
|
|
220
|
+
}
|
|
207
221
|
}
|
|
208
222
|
}
|
|
209
223
|
}
|
package/src/session-loader.js
CHANGED
|
@@ -42,7 +42,7 @@ function countSessionsAndAgents(files, sessionFilter = "") {
|
|
|
42
42
|
* Get session info and file pattern for querying
|
|
43
43
|
* @param {string | null} claudeProjectsDir - Path to ~/.claude/projects/{slug}, or null for all projects
|
|
44
44
|
* @param {string} [sessionFilter] - Optional session ID prefix
|
|
45
|
-
* @returns {Promise<{ sessionCount: number, agentCount: number, projectCount: number, filePattern: string }>}
|
|
45
|
+
* @returns {Promise<{ sessionCount: number, agentCount: number, projectCount: number, filePattern: string | string[] }>}
|
|
46
46
|
*/
|
|
47
47
|
export async function getSessionFiles(claudeProjectsDir, sessionFilter = "") {
|
|
48
48
|
// If no specific project, use all projects
|
|
@@ -72,8 +72,13 @@ export async function getSessionFiles(claudeProjectsDir, sessionFilter = "") {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// Use glob pattern for all projects (** for recursive matching)
|
|
75
|
+
// When session filter is provided, include both the filtered session AND its subagents
|
|
76
|
+
// Subagents are stored in {session_id}/subagents/*.jsonl
|
|
75
77
|
const filePattern = sessionFilter
|
|
76
|
-
?
|
|
78
|
+
? [
|
|
79
|
+
join(base, "*", `${sessionFilter}*.jsonl`),
|
|
80
|
+
join(base, "*", `${sessionFilter}*/subagents/*.jsonl`),
|
|
81
|
+
]
|
|
77
82
|
: join(base, "*", "**/*.jsonl");
|
|
78
83
|
|
|
79
84
|
return {
|
|
@@ -93,9 +98,14 @@ export async function getSessionFiles(claudeProjectsDir, sessionFilter = "") {
|
|
|
93
98
|
return { sessionCount: 0, agentCount: 0, projectCount: 1, filePattern: "" };
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
// Use glob pattern
|
|
101
|
+
// Use glob pattern for matching
|
|
102
|
+
// When session filter is provided, include both the filtered session AND its subagents
|
|
103
|
+
// Subagents are stored in {session_id}/subagents/*.jsonl
|
|
97
104
|
const filePattern = sessionFilter
|
|
98
|
-
?
|
|
105
|
+
? [
|
|
106
|
+
join(claudeProjectsDir, `${sessionFilter}*.jsonl`),
|
|
107
|
+
join(claudeProjectsDir, `${sessionFilter}*/subagents/*.jsonl`),
|
|
108
|
+
]
|
|
99
109
|
: join(claudeProjectsDir, "**/*.jsonl");
|
|
100
110
|
|
|
101
111
|
return {
|