Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:42:05 +08:00
commit 2a050efe7f
17 changed files with 1342 additions and 0 deletions

39
hooks/bun.lock Normal file
View File

@@ -0,0 +1,39 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "git-hooks",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "latest",
},
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.50", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1" } }, "sha512-vHOLohUeiVadWl4eTAbw12ACIG1wZ/NN4ScLe8P/yrsldT1QkYwn6ndkoilaFBB2gIHECEx7wRAtSfCLefge4Q=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
}
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import { parseGitStatus } from "./git-context-loader";
describe("git-context-loader", () => {
test("parseGitStatus parses clean status", () => {
const output = "## main...origin/main";
const result = parseGitStatus(output);
expect(result.branch).toBe("main");
expect(result.status).toEqual({ staged: 0, modified: 0, untracked: 0 });
});
test("parseGitStatus parses dirty status", () => {
const output = `## feature/test...origin/feature/test [ahead 1]
M modified-file.ts
A staged-file.ts
?? untracked-file.ts
D deleted-file.ts`;
const result = parseGitStatus(output);
expect(result.branch).toBe("feature/test");
// M (modified), A (staged), ?? (untracked), D (unstaged delete -> modified)
// M -> index: M, worktree: space -> staged
// A -> index: A, worktree: space -> staged
// ?? -> untracked
// D -> index: space, worktree: D -> modified
// Let's check the logic in parseGitStatus:
// if (code.startsWith("?") || code === "??") untracked++
// else:
// if (code[0] !== " " && code[0] !== "?") staged++
// if (code[1] !== " " && code[1] !== "?") modified++
// M -> code="M " -> staged++
// A -> code="A " -> staged++
// ?? -> untracked++
// D -> code=" D" -> modified++
expect(result.status).toEqual({ staged: 2, modified: 1, untracked: 1 });
});
test("parseGitStatus handles detached head", () => {
const output = "## HEAD (no branch)";
const result = parseGitStatus(output);
expect(result.branch).toBe("HEAD (no branch)");
});
});

182
hooks/git-context-loader.ts Normal file
View File

