| 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 | |
| 17 | import { execFileSync } from "node:child_process"; |
| 18 | import { existsSync, readFileSync, writeFileSync } from "node:fs"; |
| 19 | import { homedir } from "node:os"; |
| 20 | import { join } from "node:path"; |
| 21 | |
| 22 | const HOME = join(homedir(), ".g1t"); |
| 23 | const CONFIG = join(HOME, "config.json"); |
| 24 | const CACHE = join(HOME, "pulls.json"); |
| 25 | /** The longest any one entry is sent. */ |
| 26 | const MAX_TEXT = 4000; |
| 27 | |
| 28 | function read(path, fallback) { |
| 29 | try { |
| 30 | return JSON.parse(readFileSync(path, "utf8")); |
| 31 | } catch { |
| 32 | return fallback; |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | function git(cwd, ...args) { |
| 37 | try { |
| 38 | return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); |
| 39 | } catch { |
| 40 | return ""; |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | function 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. */ |
| 50 | function 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 | */ |
| 63 | async 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. */ |
| 98 | function 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 | |
| 117 | async 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 | |
| 148 | main().catch(() => {}).finally(() => process.exit(0)); |