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.
@@ -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
+ }