cc-query 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/bin/cc-query.js +83 -0
- package/index.js +1 -0
- package/package.json +35 -0
- package/src/query-session.js +339 -0
- package/src/repl.js +289 -0
- package/src/session-loader.js +107 -0
- package/src/utils.js +37 -0
package/bin/cc-query.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { startRepl } from "../src/repl.js";
|
|
4
|
+
import { resolveProjectDir } from "../src/utils.js";
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
|
|
8
|
+
// Parse --session or -s flag
|
|
9
|
+
let sessionFilter = "";
|
|
10
|
+
const sessionFlagIndex = args.findIndex((a) => a === "--session" || a === "-s");
|
|
11
|
+
if (sessionFlagIndex !== -1 && sessionFlagIndex + 1 < args.length) {
|
|
12
|
+
sessionFilter = args[sessionFlagIndex + 1];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Show help
|
|
16
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
17
|
+
console.log("Usage: cc-query [options] [project-path]");
|
|
18
|
+
console.log("");
|
|
19
|
+
console.log("Interactive SQL REPL for querying Claude Code session data.");
|
|
20
|
+
console.log("Uses chdb (embedded ClickHouse) to query JSONL session files.");
|
|
21
|
+
console.log("");
|
|
22
|
+
console.log("Arguments:");
|
|
23
|
+
console.log(
|
|
24
|
+
" project-path Path to project (omit for all projects)",
|
|
25
|
+
);
|
|
26
|
+
console.log("");
|
|
27
|
+
console.log("Options:");
|
|
28
|
+
console.log(
|
|
29
|
+
" --session, -s <prefix> Filter to sessions matching the ID prefix",
|
|
30
|
+
);
|
|
31
|
+
console.log(" --help, -h Show this help message");
|
|
32
|
+
console.log("");
|
|
33
|
+
console.log("Examples:");
|
|
34
|
+
console.log(" cc-query # All projects");
|
|
35
|
+
console.log(" cc-query ~/code/my-project # Specific project");
|
|
36
|
+
console.log(" cc-query -s abc123 . # Filter by session prefix");
|
|
37
|
+
console.log("");
|
|
38
|
+
console.log("Piped input (like psql):");
|
|
39
|
+
console.log(' echo "SELECT count(*) FROM messages;" | cc-query .');
|
|
40
|
+
console.log(" cat queries.sql | cc-query .");
|
|
41
|
+
console.log("");
|
|
42
|
+
console.log("REPL Commands:");
|
|
43
|
+
console.log(" .help Show available tables and example queries");
|
|
44
|
+
console.log(" .schema Show table schema");
|
|
45
|
+
console.log(" .quit Exit the REPL");
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Filter out flags to get positional args
|
|
50
|
+
const filteredArgs = args.filter(
|
|
51
|
+
(a, i) =>
|
|
52
|
+
a !== "--session" &&
|
|
53
|
+
a !== "-s" &&
|
|
54
|
+
(sessionFlagIndex === -1 || i !== sessionFlagIndex + 1),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// If no project specified, use null for all projects
|
|
58
|
+
let claudeProjectsDir = null;
|
|
59
|
+
let projectPath = null;
|
|
60
|
+
|
|
61
|
+
if (filteredArgs.length > 0) {
|
|
62
|
+
const resolved = resolveProjectDir(filteredArgs[0]);
|
|
63
|
+
claudeProjectsDir = resolved.claudeProjectsDir;
|
|
64
|
+
projectPath = resolved.projectPath;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await startRepl(claudeProjectsDir, { sessionFilter });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (
|
|
71
|
+
err instanceof Error &&
|
|
72
|
+
/** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT"
|
|
73
|
+
) {
|
|
74
|
+
if (projectPath) {
|
|
75
|
+
console.error(`Error: No Claude Code data found for ${projectPath}`);
|
|
76
|
+
console.error(`Expected: ${claudeProjectsDir}`);
|
|
77
|
+
} else {
|
|
78
|
+
console.error("Error: No Claude Code sessions found");
|
|
79
|
+
}
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { QuerySession } from "./src/query-session.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cc-query",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SQL REPL for querying Claude Code session data",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/dannycoates/cc-query.git"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=24"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"cc-query": "./bin/cc-query.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"index.js",
|
|
22
|
+
"src",
|
|
23
|
+
"bin"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@duckdb/node-api": "1.4.3-r.3"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^25.0.8",
|
|
33
|
+
"typescript": "^5.9.3"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { DuckDBInstance } from "@duckdb/node-api";
|
|
2
|
+
import { getSessionFiles } from "./session-loader.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} QuerySessionInfo
|
|
6
|
+
* @property {number} sessionCount
|
|
7
|
+
* @property {number} agentCount
|
|
8
|
+
* @property {number} projectCount
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format query results as a table string
|
|
13
|
+
* @param {import("@duckdb/node-api").DuckDBResultReader} result
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
function formatResults(result) {
|
|
17
|
+
const columnCount = result.columnCount;
|
|
18
|
+
if (columnCount === 0) return "";
|
|
19
|
+
|
|
20
|
+
// Build column names array
|
|
21
|
+
const columnNames = [];
|
|
22
|
+
for (let i = 0; i < columnCount; i++) {
|
|
23
|
+
columnNames.push(result.columnName(i));
|
|
24
|
+
}
|
|
25
|
+
const rows = result.getRows();
|
|
26
|
+
|
|
27
|
+
if (rows.length === 0) {
|
|
28
|
+
return columnNames.join(" | ") + "\n(0 rows)";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 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
|
+
return JSON.stringify(val, (_, v) =>
|
|
43
|
+
typeof v === "bigint" ? v.toString() : v,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return String(val);
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const widths = columnNames.map((name, i) => {
|
|
51
|
+
const maxDataWidth = Math.max(
|
|
52
|
+
...stringRows.map((row) => row[i]?.length || 0),
|
|
53
|
+
);
|
|
54
|
+
return Math.max(name.length, maxDataWidth);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Build formatted output
|
|
58
|
+
const lines = [];
|
|
59
|
+
|
|
60
|
+
// Header
|
|
61
|
+
const header = columnNames.map((n, i) => n.padEnd(widths[i])).join(" │ ");
|
|
62
|
+
lines.push("┌" + widths.map((w) => "─".repeat(w + 2)).join("┬") + "┐");
|
|
63
|
+
lines.push("│ " + header + " │");
|
|
64
|
+
lines.push("├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤");
|
|
65
|
+
|
|
66
|
+
// Data rows
|
|
67
|
+
for (const row of stringRows) {
|
|
68
|
+
const rowStr = row.map((v, i) => (v || "").padEnd(widths[i])).join(" │ ");
|
|
69
|
+
lines.push("│ " + rowStr + " │");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
lines.push("└" + widths.map((w) => "─".repeat(w + 2)).join("┴") + "┘");
|
|
73
|
+
lines.push(`(${rows.length} row${rows.length === 1 ? "" : "s"})`);
|
|
74
|
+
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A reusable session for querying Claude Code message data
|
|
80
|
+
*/
|
|
81
|
+
export class QuerySession {
|
|
82
|
+
/** @type {import("@duckdb/node-api").DuckDBConnection | undefined} */
|
|
83
|
+
#connection;
|
|
84
|
+
/** @type {string} */
|
|
85
|
+
#filePattern;
|
|
86
|
+
/** @type {QuerySessionInfo} */
|
|
87
|
+
#info;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} filePattern - Glob pattern for JSONL files
|
|
91
|
+
* @param {QuerySessionInfo} info - Session counts
|
|
92
|
+
*/
|
|
93
|
+
constructor(filePattern, info) {
|
|
94
|
+
this.#filePattern = filePattern;
|
|
95
|
+
this.#info = info;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create a QuerySession from a project path
|
|
100
|
+
* @param {string | null} projectDir - Claude projects dir, or null for all
|
|
101
|
+
* @param {string} [sessionFilter] - Optional session ID prefix filter
|
|
102
|
+
* @returns {Promise<QuerySession>}
|
|
103
|
+
*/
|
|
104
|
+
static async create(projectDir, sessionFilter = "") {
|
|
105
|
+
const { sessionCount, agentCount, projectCount, filePattern } =
|
|
106
|
+
await getSessionFiles(projectDir, sessionFilter);
|
|
107
|
+
|
|
108
|
+
if (sessionCount === 0) {
|
|
109
|
+
const err = new Error("No sessions found");
|
|
110
|
+
// @ts-ignore
|
|
111
|
+
err.code = "ENOENT";
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const qs = new QuerySession(filePattern, {
|
|
116
|
+
sessionCount,
|
|
117
|
+
agentCount,
|
|
118
|
+
projectCount,
|
|
119
|
+
});
|
|
120
|
+
await qs.#init();
|
|
121
|
+
return qs;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @returns {QuerySessionInfo} */
|
|
125
|
+
get info() {
|
|
126
|
+
return this.#info;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @returns {Promise<void>}
|
|
131
|
+
*/
|
|
132
|
+
async #init() {
|
|
133
|
+
const instance = await DuckDBInstance.create(":memory:");
|
|
134
|
+
this.#connection = await instance.connect();
|
|
135
|
+
await this.#connection.run(this.#getCreateViewsSql());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Execute a SQL query and return formatted table
|
|
140
|
+
* @param {string} sql
|
|
141
|
+
* @returns {Promise<string>} Query result as formatted table
|
|
142
|
+
*/
|
|
143
|
+
async query(sql) {
|
|
144
|
+
if (!this.#connection) {
|
|
145
|
+
throw new Error("Session not initialized - use QuerySession.create()");
|
|
146
|
+
}
|
|
147
|
+
const result = await this.#connection.runAndReadAll(sql);
|
|
148
|
+
return formatResults(result);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Execute a SQL query and return raw rows
|
|
153
|
+
* @param {string} sql
|
|
154
|
+
* @returns {Promise<{columns: string[], rows: any[][]}>} Query result as raw data
|
|
155
|
+
*/
|
|
156
|
+
async queryRows(sql) {
|
|
157
|
+
if (!this.#connection) {
|
|
158
|
+
throw new Error("Session not initialized - use QuerySession.create()");
|
|
159
|
+
}
|
|
160
|
+
const result = await this.#connection.runAndReadAll(sql);
|
|
161
|
+
const columns = [];
|
|
162
|
+
for (let i = 0; i < result.columnCount; i++) {
|
|
163
|
+
columns.push(result.columnName(i));
|
|
164
|
+
}
|
|
165
|
+
return { columns, rows: result.getRows() };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Clean up resources
|
|
170
|
+
*/
|
|
171
|
+
cleanup() {
|
|
172
|
+
this.#connection?.closeSync();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get the SQL to create views
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
#getCreateViewsSql() {
|
|
180
|
+
// Explicitly define schema so missing columns become NULL with proper types
|
|
181
|
+
const columns = {
|
|
182
|
+
// Common fields
|
|
183
|
+
uuid: "UUID",
|
|
184
|
+
type: "VARCHAR",
|
|
185
|
+
subtype: "VARCHAR",
|
|
186
|
+
parentUuid: "UUID",
|
|
187
|
+
timestamp: "TIMESTAMP",
|
|
188
|
+
sessionId: "UUID",
|
|
189
|
+
cwd: "VARCHAR",
|
|
190
|
+
gitBranch: "VARCHAR",
|
|
191
|
+
slug: "VARCHAR",
|
|
192
|
+
version: "VARCHAR",
|
|
193
|
+
isSidechain: "BOOLEAN",
|
|
194
|
+
userType: "VARCHAR",
|
|
195
|
+
message: "JSON",
|
|
196
|
+
// User-specific
|
|
197
|
+
isCompactSummary: "BOOLEAN",
|
|
198
|
+
isMeta: "BOOLEAN",
|
|
199
|
+
isVisibleInTranscriptOnly: "BOOLEAN",
|
|
200
|
+
sourceToolUseID: "VARCHAR",
|
|
201
|
+
thinkingMetadata: "JSON",
|
|
202
|
+
todos: "JSON",
|
|
203
|
+
toolUseResult: "JSON",
|
|
204
|
+
// Assistant-specific
|
|
205
|
+
error: "JSON",
|
|
206
|
+
isApiErrorMessage: "BOOLEAN",
|
|
207
|
+
requestId: "VARCHAR",
|
|
208
|
+
sourceToolAssistantUUID: "UUID",
|
|
209
|
+
// System-specific
|
|
210
|
+
content: "VARCHAR",
|
|
211
|
+
compactMetadata: "JSON",
|
|
212
|
+
hasOutput: "BOOLEAN",
|
|
213
|
+
hookCount: "INTEGER",
|
|
214
|
+
hookErrors: "JSON",
|
|
215
|
+
hookInfos: "JSON",
|
|
216
|
+
level: "VARCHAR",
|
|
217
|
+
logicalParentUuid: "UUID",
|
|
218
|
+
maxRetries: "INTEGER",
|
|
219
|
+
preventedContinuation: "BOOLEAN",
|
|
220
|
+
retryAttempt: "INTEGER",
|
|
221
|
+
retryInMs: "INTEGER",
|
|
222
|
+
stopReason: "VARCHAR",
|
|
223
|
+
toolUseID: "VARCHAR",
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const columnsDef = Object.entries(columns)
|
|
227
|
+
.map(([name, type]) => `'${name}': '${type}'`)
|
|
228
|
+
.join(", ");
|
|
229
|
+
|
|
230
|
+
return `
|
|
231
|
+
-- Base messages view with explicit schema for type safety
|
|
232
|
+
CREATE OR REPLACE VIEW messages AS
|
|
233
|
+
SELECT
|
|
234
|
+
uuid,
|
|
235
|
+
type,
|
|
236
|
+
subtype,
|
|
237
|
+
parentUuid,
|
|
238
|
+
timestamp,
|
|
239
|
+
sessionId,
|
|
240
|
+
cwd,
|
|
241
|
+
gitBranch,
|
|
242
|
+
slug,
|
|
243
|
+
version,
|
|
244
|
+
isSidechain,
|
|
245
|
+
userType,
|
|
246
|
+
message,
|
|
247
|
+
isCompactSummary,
|
|
248
|
+
isMeta,
|
|
249
|
+
isVisibleInTranscriptOnly,
|
|
250
|
+
sourceToolUseID,
|
|
251
|
+
sourceToolAssistantUUID,
|
|
252
|
+
thinkingMetadata,
|
|
253
|
+
todos,
|
|
254
|
+
toolUseResult,
|
|
255
|
+
error,
|
|
256
|
+
isApiErrorMessage,
|
|
257
|
+
requestId,
|
|
258
|
+
content,
|
|
259
|
+
compactMetadata,
|
|
260
|
+
hasOutput,
|
|
261
|
+
hookCount,
|
|
262
|
+
hookErrors,
|
|
263
|
+
hookInfos,
|
|
264
|
+
level,
|
|
265
|
+
logicalParentUuid,
|
|
266
|
+
maxRetries,
|
|
267
|
+
preventedContinuation,
|
|
268
|
+
retryAttempt,
|
|
269
|
+
retryInMs,
|
|
270
|
+
stopReason,
|
|
271
|
+
toolUseID,
|
|
272
|
+
-- Derived fields
|
|
273
|
+
regexp_extract(filename, '[^/]+$') as file,
|
|
274
|
+
starts_with(regexp_extract(filename, '[^/]+$'), 'agent-') as isAgent,
|
|
275
|
+
CASE WHEN starts_with(regexp_extract(filename, '[^/]+$'), 'agent-')
|
|
276
|
+
THEN regexp_extract(regexp_extract(filename, '[^/]+$'), 'agent-([^.]+)', 1)
|
|
277
|
+
ELSE NULL
|
|
278
|
+
END as agentId,
|
|
279
|
+
-- Extract project slug (directory after /projects/)
|
|
280
|
+
regexp_extract(filename, '/projects/([^/]+)/', 1) as project,
|
|
281
|
+
ordinality as rownum
|
|
282
|
+
FROM read_ndjson(
|
|
283
|
+
'${this.#filePattern}',
|
|
284
|
+
filename=true,
|
|
285
|
+
ignore_errors=true,
|
|
286
|
+
columns={${columnsDef}}
|
|
287
|
+
) WITH ORDINALITY
|
|
288
|
+
WHERE type IN ('user', 'assistant', 'system');
|
|
289
|
+
|
|
290
|
+
-- User messages view
|
|
291
|
+
CREATE OR REPLACE VIEW user_messages AS
|
|
292
|
+
SELECT
|
|
293
|
+
uuid, parentUuid, timestamp, sessionId, cwd, gitBranch, slug, version,
|
|
294
|
+
isSidechain, userType, message, isCompactSummary, isMeta,
|
|
295
|
+
isVisibleInTranscriptOnly, sourceToolUseID, sourceToolAssistantUUID,
|
|
296
|
+
thinkingMetadata, todos, toolUseResult, file, isAgent, agentId, project, rownum
|
|
297
|
+
FROM messages
|
|
298
|
+
WHERE type = 'user';
|
|
299
|
+
|
|
300
|
+
-- Human-typed messages (excludes tool results and system-injected text)
|
|
301
|
+
CREATE OR REPLACE VIEW human_messages AS
|
|
302
|
+
SELECT
|
|
303
|
+
uuid, parentUuid, timestamp, sessionId, cwd, gitBranch, slug, version,
|
|
304
|
+
isSidechain, message->>'content' as content, file, project, rownum
|
|
305
|
+
FROM user_messages
|
|
306
|
+
WHERE json_type(message->'content') = 'VARCHAR'
|
|
307
|
+
AND (agentId IS NULL OR agentId = '')
|
|
308
|
+
AND (isMeta IS NULL OR isMeta = false);
|
|
309
|
+
|
|
310
|
+
-- Assistant messages view
|
|
311
|
+
CREATE OR REPLACE VIEW assistant_messages AS
|
|
312
|
+
SELECT
|
|
313
|
+
uuid, parentUuid, timestamp, sessionId, cwd, gitBranch, slug, version,
|
|
314
|
+
isSidechain, userType, message, error, isApiErrorMessage, requestId,
|
|
315
|
+
file, isAgent, agentId, project, rownum
|
|
316
|
+
FROM messages
|
|
317
|
+
WHERE type = 'assistant';
|
|
318
|
+
|
|
319
|
+
-- System messages view
|
|
320
|
+
CREATE OR REPLACE VIEW system_messages AS
|
|
321
|
+
SELECT
|
|
322
|
+
uuid, subtype, parentUuid, timestamp, sessionId, cwd, gitBranch, slug,
|
|
323
|
+
version, isSidechain, userType, content, error, compactMetadata,
|
|
324
|
+
hasOutput, hookCount, hookErrors, hookInfos, level, logicalParentUuid,
|
|
325
|
+
maxRetries, preventedContinuation, retryAttempt, retryInMs, stopReason,
|
|
326
|
+
toolUseID, isMeta, file, isAgent, agentId, project, rownum
|
|
327
|
+
FROM messages
|
|
328
|
+
WHERE type = 'system';
|
|
329
|
+
|
|
330
|
+
-- Raw messages view with full JSON string
|
|
331
|
+
CREATE OR REPLACE VIEW raw_messages AS
|
|
332
|
+
SELECT
|
|
333
|
+
(json->>'uuid')::UUID as uuid,
|
|
334
|
+
json as raw
|
|
335
|
+
FROM read_ndjson_objects('${this.#filePattern}', ignore_errors=true)
|
|
336
|
+
WHERE json->>'uuid' IS NOT NULL AND length(json->>'uuid') > 0;
|
|
337
|
+
`;
|
|
338
|
+
}
|
|
339
|
+
}
|
package/src/repl.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { QuerySession } from "./query-session.js";
|
|
6
|
+
|
|
7
|
+
const HISTORY_FILE = join(homedir(), ".cc_query_history");
|
|
8
|
+
const HISTORY_SIZE = 100;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Load query history from file
|
|
12
|
+
* @returns {Promise<string[]>}
|
|
13
|
+
*/
|
|
14
|
+
async function loadHistory() {
|
|
15
|
+
try {
|
|
16
|
+
const content = await readFile(HISTORY_FILE, "utf-8");
|
|
17
|
+
return content
|
|
18
|
+
.split("\n")
|
|
19
|
+
.filter((line) => line.trim())
|
|
20
|
+
.slice(-HISTORY_SIZE);
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Save query history to file
|
|
28
|
+
* @param {string[]} history
|
|
29
|
+
*/
|
|
30
|
+
async function saveHistory(history) {
|
|
31
|
+
try {
|
|
32
|
+
await writeFile(
|
|
33
|
+
HISTORY_FILE,
|
|
34
|
+
history.slice(-HISTORY_SIZE).join("\n"),
|
|
35
|
+
"utf-8",
|
|
36
|
+
);
|
|
37
|
+
} catch {
|
|
38
|
+
// Silently ignore write errors
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {object} ReplOptions
|
|
44
|
+
* @property {string} [sessionFilter] - Session ID prefix filter
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get help text for .help command
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function getHelpText() {
|
|
52
|
+
return `
|
|
53
|
+
Commands:
|
|
54
|
+
.help, .h Show this help
|
|
55
|
+
.schema, .s Show table schema (runs DESCRIBE messages)
|
|
56
|
+
.schema <view> Show schema for a specific view
|
|
57
|
+
.quit, .q Exit
|
|
58
|
+
|
|
59
|
+
Views:
|
|
60
|
+
messages All messages (user, assistant, system)
|
|
61
|
+
user_messages User messages with user-specific fields
|
|
62
|
+
assistant_messages Assistant messages with error, requestId, etc.
|
|
63
|
+
system_messages System messages with hooks, retry info, etc.
|
|
64
|
+
|
|
65
|
+
Example queries:
|
|
66
|
+
-- Count messages by type
|
|
67
|
+
SELECT type, count(*) as cnt FROM messages GROUP BY type ORDER BY cnt DESC;
|
|
68
|
+
|
|
69
|
+
-- Messages by project (when querying all projects)
|
|
70
|
+
SELECT project, count(*) as cnt FROM messages GROUP BY project ORDER BY cnt DESC;
|
|
71
|
+
|
|
72
|
+
-- Recent assistant messages
|
|
73
|
+
SELECT timestamp, message->>'role', message->>'stop_reason'
|
|
74
|
+
FROM assistant_messages ORDER BY timestamp DESC LIMIT 10;
|
|
75
|
+
|
|
76
|
+
-- Tool usage
|
|
77
|
+
SELECT message->>'stop_reason' as reason, count(*) as cnt
|
|
78
|
+
FROM assistant_messages
|
|
79
|
+
GROUP BY reason ORDER BY cnt DESC;
|
|
80
|
+
|
|
81
|
+
-- Sessions summary
|
|
82
|
+
SELECT sessionId, count(*) as msgs, min(timestamp) as started
|
|
83
|
+
FROM messages GROUP BY sessionId ORDER BY started DESC;
|
|
84
|
+
|
|
85
|
+
-- System message subtypes
|
|
86
|
+
SELECT subtype, count(*) FROM system_messages GROUP BY subtype;
|
|
87
|
+
|
|
88
|
+
-- Agent vs main session breakdown
|
|
89
|
+
SELECT isAgent, count(*) FROM messages GROUP BY isAgent;
|
|
90
|
+
|
|
91
|
+
JSON field access (DuckDB syntax):
|
|
92
|
+
message->'field' Access JSON field (returns JSON)
|
|
93
|
+
message->>'field' Access JSON field as string
|
|
94
|
+
message->'a'->'b' Nested access
|
|
95
|
+
|
|
96
|
+
Useful functions:
|
|
97
|
+
arr[n] Get nth element (1-indexed)
|
|
98
|
+
UNNEST(arr) Expand array into rows
|
|
99
|
+
json_extract_string() Extract string from JSON
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Execute a SQL query and print results
|
|
105
|
+
* @param {QuerySession} qs
|
|
106
|
+
* @param {string} query
|
|
107
|
+
*/
|
|
108
|
+
async function executeQuery(qs, query) {
|
|
109
|
+
try {
|
|
110
|
+
const result = await qs.query(query);
|
|
111
|
+
if (result) {
|
|
112
|
+
console.log(result);
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(`Error: ${err instanceof Error ? err.message : err}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Handle dot commands
|
|
121
|
+
* @param {string} command
|
|
122
|
+
* @param {QuerySession} qs
|
|
123
|
+
* @returns {Promise<boolean>} true if should exit
|
|
124
|
+
*/
|
|
125
|
+
async function handleDotCommand(command, qs) {
|
|
126
|
+
const cmd = command.toLowerCase();
|
|
127
|
+
|
|
128
|
+
if (cmd === ".quit" || cmd === ".exit" || cmd === ".q") {
|
|
129
|
+
console.log("\nGoodbye!");
|
|
130
|
+
qs.cleanup();
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (cmd === ".help" || cmd === ".h") {
|
|
135
|
+
console.log(getHelpText());
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (cmd === ".schema" || cmd === ".s") {
|
|
140
|
+
await executeQuery(qs, "DESCRIBE messages");
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (cmd.startsWith(".schema ") || cmd.startsWith(".s ")) {
|
|
145
|
+
const view = command.split(/\s+/)[1];
|
|
146
|
+
await executeQuery(qs, `DESCRIBE ${view}`);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log(`Unknown command: ${command}. Type .help for usage.`);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read all stdin as a string
|
|
156
|
+
* @returns {Promise<string>}
|
|
157
|
+
*/
|
|
158
|
+
async function readStdin() {
|
|
159
|
+
const chunks = [];
|
|
160
|
+
for await (const chunk of process.stdin) {
|
|
161
|
+
chunks.push(chunk);
|
|
162
|
+
}
|
|
163
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Run queries from piped input (non-interactive mode)
|
|
168
|
+
* @param {QuerySession} qs
|
|
169
|
+
* @param {string} input
|
|
170
|
+
*/
|
|
171
|
+
async function runPipedQueries(qs, input) {
|
|
172
|
+
// Split by semicolons, keeping the semicolon with each statement
|
|
173
|
+
const statements = input
|
|
174
|
+
.split(/(?<=;)/)
|
|
175
|
+
.map((s) => s.trim())
|
|
176
|
+
.filter((s) => s && s !== ";");
|
|
177
|
+
|
|
178
|
+
for (const stmt of statements) {
|
|
179
|
+
if (stmt.startsWith(".")) {
|
|
180
|
+
const shouldExit = await handleDotCommand(stmt, qs);
|
|
181
|
+
if (shouldExit) break;
|
|
182
|
+
} else {
|
|
183
|
+
await executeQuery(qs, stmt);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Start the interactive REPL or run piped queries
|
|
190
|
+
* @param {string | null} claudeProjectsDir
|
|
191
|
+
* @param {ReplOptions} [options]
|
|
192
|
+
*/
|
|
193
|
+
export async function startRepl(claudeProjectsDir, options = {}) {
|
|
194
|
+
const { sessionFilter = "" } = options;
|
|
195
|
+
|
|
196
|
+
// Create query session (handles file discovery and view creation)
|
|
197
|
+
const qs = await QuerySession.create(claudeProjectsDir, sessionFilter);
|
|
198
|
+
const { sessionCount, agentCount, projectCount } = qs.info;
|
|
199
|
+
|
|
200
|
+
// Check if input is piped (non-TTY)
|
|
201
|
+
if (!process.stdin.isTTY) {
|
|
202
|
+
try {
|
|
203
|
+
const input = await readStdin();
|
|
204
|
+
await runPipedQueries(qs, input);
|
|
205
|
+
} finally {
|
|
206
|
+
qs.cleanup();
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Interactive mode
|
|
212
|
+
try {
|
|
213
|
+
const projectInfo = projectCount > 1 ? `${projectCount} project(s), ` : "";
|
|
214
|
+
console.log(
|
|
215
|
+
`Loaded ${projectInfo}${sessionCount} session(s), ${agentCount} agent file(s)`,
|
|
216
|
+
);
|
|
217
|
+
if (sessionFilter) {
|
|
218
|
+
console.log(`Filter: ${sessionFilter}*`);
|
|
219
|
+
}
|
|
220
|
+
console.log('Type ".help" for usage hints.\n');
|
|
221
|
+
|
|
222
|
+
// Setup readline with persistent history
|
|
223
|
+
const history = await loadHistory();
|
|
224
|
+
const rl = createInterface({
|
|
225
|
+
input: process.stdin,
|
|
226
|
+
output: process.stdout,
|
|
227
|
+
prompt: "cc-query> ",
|
|
228
|
+
terminal: true,
|
|
229
|
+
history,
|
|
230
|
+
historySize: HISTORY_SIZE,
|
|
231
|
+
removeHistoryDuplicates: true,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Persist history on changes
|
|
235
|
+
rl.on("history", (newHistory) => {
|
|
236
|
+
saveHistory(newHistory);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
let multiLineBuffer = "";
|
|
240
|
+
let inMultiLine = false;
|
|
241
|
+
|
|
242
|
+
rl.on("close", () => {
|
|
243
|
+
console.log("\nGoodbye!");
|
|
244
|
+
qs.cleanup();
|
|
245
|
+
process.exit(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
rl.prompt();
|
|
249
|
+
|
|
250
|
+
for await (const line of rl) {
|
|
251
|
+
const trimmed = line.trim();
|
|
252
|
+
|
|
253
|
+
// Handle multi-line mode
|
|
254
|
+
if (inMultiLine) {
|
|
255
|
+
multiLineBuffer += "\n" + line;
|
|
256
|
+
// Check if query ends with semicolon
|
|
257
|
+
if (trimmed.endsWith(";")) {
|
|
258
|
+
await executeQuery(qs, multiLineBuffer);
|
|
259
|
+
multiLineBuffer = "";
|
|
260
|
+
inMultiLine = false;
|
|
261
|
+
} else {
|
|
262
|
+
process.stdout.write(" -> ");
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Handle dot commands
|
|
267
|
+
else if (trimmed.startsWith(".")) {
|
|
268
|
+
const shouldExit = await handleDotCommand(trimmed, qs);
|
|
269
|
+
if (shouldExit) break;
|
|
270
|
+
}
|
|
271
|
+
// Handle SQL queries
|
|
272
|
+
else if (trimmed) {
|
|
273
|
+
if (trimmed.endsWith(";")) {
|
|
274
|
+
await executeQuery(qs, trimmed);
|
|
275
|
+
} else {
|
|
276
|
+
// Start multi-line mode
|
|
277
|
+
multiLineBuffer = line;
|
|
278
|
+
inMultiLine = true;
|
|
279
|
+
process.stdout.write(" -> ");
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
rl.prompt();
|
|
285
|
+
}
|
|
286
|
+
} finally {
|
|
287
|
+
qs.cleanup();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get the base Claude projects directory
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function getClaudeProjectsBase() {
|
|
10
|
+
return join(homedir(), ".claude", "projects");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get all project directories
|
|
15
|
+
* @returns {Promise<string[]>} Array of project directory paths
|
|
16
|
+
*/
|
|
17
|
+
export async function getAllProjectDirs() {
|
|
18
|
+
const base = getClaudeProjectsBase();
|
|
19
|
+
const entries = await readdir(base, { withFileTypes: true });
|
|
20
|
+
return entries.filter((e) => e.isDirectory()).map((e) => join(base, e.name));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Count sessions and agents from a list of jsonl filenames
|
|
25
|
+
* @param {string[]} files - Array of filenames (basenames)
|
|
26
|
+
* @param {string} sessionFilter - Optional session ID prefix
|
|
27
|
+
* @returns {{ sessions: number, agents: number }}
|
|
28
|
+
*/
|
|
29
|
+
function countSessionsAndAgents(files, sessionFilter = "") {
|
|
30
|
+
const jsonlFiles = files.filter((e) => e.endsWith(".jsonl"));
|
|
31
|
+
const grouped = Object.groupBy(jsonlFiles, (e) =>
|
|
32
|
+
e.startsWith("agent-") ? "agent" : "session",
|
|
33
|
+
);
|
|
34
|
+
const agents = grouped.agent?.length ?? 0;
|
|
35
|
+
const sessions = (grouped.session ?? []).filter(
|
|
36
|
+
(e) => !sessionFilter || e.startsWith(sessionFilter),
|
|
37
|
+
).length;
|
|
38
|
+
return { sessions, agents };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get session info and file pattern for querying
|
|
43
|
+
* @param {string | null} claudeProjectsDir - Path to ~/.claude/projects/{slug}, or null for all projects
|
|
44
|
+
* @param {string} [sessionFilter] - Optional session ID prefix
|
|
45
|
+
* @returns {Promise<{ sessionCount: number, agentCount: number, projectCount: number, filePattern: string }>}
|
|
46
|
+
*/
|
|
47
|
+
export async function getSessionFiles(claudeProjectsDir, sessionFilter = "") {
|
|
48
|
+
// If no specific project, use all projects
|
|
49
|
+
if (!claudeProjectsDir) {
|
|
50
|
+
const base = getClaudeProjectsBase();
|
|
51
|
+
const projectDirs = await getAllProjectDirs();
|
|
52
|
+
|
|
53
|
+
let totalSessions = 0;
|
|
54
|
+
let totalAgents = 0;
|
|
55
|
+
|
|
56
|
+
for (const dir of projectDirs) {
|
|
57
|
+
// Recursively find all jsonl files (includes */subagents/*.jsonl)
|
|
58
|
+
const entries = await readdir(dir, { recursive: true });
|
|
59
|
+
const basenames = entries.map((e) => e.split("/").pop() ?? e);
|
|
60
|
+
const counts = countSessionsAndAgents(basenames, sessionFilter);
|
|
61
|
+
totalSessions += counts.sessions;
|
|
62
|
+
totalAgents += counts.agents;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (totalSessions === 0) {
|
|
66
|
+
return {
|
|
67
|
+
sessionCount: 0,
|
|
68
|
+
agentCount: 0,
|
|
69
|
+
projectCount: 0,
|
|
70
|
+
filePattern: "",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Use glob pattern for all projects (** for recursive matching)
|
|
75
|
+
const filePattern = sessionFilter
|
|
76
|
+
? join(base, "*", `**/${sessionFilter}*.jsonl`)
|
|
77
|
+
: join(base, "*", "**/*.jsonl");
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
sessionCount: totalSessions,
|
|
81
|
+
agentCount: totalAgents,
|
|
82
|
+
projectCount: projectDirs.length,
|
|
83
|
+
filePattern,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Recursively find all jsonl files (includes */subagents/*.jsonl)
|
|
88
|
+
const entries = await readdir(claudeProjectsDir, { recursive: true });
|
|
89
|
+
const basenames = entries.map((e) => e.split("/").pop() ?? e);
|
|
90
|
+
const { sessions, agents } = countSessionsAndAgents(basenames, sessionFilter);
|
|
91
|
+
|
|
92
|
+
if (sessions === 0) {
|
|
93
|
+
return { sessionCount: 0, agentCount: 0, projectCount: 1, filePattern: "" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Use glob pattern with ** for recursive matching
|
|
97
|
+
const filePattern = sessionFilter
|
|
98
|
+
? join(claudeProjectsDir, `**/${sessionFilter}*.jsonl`)
|
|
99
|
+
: join(claudeProjectsDir, "**/*.jsonl");
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
sessionCount: sessions,
|
|
103
|
+
agentCount: agents,
|
|
104
|
+
projectCount: 1,
|
|
105
|
+
filePattern,
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve, isAbsolute } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a project path to an absolute path
|
|
6
|
+
* @param {string} projectPath - Path like ~/code/foo, ./foo, or /home/user/code/foo
|
|
7
|
+
* @returns {string} - Absolute path
|
|
8
|
+
*/
|
|
9
|
+
function resolveProjectPath(projectPath) {
|
|
10
|
+
let resolved = projectPath.replace(/^~/, homedir());
|
|
11
|
+
if (!isAbsolute(resolved)) {
|
|
12
|
+
const baseDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
13
|
+
resolved = resolve(baseDir, resolved);
|
|
14
|
+
}
|
|
15
|
+
return resolved;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get project slug from a filesystem path
|
|
20
|
+
* @param {string} projectPath - Path like ~/code/zombie-brainz or /home/user/code/zombie-brainz
|
|
21
|
+
* @returns {string} - Project slug like -home-user-code-zombie-brainz
|
|
22
|
+
*/
|
|
23
|
+
export function getProjectSlug(projectPath) {
|
|
24
|
+
return resolveProjectPath(projectPath).replace(/[/.]/g, "-");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert a project path to the Claude projects directory path
|
|
29
|
+
* @param {string} projectPath - Path like ~/code/zombie-brainz or /home/user/code/zombie-brainz
|
|
30
|
+
* @returns {{ projectPath: string, claudeProjectsDir: string }}
|
|
31
|
+
*/
|
|
32
|
+
export function resolveProjectDir(projectPath) {
|
|
33
|
+
const resolved = resolveProjectPath(projectPath);
|
|
34
|
+
const slug = getProjectSlug(projectPath);
|
|
35
|
+
const claudeProjectsDir = join(homedir(), ".claude", "projects", slug);
|
|
36
|
+
return { projectPath: resolved, claudeProjectsDir };
|
|
37
|
+
}
|