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}