From 96039d75ccfd2b65a6bc8bf69d1327036cec2250 Mon Sep 17 00:00:00 2001 From: Denis Jannot Date: Thu, 26 Feb 2026 11:51:17 +0000 Subject: [PATCH] Adding a post hook cli --- src/cli.ts | 74 +++++++++++++++++++++++++++++++++++++++ src/config.ts | 46 ++++++++++++++++++++++++ src/indexer-cli-claude.ts | 23 +++++++++--- src/indexer-cli-codex.ts | 23 +++++++++--- src/indexer-cli-cursor.ts | 23 +++++++++--- src/indexer-cli-gemini.ts | 23 +++++++++--- src/indexer-cli-vscode.ts | 23 +++++++++--- src/indexer-cli.ts | 20 +++++++++-- src/post-hook.ts | 55 +++++++++++++++++++++++++++++ 9 files changed, 288 insertions(+), 22 deletions(-) create mode 100644 src/config.ts create mode 100644 src/post-hook.ts diff --git a/src/cli.ts b/src/cli.ts index 1107840..400324f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ * npx code-session-memory uninstall — remove all installed components * npx code-session-memory reset-db — wipe the database (with confirmation) * npx code-session-memory sessions — browse / print / delete sessions + * npx code-session-memory config — manage configuration (post-hook-command, etc.) */ import fs from "fs"; @@ -18,6 +19,7 @@ import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import { resolveDbPath, openDatabase } from "./database"; import { cmdSessions } from "./cli-sessions"; import { cmdQuery } from "./cli-query"; +import { loadConfig, saveConfig, getConfigPath } from "./config"; // --------------------------------------------------------------------------- // Paths — OpenCode @@ -1644,6 +1646,15 @@ function status(): void { } catch { /* DB might be empty */ } } + const config = loadConfig(); + console.log(bold("\n Config")); + if (config.postHookCommand) { + console.log(` ${green("✓")} Post-hook ${dim(config.postHookCommand)}`); + } else { + console.log(` ${dim("○")} ${dim("Post-hook (not set)")}`); + } + console.log(` ${dim("Config file:")} ${getConfigPath()}`); + const allOk = (!openCodeInstalled || ( fs.existsSync(getOpenCodePluginDst()) && fs.existsSync(getOpenCodeSkillDst()) && @@ -1827,6 +1838,10 @@ ${bold("Usage:")} npx code-session-memory sessions delete Delete a session from the DB npx code-session-memory sessions purge --days Delete sessions older than N days (interactive) npx code-session-memory sessions purge --days --yes Delete sessions older than N days (no prompt) + npx code-session-memory config show Show current configuration + npx code-session-memory config set post-hook-command "…" Set a command to run after each indexing + npx code-session-memory config get post-hook-command Get the current post-hook command + npx code-session-memory config unset post-hook-command Remove the post-hook command npx code-session-memory help Show this help ${bold("Environment variables:")} @@ -1838,9 +1853,65 @@ ${bold("Environment variables:")} VSCODE_CONFIG_DIR Override the VS Code config directory CODEX_HOME Override the Codex home directory (~/.codex) GEMINI_CONFIG_DIR Override the Gemini CLI config directory (~/.gemini) + OPENCODE_MEMORY_CONFIG_PATH Override the config file path `); } +// --------------------------------------------------------------------------- +// config command +// --------------------------------------------------------------------------- + +function cmdConfig(args: string[]): void { + const sub = args[0]; + + switch (sub) { + case "set": { + const key = args[1]; + const value = args.slice(2).join(" "); + if (key !== "post-hook-command" || !value) { + console.error('Usage: config set post-hook-command ""'); + process.exit(1); + } + const config = loadConfig(); + config.postHookCommand = value; + saveConfig(config); + console.log(`${green("Done.")} post-hook-command set.`); + console.log(dim(`Config: ${getConfigPath()}`)); + break; + } + case "get": { + const key = args[1]; + if (key !== "post-hook-command") { + console.error("Known keys: post-hook-command"); + process.exit(1); + } + const config = loadConfig(); + console.log(config.postHookCommand ?? dim("(not set)")); + break; + } + case "unset": { + const key = args[1]; + if (key !== "post-hook-command") { + console.error("Known keys: post-hook-command"); + process.exit(1); + } + const config = loadConfig(); + delete config.postHookCommand; + saveConfig(config); + console.log(`${green("Done.")} post-hook-command removed.`); + break; + } + case "show": { + const config = loadConfig(); + console.log(JSON.stringify(config, null, 2)); + break; + } + default: + console.error("Usage: config [key] [value]"); + process.exit(1); + } +} + // --------------------------------------------------------------------------- // Entry point // --------------------------------------------------------------------------- @@ -1869,6 +1940,9 @@ switch (cmd) { process.exit(1); }); break; + case "config": + cmdConfig(process.argv.slice(3)); + break; case "help": case "--help": case "-h": help(); break; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..16ebbd8 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,46 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface AppConfig { + postHookCommand?: string; +} + +// --------------------------------------------------------------------------- +// Path resolution +// --------------------------------------------------------------------------- + +/** + * Returns the path to the config file. + * Respects OPENCODE_MEMORY_CONFIG_PATH env var, otherwise falls back to + * ~/.local/share/code-session-memory/config.json (same directory as the DB). + */ +export function getConfigPath(): string { + const envPath = process.env.OPENCODE_MEMORY_CONFIG_PATH; + if (envPath) return envPath.replace(/^~/, os.homedir()); + return path.join(os.homedir(), ".local", "share", "code-session-memory", "config.json"); +} + +// --------------------------------------------------------------------------- +// Load / Save +// --------------------------------------------------------------------------- + +export function loadConfig(): AppConfig { + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) return {}; + try { + return JSON.parse(fs.readFileSync(configPath, "utf8")) as AppConfig; + } catch { + return {}; + } +} + +export function saveConfig(config: AppConfig): void { + const configPath = getConfigPath(); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); +} diff --git a/src/indexer-cli-claude.ts b/src/indexer-cli-claude.ts index 6725a85..341c8c6 100644 --- a/src/indexer-cli-claude.ts +++ b/src/indexer-cli-claude.ts @@ -14,6 +14,7 @@ import { resolveDbPath, openDatabase, getSessionMeta } from "./database"; import { indexNewMessages } from "./indexer"; import { parseTranscript, deriveSessionTitle } from "./transcript-to-messages"; +import { runPostHookCommand } from "./post-hook"; import type { FullMessage } from "./types"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -58,6 +59,10 @@ async function main() { const dbPath = resolveDbPath(); const db = openDatabase({ dbPath }); + let result = { indexed: 0, skipped: 0 }; + let indexError: string | undefined; + let title = ""; + try { // Parse the transcript — retry if the JSONL ends on a tool result, // which means Claude Code hasn't finished writing the final assistant @@ -75,7 +80,7 @@ async function main() { // Build a session title from the first user message const existingMeta = getSessionMeta(db, sessionId); - const title = existingMeta?.session_title || deriveSessionTitle(messages); + title = existingMeta?.session_title || deriveSessionTitle(messages); const session = { id: sessionId, @@ -83,13 +88,23 @@ async function main() { directory: cwd ?? "", }; - await indexNewMessages(db, session, messages, "claude-code"); + result = await indexNewMessages(db, session, messages, "claude-code"); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`[code-session-memory] Indexing error: ${msg}\n`); + indexError = err instanceof Error ? err.message : String(err); + process.stderr.write(`[code-session-memory] Indexing error: ${indexError}\n`); } finally { db.close(); } + + runPostHookCommand({ + source: "claude-code", + sessionId, + sessionTitle: title, + project: cwd, + indexedCount: result.indexed, + success: !indexError, + errorMessage: indexError, + }); } main().catch((err) => { diff --git a/src/indexer-cli-codex.ts b/src/indexer-cli-codex.ts index 4f9cea8..26680da 100644 --- a/src/indexer-cli-codex.ts +++ b/src/indexer-cli-codex.ts @@ -15,6 +15,7 @@ import os from "os"; import { resolveDbPath, openDatabase, getSessionMeta } from "./database"; import { indexNewMessages } from "./indexer"; import { codexSessionToMessages, deriveCodexSessionTitle } from "./codex-session-to-messages"; +import { runPostHookCommand } from "./post-hook"; function getCodexHome(): string { return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); @@ -105,12 +106,16 @@ async function main() { const dbPath = resolveDbPath(); const db = openDatabase({ dbPath }); + let result = { indexed: 0, skipped: 0 }; + let indexError: string | undefined; + let title = ""; + try { const messages = codexSessionToMessages(sessionFilePath); if (messages.length === 0) return; const existingMeta = getSessionMeta(db, threadId); - const title = existingMeta?.session_title + title = existingMeta?.session_title || deriveCodexSessionTitle(messages, payload["last-assistant-message"]); const session = { @@ -119,13 +124,23 @@ async function main() { directory: cwd ?? "", }; - await indexNewMessages(db, session, messages, "codex"); + result = await indexNewMessages(db, session, messages, "codex"); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`[code-session-memory] Indexing error: ${msg}\n`); + indexError = err instanceof Error ? err.message : String(err); + process.stderr.write(`[code-session-memory] Indexing error: ${indexError}\n`); } finally { db.close(); } + + runPostHookCommand({ + source: "codex", + sessionId: threadId, + sessionTitle: title, + project: cwd, + indexedCount: result.indexed, + success: !indexError, + errorMessage: indexError, + }); } main().catch((err) => { diff --git a/src/indexer-cli-cursor.ts b/src/indexer-cli-cursor.ts index d578461..06e52aa 100644 --- a/src/indexer-cli-cursor.ts +++ b/src/indexer-cli-cursor.ts @@ -24,6 +24,7 @@ import { resolveDbPath, openDatabase, getSessionMeta } from "./database"; import { indexNewMessages } from "./indexer"; +import { runPostHookCommand } from "./post-hook"; import { resolveCursorDbPath, openCursorDb, @@ -89,10 +90,14 @@ async function main() { const dbPath = resolveDbPath(); const db = openDatabase({ dbPath }); + let result = { indexed: 0, skipped: 0 }; + let indexError: string | undefined; + let title = ""; + try { // Derive session title from SQLite (best-effort — don't fail if unavailable) const existingMeta = getSessionMeta(db, composerId); - let title = existingMeta?.session_title ?? ""; + title = existingMeta?.session_title ?? ""; if (!title) { try { @@ -127,13 +132,23 @@ async function main() { directory: projectDir, }; - await indexNewMessages(db, session, messages, "cursor"); + result = await indexNewMessages(db, session, messages, "cursor"); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`[code-session-memory] Indexing error: ${msg}\n`); + indexError = err instanceof Error ? err.message : String(err); + process.stderr.write(`[code-session-memory] Indexing error: ${indexError}\n`); } finally { db.close(); } + + runPostHookCommand({ + source: "cursor", + sessionId: composerId, + sessionTitle: title, + project: projectDir, + indexedCount: result.indexed, + success: !indexError, + errorMessage: indexError, + }); } main().catch((err) => { diff --git a/src/indexer-cli-gemini.ts b/src/indexer-cli-gemini.ts index ce4ba82..4314f61 100644 --- a/src/indexer-cli-gemini.ts +++ b/src/indexer-cli-gemini.ts @@ -11,6 +11,7 @@ import { resolveDbPath, openDatabase, getSessionMeta } from "./database"; import { indexNewMessages } from "./indexer"; +import { runPostHookCommand } from "./post-hook"; import { geminiSessionToMessages, deriveGeminiSessionTitle, @@ -164,12 +165,16 @@ async function main() { const dbPath = resolveDbPath(); const db = openDatabase({ dbPath }); + let result = { indexed: 0, skipped: 0 }; + let indexError: string | undefined; + let title = ""; + try { const messages = geminiSessionToMessages(transcriptPath); if (messages.length === 0) return; const existingMeta = getSessionMeta(db, sessionId); - const title = existingMeta?.session_title || deriveGeminiSessionTitle(messages, sessionId); + title = existingMeta?.session_title || deriveGeminiSessionTitle(messages, sessionId); const session = { id: sessionId, @@ -177,13 +182,23 @@ async function main() { directory: projectDir, }; - await indexNewMessages(db, session, messages, "gemini-cli"); + result = await indexNewMessages(db, session, messages, "gemini-cli"); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`[code-session-memory] Indexing error: ${msg}\n`); + indexError = err instanceof Error ? err.message : String(err); + process.stderr.write(`[code-session-memory] Indexing error: ${indexError}\n`); } finally { db.close(); } + + runPostHookCommand({ + source: "gemini-cli", + sessionId, + sessionTitle: title, + project: projectDir, + indexedCount: result.indexed, + success: !indexError, + errorMessage: indexError, + }); } main().catch((err) => { diff --git a/src/indexer-cli-vscode.ts b/src/indexer-cli-vscode.ts index 488b35d..d0a5f99 100644 --- a/src/indexer-cli-vscode.ts +++ b/src/indexer-cli-vscode.ts @@ -14,6 +14,7 @@ import { resolveDbPath, openDatabase, getSessionMeta } from "./database"; import { indexNewMessages } from "./indexer"; import { parseVscodeTranscript, deriveVscodeSessionTitle } from "./vscode-transcript-to-messages"; +import { runPostHookCommand } from "./post-hook"; import type { FullMessage } from "./types"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -65,6 +66,10 @@ async function main() { const dbPath = resolveDbPath(); const db = openDatabase({ dbPath }); + let result = { indexed: 0, skipped: 0 }; + let indexError: string | undefined; + let title = ""; + try { // Parse the transcript — retry if the JSONL ends on a tool result, // which may mean the transcript is not fully written yet. @@ -81,7 +86,7 @@ async function main() { // Build a session title from the first user message const existingMeta = getSessionMeta(db, sessionId); - const title = existingMeta?.session_title || deriveVscodeSessionTitle(messages); + title = existingMeta?.session_title || deriveVscodeSessionTitle(messages); const session = { id: sessionId, @@ -89,13 +94,23 @@ async function main() { directory: cwd ?? "", }; - await indexNewMessages(db, session, messages, "vscode"); + result = await indexNewMessages(db, session, messages, "vscode"); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`[code-session-memory] Indexing error: ${msg}\n`); + indexError = err instanceof Error ? err.message : String(err); + process.stderr.write(`[code-session-memory] Indexing error: ${indexError}\n`); } finally { db.close(); } + + runPostHookCommand({ + source: "vscode", + sessionId, + sessionTitle: title, + project: cwd, + indexedCount: result.indexed, + success: !indexError, + errorMessage: indexError, + }); } main().catch((err) => { diff --git a/src/indexer-cli.ts b/src/indexer-cli.ts index a0b93e8..0dbce0f 100644 --- a/src/indexer-cli.ts +++ b/src/indexer-cli.ts @@ -16,6 +16,7 @@ import { APIConnectionError, RateLimitError } from "openai"; import { resolveDbPath, openDatabase } from "./database"; import { indexNewMessages } from "./indexer"; +import { runPostHookCommand } from "./post-hook"; import { getSessionFromOpenCodeDb, getMessagesFromOpenCodeDb } from "./opencode-db-to-messages"; import type { FullMessage } from "./types"; @@ -120,18 +121,33 @@ async function main() { const dbPath = resolveDbPath(); const db = openDatabase({ dbPath }); + + let result = { indexed: 0, skipped: 0 }; + let indexError: string | undefined; + try { - await indexNewMessages( + result = await indexNewMessages( db, { id: session.id, title: session.title, directory: session.directory }, messages, "opencode", ); + } catch (err: unknown) { + indexError = err instanceof Error ? err.message : String(err); + process.stderr.write(`[code-session-memory] Indexing error: ${indexError}\n`); } finally { db.close(); } - // No output — the plugin runs this silently via Bun's $.quiet() + runPostHookCommand({ + source: "opencode", + sessionId: session.id, + sessionTitle: session.title, + project: session.directory, + indexedCount: result.indexed, + success: !indexError, + errorMessage: indexError, + }); } main().catch((err) => { diff --git a/src/post-hook.ts b/src/post-hook.ts new file mode 100644 index 0000000..b53ac28 --- /dev/null +++ b/src/post-hook.ts @@ -0,0 +1,55 @@ +import { exec } from "child_process"; +import { loadConfig } from "./config"; +import type { SessionSource } from "./types"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PostHookContext { + source: SessionSource; + sessionId: string; + sessionTitle?: string; + project?: string; + indexedCount: number; + success: boolean; + errorMessage?: string; +} + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +/** + * Executes the user-configured post-hook command (if any) after indexing. + * + * Context is passed via CSM_* environment variables so the command can use + * them freely (e.g. in a notification message). + * + * Fire-and-forget: does not block the indexer and silently logs errors to + * stderr. A 10-second timeout prevents runaway commands from hanging the + * process. + */ +export function runPostHookCommand(ctx: PostHookContext): void { + const config = loadConfig(); + if (!config.postHookCommand) return; + + const env: Record = { + ...(process.env as Record), + CSM_SOURCE: ctx.source, + CSM_SESSION_ID: ctx.sessionId, + CSM_SESSION_TITLE: ctx.sessionTitle ?? "", + CSM_PROJECT: ctx.project ?? "", + CSM_INDEXED_COUNT: String(ctx.indexedCount), + CSM_SUCCESS: ctx.success ? "true" : "false", + CSM_ERROR: ctx.errorMessage ?? "", + }; + + exec(config.postHookCommand, { env, timeout: 10_000 }, (err) => { + if (err) { + process.stderr.write( + `[code-session-memory] post-hook command failed: ${err.message}\n`, + ); + } + }); +}