main
  1/**
  2 * Shared utilities for Claude Code hooks.
  3 * All hooks write to unified AI storage: ~/.local/share/ai/
  4 */
  5
  6import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from "node:fs";
  7import { join, basename } from "node:path";
  8import { homedir, hostname } from "node:os";
  9
 10// ── Paths ──────────────────────────────────────────────────────────
 11
 12export const HOME = homedir();
 13export const AI_DATA_DIR = join(HOME, ".local", "share", "ai");
 14export const SESSIONS_DIR = join(AI_DATA_DIR, "sessions");
 15export const RESEARCH_DIR = join(AI_DATA_DIR, "research");
 16export const PLANS_DIR = join(AI_DATA_DIR, "plans");
 17export const LEARNINGS_DIR = join(AI_DATA_DIR, "learnings");
 18
 19// Tool-output capture stays under claude config (not shared across tools)
 20export const CLAUDE_DIR = process.env.CLAUDE_DIR || join(HOME, ".config", "claude");
 21export const TOOL_OUTPUTS_DIR = join(CLAUDE_DIR, "history", "tool-outputs");
 22
 23// ── Date helpers (local time, matching pi ai-storage format) ───────
 24
 25export function now() {
 26  return new Date();
 27}
 28
 29/** Pad to 2 digits */
 30function p2(n: number): string {
 31  return String(n).padStart(2, "0");
 32}
 33
 34/** Local timezone offset string, e.g. "+01:00" or "-05:00" */
 35function tzOffset(d: Date): string {
 36  const off = -d.getTimezoneOffset();
 37  const sign = off >= 0 ? "+" : "-";
 38  const h = p2(Math.floor(Math.abs(off) / 60));
 39  const m = p2(Math.abs(off) % 60);
 40  return `${sign}${h}:${m}`;
 41}
 42
 43export function yearMonth(d: Date = now()): string {
 44  return `${d.getFullYear()}-${p2(d.getMonth() + 1)}`;
 45}
 46
 47export function dateStr(d: Date = now()): string {
 48  return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
 49}
 50
 51/** ISO-ish local timestamp: YYYY-MM-DDTHH:MM:SS+TZ:TZ */
 52export function localTimestamp(d: Date = now()): string {
 53  return `${dateStr(d)}T${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}${tzOffset(d)}`;
 54}
 55
 56/** HH:MM */
 57export function timeStr(d: Date = now()): string {
 58  return `${p2(d.getHours())}:${p2(d.getMinutes())}`;
 59}
 60
 61// ── Subagent detection ─────────────────────────────────────────────
 62
 63export function isSubagent(): boolean {
 64  const projectDir = process.env.CLAUDE_PROJECT_DIR || "";
 65  if (projectDir.includes("/.claude/agents/")) return true;
 66  if (process.env.CLAUDE_AGENT_TYPE) return true;
 67  return false;
 68}
 69
 70// ── Debounce via lockfile ──────────────────────────────────────────
 71
 72export function shouldDebounce(name: string, ms: number = 2000): boolean {
 73  const lockfile = join("/tmp", `claude-${name}.lock`);
 74  const nowMs = Date.now();
 75
 76  if (existsSync(lockfile)) {
 77    try {
 78      const prev = parseInt(readFileSync(lockfile, "utf-8").trim(), 10);
 79      if (nowMs - prev < ms) return true;
 80    } catch {}
 81  }
 82
 83  try {
 84    writeFileSync(lockfile, String(nowMs));
 85  } catch {}
 86  return false;
 87}
 88
 89// ── Terminal title ─────────────────────────────────────────────────
 90
 91export function setTerminalTitle(title: string): void {
 92  process.stderr.write(`\x1b]0;${title}\x07`);
 93  process.stderr.write(`\x1b]2;${title}\x07`);
 94  process.stderr.write(`\x1b]30;${title}\x07`);
 95}
 96
 97// ── File helpers ───────────────────────────────────────────────────
 98
 99export function ensureDir(dir: string): void {
100  mkdirSync(dir, { recursive: true });
101}
102
103export function appendLog(file: string, line: string): void {
104  ensureDir(join(file, ".."));
105  appendFileSync(file, line);
106}
107
108export function sessionLogPath(d: Date = now()): string {
109  return join(SESSIONS_DIR, yearMonth(d), `${dateStr(d)}_session-log_${host()}.txt`);
110}
111
112// ── Read stdin (for hooks that receive JSON) ───────────────────────
113
114export async function readStdin(): Promise<string> {
115  const chunks: Buffer[] = [];
116  for await (const chunk of process.stdin) {
117    chunks.push(chunk);
118  }
119  return Buffer.concat(chunks).toString("utf-8");
120}
121
122export async function readStdinJSON<T = any>(): Promise<T | null> {
123  try {
124    const raw = await readStdin();
125    if (!raw.trim()) return null;
126    return JSON.parse(raw) as T;
127  } catch {
128    return null;
129  }
130}
131
132// ── Hostname ───────────────────────────────────────────────────────
133
134export function host(): string {
135  return hostname();
136}
137
138// ── Slug helpers ───────────────────────────────────────────────────
139
140/**
141 * Normalise a description into a filename-safe slug.
142 * Target format: YYYY-MM-DD-description-with-dashes.md
143 */
144export function slugify(s: string): string {
145  return s
146    .toLowerCase()
147    .replace(/[^a-z0-9]+/g, "-")
148    .replace(/^-+|-+$/g, "")
149    .substring(0, 60);
150}