@@ -0,0 +1,182 @@
#!/usr/bin/env bun
/**
* Git Context Loader Hook
*
* SessionStart hook that loads git context at the beginning of a session.
* Outputs recent commits, status, and open issues for Claude's awareness.
*/
import type { SessionStartHookInput } from "@anthropic-ai/claude-agent-sdk";
interface GitContext {
branch: string;
status: {
staged: number;
modified: number;
untracked: number;
};
recentCommits: string[];
openIssues?: string[];
}
async function exec(
command: string,
cwd: string,
): Promise<{ stdout: string; exitCode: number }> {
const proc = Bun.spawn(["sh", "-c", command], {
cwd,
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
return { stdout: stdout.trim(), exitCode };
}
async function isGitRepo(cwd: string): Promise<boolean> {
const { exitCode } = await exec("git rev-parse --git-dir", cwd);
return exitCode === 0;
}
export function parseGitStatus(statusOut: string) {
const lines = statusOut.split("\n");
const branchLine = lines.find((l) => l.startsWith("##"));
let branch = "(detached)";
if (branchLine) {
const parsed = branchLine.substring(3).split("...")[0];
if (parsed) branch = parsed.trim();
}
let staged = 0;
let modified = 0;
let untracked = 0;
for (const line of lines) {
if (line.startsWith("##") || !line.trim()) continue;
const code = line.substring(0, 2);
if (code.startsWith("?") || code === "??") {
untracked++;
} else {
if (code[0] !== " " && code[0] !== "?") staged++;
if (code[1] !== " " && code[1] !== "?") modified++;
}
}
return { branch, status: { staged, modified, untracked } };
}
async function getGitContext(cwd: string): Promise<GitContext | null> {
if (!(await isGitRepo(cwd))) {
return null;
}
// Get status and branch in one go
const { stdout: statusOut } = await exec(
"git status --porcelain -b 2>/dev/null",
cwd,
);
const { branch, status } = parseGitStatus(statusOut);
// Get recent commits
const { stdout: commitsOut } = await exec(
'git log --oneline -5 --format="%h %s (%ar)" 2>/dev/null',
cwd,
);
const recentCommits = commitsOut
.split("\n")
.filter((line) => line.trim() !== "");
const context: GitContext = {
branch: branch || "(detached)",
status,
recentCommits,
};
// Check for open issues if gh is available and authenticated
const { exitCode: ghAuthCheck } = await exec("gh auth status", cwd);
if (ghAuthCheck === 0) {
const { stdout: issuesOut, exitCode: issuesCode } = await exec(
"gh issue list --limit 3 --state open 2>/dev/null",
cwd,
);
if (issuesCode === 0 && issuesOut.trim()) {
context.openIssues = issuesOut
.split("\n")
.filter((line) => line.trim() !== "");
}
}
return context;
}
function formatContext(context: GitContext): string {
const { branch, status, recentCommits, openIssues } = context;
let output = "Git Context:\n";
output += ` Branch: ${branch}\n`;
output += ` Status: ${status.staged} staged, ${status.modified} modified, ${status.untracked} untracked\n`;
output += "\nRecent commits:\n";
if (recentCommits.length > 0) {
recentCommits.forEach((commit) => {
output += ` ${commit}\n`;
});
} else {
output += " (no commits yet)\n";
}
if (openIssues && openIssues.length > 0) {
output += "\nOpen issues (top 3):\n";
openIssues.forEach((issue) => {
output += ` ${issue}\n`;
});
}
return output;
}
function formatSystemMessage(context: GitContext): string {
const { branch, status, recentCommits } = context;
const totalChanges = status.staged + status.modified + status.untracked;
const changesStr = totalChanges > 0 ? `, ${totalChanges} changes` : "";
const lastCommit =
recentCommits[0]?.split(" ").slice(1).join(" ") || "no commits";
return `Git: ${branch}${changesStr} | Last: ${lastCommit}`;
}
interface HookOutput {
systemMessage?: string;
hookSpecificOutput: {
hookEventName: string;
additionalContext: string;
};
}
// Main execution
if (import.meta.main) {
const input = (await Bun.stdin.json()) as SessionStartHookInput;
const { cwd, source } = input;
// Only run on startup, not on resume/clear/compact
if (source === "startup") {
const context = await getGitContext(cwd);
if (context) {
const output: HookOutput = {
systemMessage: formatSystemMessage(context),
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: formatContext(context),
},
};
console.log(JSON.stringify(output));
}
}
process.exit(0);
}

32
hooks/hooks.json Normal file
View File

@@ -0,0 +1,32 @@
{
"description": "Git intelligence hooks for session context and summaries",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/install-deps.sh",
"timeout": 30
},
{
"type": "command",
"command": "bun run ${CLAUDE_PLUGIN_ROOT}/hooks/git-context-loader.ts",
"timeout": 10
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bun run ${CLAUDE_PLUGIN_ROOT}/hooks/session-summary.ts",
"timeout": 5
}
]
}
]
}
}

200
hooks/hooks.test.ts Normal file
View File

