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