flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/public/install/hook.mjs

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