@@ -0,0 +1,200 @@
import { describe, expect, test } from "bun:test";
import type {
PreCompactHookInput,
SessionStartHookInput,
} from "@anthropic-ai/claude-agent-sdk";
// Test the hook input types and basic functionality
describe("SessionStartHookInput type", () => {
test("has correct shape", () => {
const input: SessionStartHookInput = {
session_id: "test-session-123",
transcript_path: "/tmp/transcript",
cwd: "/Users/test/code",
hook_event_name: "SessionStart",
source: "startup",
};
expect(input.session_id).toBe("test-session-123");
expect(input.cwd).toBe("/Users/test/code");
expect(input.hook_event_name).toBe("SessionStart");
expect(input.source).toBe("startup");
});
test("source can be startup, resume, clear, or compact", () => {
const sources: SessionStartHookInput["source"][] = [
"startup",
"resume",
"clear",
"compact",
];
sources.forEach((source) => {
const input: SessionStartHookInput = {
session_id: "test",
transcript_path: "/tmp",
cwd: "/tmp",
hook_event_name: "SessionStart",
source,
};
expect(input.source).toBe(source);
});
});
});
describe("PreCompactHookInput type", () => {
test("has correct shape", () => {
const input: PreCompactHookInput = {
session_id: "test-session-123",
transcript_path: "/tmp/transcript",
cwd: "/Users/test/code",
hook_event_name: "PreCompact",
trigger: "auto",
custom_instructions: null,
};
expect(input.session_id).toBe("test-session-123");
expect(input.hook_event_name).toBe("PreCompact");
expect(input.trigger).toBe("auto");
expect(input.custom_instructions).toBeNull();
});
test("trigger can be manual or auto", () => {
const triggers: PreCompactHookInput["trigger"][] = ["manual", "auto"];
triggers.forEach((trigger) => {
const input: PreCompactHookInput = {
session_id: "test",
transcript_path: "/tmp",
cwd: "/tmp",
hook_event_name: "PreCompact",
trigger,
custom_instructions: null,
};
expect(input.trigger).toBe(trigger);
});
});
test("custom_instructions can be string or null", () => {
const withInstructions: PreCompactHookInput = {
session_id: "test",
transcript_path: "/tmp",
cwd: "/tmp",
hook_event_name: "PreCompact",
trigger: "manual",
custom_instructions: "Focus on the auth changes",
};
const withoutInstructions: PreCompactHookInput = {
session_id: "test",
transcript_path: "/tmp",
cwd: "/tmp",
hook_event_name: "PreCompact",
trigger: "auto",
custom_instructions: null,
};
expect(withInstructions.custom_instructions).toBe(
"Focus on the auth changes",
);
expect(withoutInstructions.custom_instructions).toBeNull();
});
});
describe("git-context-loader", () => {
test("can be run with valid input", async () => {
const input: SessionStartHookInput = {
session_id: "test-123",
transcript_path: "/tmp/transcript",
cwd: import.meta.dir, // Use the hooks directory (which is in a git repo)
hook_event_name: "SessionStart",
source: "startup",
};
// Run the actual hook script using echo to pipe input
const proc = Bun.spawn(
[
"sh",
"-c",
`echo '${JSON.stringify(input)}' | bun run git-context-loader.ts`,
],
{
cwd: import.meta.dir,
stdout: "pipe",
stderr: "pipe",
},
);
// Wait for process to complete
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
expect(exitCode).toBe(0);
// Should output git context
expect(stdout).toContain("Git Context:");
expect(stdout).toContain("Branch:");
});
test("skips non-startup sources", async () => {
const input: SessionStartHookInput = {
session_id: "test-123",
transcript_path: "/tmp/transcript",
cwd: import.meta.dir,
hook_event_name: "SessionStart",
source: "resume", // Not startup
};
const proc = Bun.spawn(
[
"sh",
"-c",
`echo '${JSON.stringify(input)}' | bun run git-context-loader.ts`,
],
{
cwd: import.meta.dir,
stdout: "pipe",
stderr: "pipe",
},
);
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
expect(exitCode).toBe(0);
// Should NOT output git context for resume
expect(stdout).not.toContain("Git Context:");
});
});
describe("session-summary", () => {
test("can be run with valid input", async () => {
const input: PreCompactHookInput = {
session_id: "test-123",
transcript_path: "/tmp/transcript",
cwd: import.meta.dir,
hook_event_name: "PreCompact",
trigger: "manual",
custom_instructions: null,
};
const proc = Bun.spawn(
[
"sh",
"-c",
`echo '${JSON.stringify(input)}' | bun run session-summary.ts`,
],
{
cwd: import.meta.dir,
stdout: "pipe",
stderr: "pipe",
},
);
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
expect(exitCode).toBe(0);
expect(stdout).toContain("Session summary saved");
});
});

23
hooks/install-deps.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
# Install MCP server and hooks dependencies on session start
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_ROOT="$(dirname "$SCRIPT_DIR")"
MCP_DIR="$PLUGIN_ROOT/mcp-servers/git-intelligence"
HOOKS_DIR="$PLUGIN_ROOT/hooks"
# Install MCP server dependencies
if [ ! -d "$MCP_DIR/node_modules" ]; then
cd "$MCP_DIR"
bun install --silent 2>/dev/null || bun install
fi
# Install hooks dependencies (for TypeScript types)
if [ ! -d "$HOOKS_DIR/node_modules" ]; then
cd "$HOOKS_DIR"
bun install --silent 2>/dev/null || bun install
fi
exit 0

