Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` を手動実行してください。

---

## 動作原理
Expand Down Expand Up @@ -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 にエクスポート
Expand Down
16 changes: 16 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 <seconds>", "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 }));
});
Expand Down Expand Up @@ -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 <seconds>",
"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);
Expand Down
133 changes: 133 additions & 0 deletions src/commands/install-hook.ts
Original file line number Diff line number Diff line change
@@ -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<string, HookMatcher[]>;
[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<number> {
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;
}
43 changes: 42 additions & 1 deletion src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<boolean> {
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<void> {
await writeFile(join(storage, DEBOUNCE_FILE), String(Date.now()), "utf8");
}

export async function runSync(opts: {
config?: string;
analyze?: boolean;
Expand All @@ -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<number> {
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));

Expand Down Expand Up @@ -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) {
Expand Down
Loading