Skip to content
148 linesCodeBlameRaw
1#!/usr/bin/env node
2// g1t's Claude Code hook: records what your agent does onto the pull
3// request you are working on, so its session sits beside the code.
4//
5// Claude Code runs this for UserPromptSubmit, PostToolUse and Stop, with
6// the event as JSON on stdin. It works out the pull request from the
7// working copy's git remote: a g1t fork (g1t.sh/pulls/<id>) or a branch
8// of a g1t repository with an open pull request. Anywhere else it does
9// nothing. It never fails the agent's turn.
10//
11// It records what each tool was called with, never what it returned.
12//
13// Installed by an earlier `curl -fsSL https://g1t.sh/install/claude.sh | sh`.
14// g1t's Claude Code plugin replaces it:
15// https://docs.g1t.sh/guides/claude-code-plugin/
16
17import { execFileSync } from "node:child_process";
18import { existsSync, readFileSync, writeFileSync } from "node:fs";
19import { homedir } from "node:os";
20import { join } from "node:path";
21
22const HOME = join(homedir(), ".g1t");
23const CONFIG = join(HOME, "config.json");
24const CACHE = join(HOME, "pulls.json");
25/** The longest any one entry is sent. */
26const MAX_TEXT = 4000;
27
28function read(path, fallback) {
29 try {
30 return JSON.parse(readFileSync(path, "utf8"));
31 } catch {
32 return fallback;
33 }
34}
35
36function git(cwd, ...args) {
37 try {
38 return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
39 } catch {
40 return "";
41 }
42}
43
44function clip(text) {
45 const value = typeof text === "string" ? text : JSON.stringify(text ?? "");
46 return value.length > MAX_TEXT ? `${value.slice(0, MAX_TEXT)}…` : value;
47}
48
49/** What a tool call did, in one line where possible. */
50function describeCall(name, input = {}) {
51 if (input.command) return input.command;
52 if (input.file_path) return input.file_path;
53 if (input.pattern) return `${input.pattern}${input.path ? ` in ${input.path}` : ""}`;
54 if (input.url) return input.url;
55 return clip(input);
56}
57
58/**
59 * The pull request this working copy belongs to, if any. Only a pull
60 * request found is cached: a branch whose pull request is opened later in
61 * the session is looked up again, and recorded from then on.
62 */
63async function pullFor(cwd, config) {
64 const remote = git(cwd, "remote", "get-url", "origin");
65 const branch = git(cwd, "branch", "--show-current");
66 if (!remote.includes("g1t.sh/")) return null;
67 const key = `${remote}#${branch}`;
68 const cache = read(CACHE, {});
69 if (cache[key]) return cache[key];
70 const headers = { authorization: `Bearer ${config.token}` };
71 let found = null;
72 const fork = /g1t\.sh\/pulls\/([a-z0-9_]+?)(?:\.git)?$/.exec(remote);
73 const repo = /g1t\.sh\/([a-z0-9-]+)\/([a-z0-9._-]+?)(?:\.git)?$/i.exec(remote);
74 if (fork) {
75 const response = await fetch(`${config.api}/pulls/${fork[1]}`, { headers });
76 if (response.ok) {
77 const pull = await response.json();
78 found = { repo: `${pull.repo.namespace}/${pull.repo.name}`, number: pull.number };
79 }
80 } else if (repo && branch) {
81 const response = await fetch(`${config.api}/repos/${repo[1]}/${repo[2]}/pulls?state=open`, { headers });
82 if (response.ok) {
83 const pulls = await response.json();
84 const pull = (Array.isArray(pulls) ? pulls : []).find((p) => p.branch === branch);
85 if (pull) found = { repo: `${repo[1]}/${repo[2]}`, number: pull.number };
86 }
87 }
88 if (found) {
89 cache[key] = found;
90 try {
91 writeFileSync(CACHE, JSON.stringify(cache));
92 } catch {}
93 }
94 return found;
95}
96
97/** The agent's last words this turn, from its transcript. */
98function lastMessage(transcriptPath) {
99 if (!transcriptPath || !existsSync(transcriptPath)) return null;
100 const lines = readFileSync(transcriptPath, "utf8").trim().split("\n").reverse();
101 for (const line of lines) {
102 try {
103 const entry = JSON.parse(line);
104 if (entry.type !== "assistant") continue;
105 const content = entry.message?.content;
106 const text = Array.isArray(content)
107 ? content.filter((part) => part.type === "text").map((part) => part.text).join("\n")
108 : typeof content === "string"
109 ? content
110 : "";
111 if (text.trim()) return text.trim();
112 } catch {}
113 }
114 return null;
115}
116
117async function main() {
118 const config = read(CONFIG, null);
119 if (!config?.token) return;
120 let raw = "";
121 for await (const chunk of process.stdin) raw += chunk;
122 const event = JSON.parse(raw || "{}");
123 const pull = await pullFor(event.cwd ?? process.cwd(), config);
124 if (!pull) return;
125
126 const entries = [];
127 switch (event.hook_event_name) {
128 case "UserPromptSubmit":
129 if (event.prompt && process.env.G1T_RECORD_PROMPTS !== "0") entries.push({ kind: "prompt", text: clip(event.prompt) });
130 break;
131 case "PostToolUse":
132 entries.push({ kind: "tool_call", tool: event.tool_name, text: clip(describeCall(event.tool_name, event.tool_input)) });
133 break;
134 case "Stop": {
135 const message = lastMessage(event.transcript_path);
136 if (message) entries.push({ kind: "message", text: clip(message) });
137 break;
138 }
139 }
140 if (entries.length === 0) return;
141 await fetch(`${config.api}/repos/${pull.repo}/pulls/${pull.number}/session`, {
142 method: "POST",
143 headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
144 body: JSON.stringify({ entries }),
145 });
146}
147
148main().catch(() => {}).finally(() => process.exit(0));