15
hooks/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "git-hooks",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "latest"
},
"devDependencies": {
"bun-types": "^1.3.3"
}
}

158
hooks/session-summary.ts Normal file
View File

@@ -0,0 +1,158 @@
#!/usr/bin/env bun
/**
* Session Summary Hook
*
* PreCompact hook that saves a session summary before context compaction.
* Helps maintain continuity across context windows.
*/
import { existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { PreCompactHookInput } from "@anthropic-ai/claude-agent-sdk";
interface SessionSummary {
timestamp: string;
branch: string;
trigger: "manual" | "auto";
sessionCommits: string[];
uncommittedChanges: {
staged: string;
modified: string;
};
}
async function exec(
command: string,
cwd: string,
): Promise<{ stdout: string; exitCode: number }> {
const proc = Bun.spawn(["sh", "-c", command], {
cwd,
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
return { stdout: stdout.trim(), exitCode };
}
async function isGitRepo(cwd: string): Promise<boolean> {
const { exitCode } = await exec("git rev-parse --git-dir", cwd);
return exitCode === 0;
}
async function getGitRoot(cwd: string): Promise<string | null> {
const { stdout, exitCode } = await exec("git rev-parse --show-toplevel", cwd);
return exitCode === 0 ? stdout : null;
}
async function getSessionSummary(
cwd: string,
trigger: "manual" | "auto",
): Promise<SessionSummary | null> {
if (!(await isGitRepo(cwd))) {
return null;
}
// Get branch
const { stdout: branch } = await exec(
"git branch --show-current 2>/dev/null || echo '(detached)'",
cwd,
);
// Get commits from the last hour (approximate session length)
const { stdout: commitsOut } = await exec(
'git log --oneline --since="1 hour ago" 2>/dev/null | head -10',
cwd,
);
const sessionCommits = commitsOut
.split("\n")
.filter((line) => line.trim() !== "");
// Get staged changes summary
const { stdout: stagedStat } = await exec(
"git diff --cached --stat 2>/dev/null | tail -1",
cwd,
);
// Get modified changes summary
const { stdout: modifiedStat } = await exec(
"git diff --stat 2>/dev/null | tail -1",
cwd,
);
return {
timestamp: new Date().toISOString(),
branch: branch || "(detached)",
trigger,
sessionCommits,
uncommittedChanges: {
staged: stagedStat || "none",
modified: modifiedStat || "none",
},
};
}
function formatSummary(summary: SessionSummary): string {
let output = "# Claude Session Summary\n";
output += `# Generated: ${summary.timestamp}\n`;
output += `# Branch: ${summary.branch}\n`;
output += `# Trigger: ${summary.trigger}\n\n`;
output += "## Session Activity\n\n";
if (summary.sessionCommits.length > 0) {
output += "### Commits this session:\n";
summary.sessionCommits.forEach((commit) => {
output += `- ${commit}\n`;
});
output += "\n";
}
if (
summary.uncommittedChanges.staged !== "none" ||
summary.uncommittedChanges.modified !== "none"
) {
output += "### Uncommitted changes:\n";
if (summary.uncommittedChanges.staged !== "none") {
output += `Staged: ${summary.uncommittedChanges.staged}\n`;
}
if (summary.uncommittedChanges.modified !== "none") {
output += `Modified: ${summary.uncommittedChanges.modified}\n`;
}
output += "\n";
}
return output;
}
// Main execution
const input = (await Bun.stdin.json()) as PreCompactHookInput;
const { cwd, trigger } = input;
const gitRoot = await getGitRoot(cwd);
if (gitRoot) {
const summary = await getSessionSummary(cwd, trigger);
if (summary) {
// Write to ~/.claude/session-summaries/ to avoid polluting user repos
const claudeDir = join(homedir(), ".claude", "session-summaries");
if (!existsSync(claudeDir)) {
mkdirSync(claudeDir, { recursive: true });
}
// Use repo name as filename to keep summaries separate per project
const repoName = gitRoot.split("/").pop() || "unknown";
const summaryPath = join(claudeDir, `${repoName}.md`);
const content = formatSummary(summary);
await Bun.write(summaryPath, content);
console.log(`Session summary saved to ${summaryPath}`);
}
}
process.exit(0);