From ae088dc6f8c7216f97316f47914e156e0e8819b5 Mon Sep 17 00:00:00 2001 From: kernel-bot <209553775+ifx-kernel-bot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 12:20:58 +0000 Subject: [PATCH] feat: add cchist install-hook command for auto-sync on session end Registers a Stop hook in ~/.claude/settings.json that runs `cchist sync --only local --debounce 30` whenever a Claude Code session ends. - `cchist install-hook`: idempotent hook registration (no-op if already present) - `--debounce `: configurable debounce window (default 30s) - `--force`: replace existing cchist hook entries - `--dry-run`: preview JSON without writing - `cchist sync --debounce `: skip sync if ran within N seconds (timestamp stored in `{storage}/.cchist-last-sync`) - Marks roadmap item as done in README Closes #3 Co-Authored-By: Claude Sonnet 4.6 --- README.md | 21 +++++- src/cli.ts | 16 +++++ src/commands/install-hook.ts | 133 +++++++++++++++++++++++++++++++++++ src/commands/sync.ts | 43 ++++++++++- 4 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 src/commands/install-hook.ts diff --git a/README.md b/README.md index 1effb46..7e454d0 100644 --- a/README.md +++ b/README.md @@ -118,14 +118,33 @@ cchist sync # 全ソースから同期 cchist sync --only local vps # 特定ソースだけ cchist sync --dry-run # 何をやるか確認 cchist sync --analyze # 同期 + claude -p で分析 +cchist sync --debounce 30 # 30秒以内に実行済みならスキップ cchist list # アーカイブ内のセッション一覧(新しい順) cchist list --source vps # ソース絞り込み cchist list --json # JSON 出力 cchist search "ERC-4337" # 横断 grep cchist analyze # 同期せずに分析だけ実行 cchist analyze --prompt "..." # プロンプト差し替え +cchist install-hook # Claude Code の Stop hook に cchist sync を登録 +cchist install-hook --debounce 60 # debounce 窓を 60 秒に変更 +cchist install-hook --dry-run # 書き込まずに確認 ``` +### 自動同期 (install-hook) + +`cchist install-hook` を一度実行すると、`~/.claude/settings.json` の `Stop` hook に +`cchist sync --only local --debounce 30` が登録されます。 +Claude Code セッションが終了するたびにローカル履歴が自動同期されます。 + +```bash +cchist install-hook # デフォルト設定で登録 +cchist install-hook --debounce 60 # debounce を 60 秒に変更して登録 +cchist install-hook --force # 既存の cchist hook を置き換えて再登録 +``` + +**注意:** SSH ソースは hook では同期されません (`--only local` 限定)。 +マルチマシン集約は引き続き `cchist sync` を手動実行してください。 + --- ## 動作原理 @@ -176,7 +195,7 @@ CLI で S3 / R2 に push したアーカイブを、ブラウザで閲覧する ## ロードマップ -- [ ] `cchist watch`: hook 経由のリアルタイム同期 +- [x] `cchist install-hook`: hook 経由のリアルタイム同期 (`Stop` hook → `cchist sync --only local`) - [ ] API ログ取り込み(SDK ラッパー or OpenTelemetry コレクタ) - [x] Web UI (検索 / セッション差分 / タイムライン) — [`web/`](./web/) - [ ] 個別セッションを markdown にエクスポート diff --git a/src/cli.ts b/src/cli.ts index 61b1ac4..e118a67 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { runSync } from "./commands/sync.js"; import { runList } from "./commands/list.js"; import { runSearch } from "./commands/search.js"; import { runAnalyze } from "./commands/analyze.js"; +import { runInstallHook } from "./commands/install-hook.js"; import { ConfigNotFoundError } from "./config.js"; import { log } from "./log.js"; @@ -33,6 +34,7 @@ program .option("--skip-remote", "skip pushing to configured [[remote]] targets") .option("--remote-only", "skip source sync; only push existing archive to remotes") .option("--dry-run", "don't actually transfer; just print what would happen") + .option("--debounce ", "skip sync if it already ran within N seconds", (v) => parseInt(v, 10)) .action(async (opts) => { process.exit(await runSync({ ...opts, config: program.opts().config })); }); @@ -63,6 +65,20 @@ program process.exit(await runAnalyze({ ...opts, config: program.opts().config })); }); +program + .command("install-hook") + .description("register a Stop hook in ~/.claude/settings.json to auto-sync on session end") + .option( + "--debounce ", + "skip sync if it already ran within N seconds (default: 30)", + (v) => parseInt(v, 10), + ) + .option("--force", "replace any existing cchist hook entries") + .option("--dry-run", "show what would be written without modifying settings.json") + .action(async (opts) => { + process.exit(await runInstallHook(opts)); + }); + program.parseAsync(process.argv).catch((err) => { if (err instanceof ConfigNotFoundError) { log.error(err.message); diff --git a/src/commands/install-hook.ts b/src/commands/install-hook.ts new file mode 100644 index 0000000..74092b9 --- /dev/null +++ b/src/commands/install-hook.ts @@ -0,0 +1,133 @@ +/** + * cchist install-hook + * + * Registers a `Stop` hook in ~/.claude/settings.json that runs + * `cchist sync --only local` every time a Claude Code session ends. + * + * Idempotent: re-running the command is a no-op unless --force is passed. + */ + +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import { log } from "../log.js"; + +/** A single hook entry in Claude's settings.json */ +type HookEntry = { + type: "command"; + command: string; +}; + +/** One element of the hooks[Event] array */ +type HookMatcher = { + matcher: string; + hooks: HookEntry[]; +}; + +/** Minimal shape of ~/.claude/settings.json we care about */ +type ClaudeSettings = { + hooks?: Record; + [key: string]: unknown; +}; + +const DEFAULT_SETTINGS_PATH = join(homedir(), ".claude", "settings.json"); +const HOOK_EVENT = "Stop"; +const HOOK_COMMAND_PREFIX = "cchist sync"; + +function buildHookCommand(debounce: number): string { + const base = "cchist sync --only local"; + return debounce > 0 ? `${base} --debounce ${debounce}` : base; +} + +/** + * Returns true if any existing hook entry already matches the given command + * so we can skip re-adding it (idempotency check). + */ +function hookAlreadyPresent(settings: ClaudeSettings, command: string): boolean { + const matchers = settings.hooks?.[HOOK_EVENT] ?? []; + return matchers.some((m) => m.hooks.some((h) => h.command === command)); +} + +/** + * Removes all hook entries whose command starts with "cchist sync" + * from the Stop event list. Used by --force to clean up before re-adding. + */ +function removeCchistHooks(matchers: HookMatcher[]): HookMatcher[] { + return matchers + .map((m) => ({ + ...m, + hooks: m.hooks.filter((h) => !h.command.startsWith(HOOK_COMMAND_PREFIX)), + })) + .filter((m) => m.hooks.length > 0); +} + +export async function runInstallHook(opts: { + debounce?: number; + force?: boolean; + dryRun?: boolean; + /** Override settings path (used in tests) */ + settingsPath?: string; +}): Promise { + const settingsPath = opts.settingsPath ?? DEFAULT_SETTINGS_PATH; + const debounce = opts.debounce ?? 30; + const hookCommand = buildHookCommand(debounce); + + // ── Read existing settings ────────────────────────────────────────────── + let settings: ClaudeSettings = {}; + if (existsSync(settingsPath)) { + const raw = await readFile(settingsPath, "utf8"); + try { + settings = JSON.parse(raw) as ClaudeSettings; + } catch (err) { + log.error( + `Could not parse ${settingsPath}: ${(err as Error).message}\n` + + `Fix or delete the file, then re-run \`cchist install-hook\`.`, + ); + return 1; + } + } + + // ── Idempotency check ─────────────────────────────────────────────────── + if (hookAlreadyPresent(settings, hookCommand) && !opts.force) { + log.ok(`cchist hook already registered in ${settingsPath}`); + log.dim(` command: ${hookCommand}`); + log.dim(` (use --force to re-register)`); + return 0; + } + + // ── Build updated settings ────────────────────────────────────────────── + if (!settings.hooks) settings.hooks = {}; + + const existingMatchers: HookMatcher[] = settings.hooks[HOOK_EVENT] ?? []; + const cleaned = opts.force ? removeCchistHooks(existingMatchers) : existingMatchers; + + const newMatcher: HookMatcher = { + matcher: "", + hooks: [{ type: "command", command: hookCommand }], + }; + + settings.hooks[HOOK_EVENT] = [...cleaned, newMatcher]; + + const json = JSON.stringify(settings, null, 2); + + // ── Dry-run ───────────────────────────────────────────────────────────── + if (opts.dryRun) { + log.info(`[dry-run] would write to ${settingsPath}:`); + console.log(json); + return 0; + } + + // ── Write ──────────────────────────────────────────────────────────────── + await mkdir(dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, json + "\n", "utf8"); + + log.ok(`hook installed → ${settingsPath}`); + log.info(`event : ${HOOK_EVENT}`); + log.info(`command : ${hookCommand}`); + if (debounce > 0) { + log.dim(`debounce: ${debounce}s — sync is skipped if it already ran within ${debounce}s`); + } + log.dim(`\nRestart Claude Code for the hook to take effect.`); + return 0; +} diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 5a75109..5cb8045 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1,4 +1,5 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, writeFile, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { join } from "node:path"; import { loadConfig } from "../config.js"; import { syncLocal } from "../sources/local.js"; @@ -10,6 +11,30 @@ import type { PushResult } from "../remotes/types.js"; import { ensureClaudeAvailable, runClaude, buildAnalyzePrompt } from "../claude.js"; import { log, color } from "../log.js"; +const DEBOUNCE_FILE = ".cchist-last-sync"; + +/** + * Returns true if enough time has passed since the last sync (i.e. we should + * proceed), or false if we are still within the debounce window. + */ +async function shouldRunDebounced(storage: string, debounceSeconds: number): Promise { + const stampPath = join(storage, DEBOUNCE_FILE); + if (!existsSync(stampPath)) return true; + try { + const raw = await readFile(stampPath, "utf8"); + const lastMs = parseInt(raw.trim(), 10); + if (isNaN(lastMs)) return true; + return Date.now() - lastMs >= debounceSeconds * 1000; + } catch { + // Unreadable stamp → treat as stale, proceed with sync + return true; + } +} + +async function recordSyncTime(storage: string): Promise { + await writeFile(join(storage, DEBOUNCE_FILE), String(Date.now()), "utf8"); +} + export async function runSync(opts: { config?: string; analyze?: boolean; @@ -18,10 +43,21 @@ export async function runSync(opts: { skipRemote?: boolean; remoteOnly?: boolean; dryRun?: boolean; + /** Skip sync if it already ran within this many seconds (0 = disabled) */ + debounce?: number; }): Promise { const config = await loadConfig(opts.config); await mkdir(config.storage, { recursive: true }); + // Debounce: skip if we already synced within the requested window + if (opts.debounce && opts.debounce > 0 && !opts.dryRun) { + const proceed = await shouldRunDebounced(config.storage, opts.debounce); + if (!proceed) { + log.dim(`[debounce] last sync was within ${opts.debounce}s — skipping`); + return 0; + } + } + const filter = opts.only && opts.only.length ? new Set(opts.only) : null; const targetSources = config.sources.filter((s) => !filter || filter.has(s.name)); @@ -85,6 +121,11 @@ export async function runSync(opts: { log.header(`${color.bold}Summary${color.reset}: ${successCount}/${results.length} sources synced`); } + // Record sync timestamp for debounce (even on partial success, to avoid retry storms) + if (!opts.dryRun && opts.debounce && opts.debounce > 0) { + await recordSyncTime(config.storage); + } + // Remote push (S3 / R2 / S3-compatible) const pushResults: PushResult[] = []; if (targetRemotes.length > 0) {