Merge branch 'worktree-agent-ab3c375781462035f'
25 files+995−1060/25 viewed
| 7 | 7 | "name": "g1t", | |
| 8 | 8 | "source": "./plugins/claude-code", | |
| 9 | 9 | "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.", | |
| 10 | − | "version": "0.2.0", | |
| 10 | + | "version": "0.2.1", | |
| 11 | 11 | "category": "development" | |
| 12 | 12 | } | |
| 13 | 13 | ] |
| 1320 | 1320 | ||
| 1321 | 1321 | [[package]] | |
| 1322 | 1322 | name = "g1t" | |
| 1323 | − | version = "0.3.1" | |
| 1323 | + | version = "0.3.2" | |
| 1324 | 1324 | dependencies = [ | |
| 1325 | 1325 | "anstyle-query", | |
| 1326 | 1326 | "base64 0.22.1", |
| 99 | 99 | ```sh | |
| 100 | 100 | # 1. Create an account and a workspace at https://g1t.sh/register (you need an invite). | |
| 101 | 101 | ||
| 102 | − | # 2. Connect Claude Code, then run /mcp in it to sign in through your browser. | |
| 103 | − | claude mcp add --transport http g1t https://mcp.g1t.sh | |
| 102 | + | # 2. Install g1t's Claude Code plugin (it adds g1t's MCP server and records | |
| 103 | + | # your sessions), then run /mcp in Claude Code and sign plugin:g1t:g1t in. | |
| 104 | + | claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git | |
| 105 | + | claude plugin install g1t@g1t | |
| 104 | 106 | ||
| 105 | 107 | # 3. Ask it to open a pull request for an open issue. | |
| 106 | 108 | ``` |
| 143 | 143 | "endpoint": addresses.mcp, | |
| 144 | 144 | "transport": "streamable-http", | |
| 145 | 145 | "protocol_versions": SUPPORTED_VERSIONS, | |
| 146 | + | // For Claude Code, g1t's plugin comes first: it adds this server | |
| 147 | + | // itself (as plugin:g1t:g1t), so `connect` is for Claude Code | |
| 148 | + | // without the plugin; with both, every tool is listed twice. | |
| 146 | 149 | "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp), | |
| 150 | + | "claude_code_plugin": { | |
| 151 | + | "install": [ | |
| 152 | + | "claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git", | |
| 153 | + | "claude plugin install g1t@g1t", | |
| 154 | + | ], | |
| 155 | + | "note": "The plugin adds this server (listed in /mcp as plugin:g1t:g1t) and records your sessions on g1t. With it installed, skip `connect`, or each tool is listed twice.", | |
| 156 | + | "documentation_url": "https://docs.g1t.sh/guides/claude-code-plugin/", | |
| 157 | + | }, | |
| 147 | 158 | "authorization": { | |
| 148 | 159 | "required": true, | |
| 149 | 160 | "oauth_protected_resource": addresses.protected_resource(), |
| 26 | 26 | ## Connect your coding agent | |
| 27 | 27 | ||
| 28 | 28 | Choose your agent. Every block on these pages follows the choice, and it is | |
| 29 | − | remembered for your next visit. | |
| 29 | + | remembered for your next visit. For Claude Code, install g1t's | |
| 30 | + | [Claude Code plugin](/guides/claude-code-plugin/): it adds the MCP server | |
| 31 | + | and records your sessions, so `claude mcp add` is only for Claude Code | |
| 32 | + | without it. | |
| 30 | 33 | ||
| 31 | 34 | <AgentSetup /> | |
| 32 | 35 | ||
| ⋯ | |||
| 290 | 293 | | `repository` | Repositories, their settings, rulesets, branches, labels, milestones and releases | | |
| 291 | 294 | | `issue` | Issues, labels and comments; an issue's whole context, and claims | | |
| 292 | 295 | | `pull_request` | Pull requests: create, record and read sessions, review, merge, the merge queue | | |
| 293 | − | | `session` | Your harness's session: start, heartbeat, record, end; who else is working | | |
| 296 | + | | `session` | Your harness's session: start, update, record, end, get, list; dispatch work on an issue; who else is working | | |
| 294 | 297 | | `memory` | `recall` and `remember` | | |
| 295 | 298 | | `workflow` | Workflow runs and logs, checks, deployments, environments and runners | | |
| 296 | 299 | | `observability` | A project's requests, logs, traces, errors and metrics in production | | |
| 20 | 20 | ||
| 21 | 21 | ## Install | |
| 22 | 22 | ||
| 23 | − | You need Claude Code, Node 18 or later (Claude Code runs on it) and the | |
| 24 | − | [g1t CLI](/guides/cli/), which the plugin's hooks use. | |
| 23 | + | You need Claude Code, the [g1t CLI](/guides/cli/) 0.3.2 or later, which | |
| 24 | + | the plugin's hooks use, and Node 18 or later on your `PATH`, which runs | |
| 25 | + | them. Claude Code's native installer does not bring Node with it: run | |
| 26 | + | `node --version` to check, and install it from | |
| 27 | + | [nodejs.org](https://nodejs.org) if it is missing or older. | |
| 25 | 28 | ||
| 26 | 29 | 1. Install the g1t CLI and sign in, if you have not: | |
| 27 | 30 | ||
| ⋯ | |||
| 41 | 44 | ||
| 42 | 45 | Or run `/plugin` inside Claude Code and choose the marketplace there. | |
| 43 | 46 | ||
| 44 | − | 3. Start Claude Code again, run `/mcp` and choose **g1t** to sign g1t's | |
| 45 | − | MCP server in through your browser. | |
| 47 | + | 3. Start Claude Code again, run `/mcp` and choose **plugin:g1t:g1t** to | |
| 48 | + | sign g1t's MCP server in through your browser. | |
| 49 | + | ||
| 50 | + | The plugin adds g1t's MCP server itself. If you added it before with | |
| 51 | + | `claude mcp add … g1t https://mcp.g1t.sh`, remove that one | |
| 52 | + | (`claude mcp remove g1t`), or Claude sees every g1t tool twice. | |
| 46 | 53 | ||
| 47 | 54 | `curl -fsSL https://g1t.sh/install/claude.sh | sh` prints the same | |
| 48 | 55 | commands; add `-s -- --cli` to have it install the CLI too. | |
| ⋯ | |||
| 61 | 68 | | A session starts or resumes | `g1t session start --harness claude_code --harness-session-id ID` | A session for this Claude Code session. Starting the same session again, as a resume does, finds it rather than making another. | | |
| 62 | 69 | | You send a prompt | `g1t session log --kind prompt TEXT` | Your prompt's first 2,000 characters. | | |
| 63 | 70 | | Claude uses a tool | `g1t session log --kind tool_call TEXT` | The tool's name and one line on what it was called with: the command, file, pattern or address, up to 200 characters, with anything that looks like a token or password replaced by `…`. | | |
| 64 | − | | Claude uses a skill | `g1t session log --kind skill NAME` | The skill's name, nothing more. | | |
| 71 | + | | Claude uses a skill, or you run one (`/g1t:work-on-issue 42`) | `g1t session log --kind skill NAME` | The skill's name, nothing more, once in a turn. A plugin's skill or command you type as `/plugin:name` is recorded even with prompts kept back. | | |
| 65 | 72 | | Claude writes a file, makes a branch or a commit, or claims an issue | `g1t session touch --file PATH` (or `--branch`, `--commit`, `--issue`) | What it touched: the path from the repository's root, the branch, the commit, the issue. g1t ties the session to the pull requests and issues these lead to, and tells two sessions that touch the same file. | | |
| 66 | 73 | | A `git push` reaches the remote | `g1t api update_session -f id=ID -f head_commit=SHA` and `g1t session touch --commit SHA --branch BRANCH` | The commit you pushed and its branch, so the session's entries line up with the code and it is tied to the branch's pull request. | | |
| 67 | 74 | | Claude starts a subagent | `g1t session start --parent SESSION --agent-type TYPE --task TASK --prompt PROMPT` | A session of its own for the subagent, under this one, with its kind and the task it was given (its prompt's first 4,000 characters). See [Subagents](#subagents). | | |
| ⋯ | |||
| 73 | 80 | ||
| 74 | 81 | - what a tool returned: command output, file contents, search results and | |
| 75 | 82 | web pages are never sent (an Agent call's answer is the one thing read, | |
| 76 | − | for its subagent's final message and id); | |
| 83 | + | for its subagent's final message and id), nor the turns Claude Code | |
| 84 | + | starts itself to hand Claude a background task's output; | |
| 77 | 85 | - Claude's replies or reasoning; | |
| 78 | 86 | - your files, environment or transcript. | |
| 79 | 87 | ||
| ⋯ | |||
| 84 | 92 | The hooks return at once: the CLI calls run in a background process, so a | |
| 85 | 93 | slow network never holds Claude up. | |
| 86 | 94 | ||
| 95 | + | Shell commands are read the same way from the Bash tool and, on Windows, | |
| 96 | + | the PowerShell tool: a `git commit`, a `git push`, a branch made or a | |
| 97 | + | `g1t work claim` in either is recorded. | |
| 98 | + | ||
| 99 | + | Once the session has started, the plugin sets `G1T_SESSION` for the | |
| 100 | + | commands Claude runs (through Claude Code's session environment), so | |
| 101 | + | Claude's own `g1t` commands act in this session even when another session | |
| 102 | + | works in the same working copy. A command run before the session's id | |
| 103 | + | arrives, in its first second, uses the working copy's session. | |
| 104 | + | ||
| 87 | 105 | ## The context Claude starts with | |
| 88 | 106 | ||
| 89 | 107 | When a session starts, the plugin looks for the issue it is working on: | |
| ⋯ | |||
| 125 | 143 | its claims. Each subagent's claim is its own, so siblings see each | |
| 126 | 144 | other's, and when two touch the same file both are told | |
| 127 | 145 | (`overlap.detected`). | |
| 146 | + | ||
| 147 | + | A subagent shares its parent's shell environment, so the plugin sets | |
| 148 | + | its session on each of its commands that runs `g1t` or names | |
| 149 | + | `G1T_SESSION`, as Claude Code lets a `PreToolUse` hook do: the command | |
| 150 | + | runs with `export G1T_SESSION=ses_…;` before it in Bash, or | |
| 151 | + | `$env:G1T_SESSION='ses_…';` in PowerShell. The plugin makes no | |
| 152 | + | permission decision: Claude Code checks your permission rules against | |
| 153 | + | the command as it runs, with that prefix, so a rule such as | |
| 154 | + | `Bash(g1t work claim:*)` may no longer match it and Claude Code asks | |
| 155 | + | instead. Other commands are left as Claude wrote them, and the session | |
| 156 | + | records each command as Claude wrote it. | |
| 128 | 157 | - **When it finishes** (`SubagentStop`), its session ends with its final | |
| 129 | 158 | message as its result. A background subagent's, or a late hook's, order | |
| 130 | 159 | does not matter: starting a subagent again finds the same session, and | |
| ⋯ | |||
| 156 | 185 | A claim says you are working on an issue. It is never a lock: a second | |
| 157 | 186 | claim is allowed, and whoever makes it is told about the first. | |
| 158 | 187 | ||
| 159 | − | ## When the CLI is missing or signed out | |
| 188 | + | ## When the CLI is missing, signed out or out of date | |
| 160 | 189 | ||
| 161 | − | If `g1t` is not installed, or not signed in, the hooks record nothing and | |
| 190 | + | If `g1t` is not installed, not signed in, or older than 0.3.2 (the oldest | |
| 191 | + | that takes every command the hooks run), the hooks record nothing and | |
| 162 | 192 | Claude Code says so once. The plugin never installs anything itself. | |
| 163 | 193 | Install the CLI or run `g1t auth login`, and the next session is recorded. | |
| 164 | 194 | ||
| 195 | + | To update the CLI, run its installer again: | |
| 196 | + | ||
| 197 | + | ```sh | |
| 198 | + | curl -fsSL https://g1t.sh/install | sh | |
| 199 | + | ``` | |
| 200 | + | ||
| 201 | + | On Windows, `irm https://g1t.sh/install.ps1 | iex`. `g1t --version` says | |
| 202 | + | which you have. A CLI that refuses a command the hooks send is taken as | |
| 203 | + | out of date, and said the same way. | |
| 204 | + | ||
| 205 | + | The hooks run on Node. Without Node 18 or later they record nothing: | |
| 206 | + | with an older Node, Claude Code says so once; with no `node` on your | |
| 207 | + | `PATH` at all, Claude Code shows each hook's failure (`node: command not | |
| 208 | + | found`) as a hook error. Install Node, or turn the plugin off until you | |
| 209 | + | do. | |
| 210 | + | ||
| 165 | 211 | ## Opt out | |
| 166 | 212 | ||
| 167 | 213 | | To | Do | | |
| 343 | 343 | ||
| 344 | 344 | | Command | What it does | | |
| 345 | 345 | | --- | --- | | |
| 346 | − | | `g1t session start` | Starts a session and prints its id. `--harness` (`claude-code`, `claude-managed-agents`, `codex`, `cursor`, `copilot`, `other`), `--harness-session-id`, `--issue`, `--pull`, `--branch` (the current one by default) and `--title`. Starting again with the same `--harness-session-id` returns the same session. For a subagent, `--parent` (the session that started it), `--agent-type`, `--task`, `--prompt` and `--model`; a subagent's session is not kept as the working copy's (nor is one with `--no-remember`). | | |
| 346 | + | | `g1t session start` | Starts a session and prints its id. `--harness` (`claude_code`, `codex`, `cursor`, `copilot` or `other`; `claude-code` works too; `claude_managed_agents` is only for the sessions g1t starts itself, with `g1t work dispatch`), `--harness-session-id`, `--issue`, `--pull`, `--branch` (the current one by default) and `--title`. Starting again with the same `--harness-session-id` returns the same session. For a subagent, `--parent` (the session that started it), `--agent-type`, `--task`, `--prompt` and `--model`; a subagent's session is not kept as the working copy's (nor is one with `--no-remember`). | | |
| 347 | 347 | | `g1t session heartbeat` | Says the session is still working, and renews its claims. A session with no heartbeat for 15 minutes reads as idle, and after 2 hours as ended. | | |
| 348 | − | | `g1t session log TEXT` | Records a note. `--kind` takes `note`, `prompt`, `message`, `tool-call` or `skill` (a skill's name), `--tool` names the tool, and `--key` makes recording the same entry twice do nothing. `-` reads the text from stdin; put `--` before text that starts with a dash. | | |
| 348 | + | | `g1t session log TEXT` | Records a note. `--kind` takes `note`, `prompt`, `message`, `tool_call` (or `tool-call`) or `skill` (a skill's name), `--tool` names the tool, and `--key` makes recording the same entry twice do nothing. `-` reads the text from stdin; put `--` before text that starts with a dash. | | |
| 349 | 349 | | `g1t session attach` | Points the session at an `--issue`, a `--pull`, a `--branch` or its latest `--head-commit`. | | |
| 350 | 350 | | `g1t session touch` | Says what the session touched: `--file PATH`, `--commit SHA`, `--branch NAME`, `--pull N` (one it opened), `--issue N`, each repeatable. Ties the session to the pull requests and issues they lead to; a file another active session touched too is an `overlap.detected` for both. | | |
| 351 | 351 | | `g1t session end` | Ends it, with an optional `--reason` and `--result` (what it reported; `-` reads it from stdin), and forgets it. Its subagents still going end with it. | |
| 265 | 265 | | Argument or flag | | | |
| 266 | 266 | | --- | --- | | |
| 267 | 267 | | `<NUMBER>` | | | |
| 268 | − | | `-r, --reason <REASON>` | One of `completed`, `not-planned`. | | |
| 268 | + | | `-r, --reason <REASON>` | One of `completed`, `not_planned`. | | |
| 269 | 269 | | `-c, --comment <COMMENT>` | Leave a comment as it closes. | | |
| 270 | 270 | ||
| 271 | 271 | Calls [`close_issue`](/reference/api/issues/close-issue/), [`add_comment`](/reference/api/issues/add-comment/). | |
| ⋯ | |||
| 936 | 936 | | Argument or flag | | | |
| 937 | 937 | | --- | --- | | |
| 938 | 938 | | `<TEXT>` | What to record; - reads it from stdin. | | |
| 939 | − | | `-k, --kind <KIND>` | One of `note`, `prompt`, `message`, `tool-call`, `skill`. Default: `note`. | | |
| 939 | + | | `-k, --kind <KIND>` | One of `note`, `prompt`, `message`, `tool_call`, `skill`. Default: `note`. | | |
| 940 | 940 | | `--tool <TOOL>` | The tool, for a tool_call. | | |
| 941 | 941 | | `--key <KEY>` | A key that makes recording it again do nothing. | | |
| 942 | 942 | ||
| 15 | 15 | ## Connect | |
| 16 | 16 | ||
| 17 | 17 | To connect Claude Code, Codex, OpenCode, Cursor or another client, see | |
| 18 | − | [connect a coding agent](/guides/bring-your-own-agent/). With Claude Code: | |
| 18 | + | [connect a coding agent](/guides/bring-your-own-agent/). With Claude Code, | |
| 19 | + | install g1t's [plugin](/guides/claude-code-plugin/), which adds the server | |
| 20 | + | (`plugin:g1t:g1t` in `/mcp`) and records your sessions: | |
| 19 | 21 | ||
| 20 | 22 | ```sh | |
| 21 | − | claude mcp add --transport http g1t https://mcp.g1t.sh | |
| 23 | + | claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git | |
| 24 | + | claude plugin install g1t@g1t | |
| 22 | 25 | ``` | |
| 23 | 26 | ||
| 27 | + | Without the plugin, `claude mcp add --transport http g1t https://mcp.g1t.sh` | |
| 28 | + | adds the server alone. Never both, or every tool is listed twice. | |
| 29 | + | ||
| 24 | 30 | The server speaks MCP over streamable HTTP, and answers every request with | |
| 25 | 31 | JSON. Every call needs to be signed in, in one of two ways: | |
| 26 | 32 |
| 6 | 6 | ||
| 7 | 7 | export const MCP_URL = 'https://mcp.g1t.sh'; | |
| 8 | 8 | ||
| 9 | + | /** Installs g1t's Claude Code plugin, which adds the MCP server (as `plugin:g1t:g1t`) and records sessions. */ | |
| 10 | + | export const PLUGIN_INSTALL = 'claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git\nclaude plugin install g1t@g1t'; | |
| 11 | + | ||
| 9 | 12 | export type AgentSetup = { | |
| 10 | 13 | label: string; | |
| 11 | 14 | /** The file the snippet goes in, or null for a terminal command. */ | |
| ⋯ | |||
| 20 | 23 | ||
| 21 | 24 | export const AGENTS: AgentSetup[] = [ | |
| 22 | 25 | { | |
| 26 | + | // g1t's plugin adds the MCP server itself, and records sessions. | |
| 23 | 27 | label: 'Claude Code', | |
| 24 | 28 | file: null, | |
| 25 | 29 | lang: 'sh', | |
| 26 | − | code: `claude mcp add --transport http g1t ${MCP_URL}`, | |
| 27 | − | then: 'Then run `/mcp` in Claude Code and choose g1t to sign in through your browser.', | |
| 30 | + | code: PLUGIN_INSTALL, | |
| 31 | + | then: `g1t's Claude Code plugin adds g1t's MCP server, and records your sessions on g1t. Start Claude Code again, run \`/mcp\` and choose \`plugin:g1t:g1t\` to sign in through your browser. With the plugin installed, skip \`claude mcp add\`: adding the server again lists every g1t tool twice. Without the plugin, \`claude mcp add --transport http g1t ${MCP_URL}\` adds the server alone.`, | |
| 28 | 32 | }, | |
| 29 | 33 | { | |
| 30 | 34 | label: 'Codex', | |
| ⋯ | |||
| 59 | 63 | file: null, | |
| 60 | 64 | lang: 'sh', | |
| 61 | 65 | code: `claude mcp add --transport http g1t ${MCP_URL} \\\n --header "Authorization: Bearer $G1T_TOKEN"`, | |
| 62 | − | then: '', | |
| 66 | + | then: "This adds the server without g1t's plugin. With the plugin installed, which adds it too, disable the plugin's (`plugin:g1t:g1t` in `/mcp`) or skip this, so g1t's tools are not listed twice.", | |
| 63 | 67 | }, | |
| 64 | 68 | { | |
| 65 | 69 | label: 'Codex', | |
| 83 | 83 | }); | |
| 84 | 84 | ||
| 85 | 85 | test("every snippet points at the MCP server", () => { | |
| 86 | − | for (const id of AGENT_IDS) assert.ok(AGENTS[id].code.includes(MCP_URL), id); | |
| 86 | + | // Claude Code's is g1t's plugin, which adds the server itself. | |
| 87 | + | for (const id of AGENT_IDS) if (id !== "claude-code") assert.ok(AGENTS[id].code.includes(MCP_URL), id); | |
| 88 | + | assert.match(AGENTS["claude-code"].code, /^claude plugin marketplace add https:\/\/g1t\.sh\/flagon-io\/g1t\.git\nclaude plugin install g1t@g1t$/); | |
| 89 | + | assert.match(AGENTS["claude-code"].then, /plugin:g1t:g1t/); | |
| 87 | 90 | assert.deepEqual(JSON.parse(AGENTS.cursor.code), { mcpServers: { g1t: { url: MCP_URL } } }); | |
| 88 | 91 | assert.equal(JSON.parse(AGENTS.opencode.code).mcp.g1t.type, "remote"); | |
| 89 | 92 | const config = new URL(cursorInstallLink()).searchParams.get("config"); |
| 14 | 14 | /** g1t.sh's MCP server, the same as HOSTED_ADDRESSES.mcp in ./addresses. */ | |
| 15 | 15 | export const MCP_URL = "https://mcp.g1t.sh"; | |
| 16 | 16 | ||
| 17 | + | /** Installs g1t's Claude Code plugin, which adds g1t.sh's MCP server and records sessions. */ | |
| 18 | + | export const PLUGIN_INSTALL = "claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git\nclaude plugin install g1t@g1t"; | |
| 19 | + | ||
| 17 | 20 | export const AGENT_IDS = ["claude-code", "codex", "opencode", "cursor"] as const; | |
| 18 | 21 | export type AgentId = (typeof AGENT_IDS)[number]; | |
| 19 | 22 | export const DEFAULT_AGENT: AgentId = "claude-code"; | |
| ⋯ | |||
| 38 | 41 | /** How each agent adds the MCP server at `mcp`. */ | |
| 39 | 42 | export function agentsFor(mcp: string = MCP_URL): Record<AgentId, AgentSetup> { | |
| 40 | 43 | return { | |
| 41 | − | "claude-code": { | |
| 42 | − | id: "claude-code", | |
| 43 | − | label: "Claude Code", | |
| 44 | − | file: null, | |
| 45 | − | lang: "sh", | |
| 46 | − | code: `claude mcp add --transport http g1t ${mcp}`, | |
| 47 | − | then: "Then run `/mcp` in Claude Code and choose g1t to sign in through your browser.", | |
| 48 | − | instructions: "CLAUDE.md", | |
| 49 | − | }, | |
| 44 | + | // On g1t.sh, g1t's plugin: it adds the MCP server itself (as | |
| 45 | + | // plugin:g1t:g1t) and records sessions. A self-hosted g1t's server is | |
| 46 | + | // added on its own. | |
| 47 | + | "claude-code": | |
| 48 | + | mcp === MCP_URL | |
| 49 | + | ? { | |
| 50 | + | id: "claude-code", | |
| 51 | + | label: "Claude Code", | |
| 52 | + | file: null, | |
| 53 | + | lang: "sh", | |
| 54 | + | code: PLUGIN_INSTALL, | |
| 55 | + | then: "The plugin adds g1t's MCP server and records your sessions. Start Claude Code again, run `/mcp` and choose `plugin:g1t:g1t` to sign in through your browser. Skip `claude mcp add` with the plugin installed, or every g1t tool is listed twice.", | |
| 56 | + | instructions: "CLAUDE.md", | |
| 57 | + | } | |
| 58 | + | : { | |
| 59 | + | id: "claude-code", | |
| 60 | + | label: "Claude Code", | |
| 61 | + | file: null, | |
| 62 | + | lang: "sh", | |
| 63 | + | code: `claude mcp add --transport http g1t ${mcp}`, | |
| 64 | + | then: "Then run `/mcp` in Claude Code and choose g1t to sign in through your browser.", | |
| 65 | + | instructions: "CLAUDE.md", | |
| 66 | + | }, | |
| 50 | 67 | codex: { | |
| 51 | 68 | id: "codex", | |
| 52 | 69 | label: "Codex", | |
| 32 | 32 | claude plugin marketplace add $BASE/flagon-io/g1t.git | |
| 33 | 33 | claude plugin install g1t@g1t | |
| 34 | 34 | ||
| 35 | − | The plugin's hooks use the g1t CLI. If you do not have it yet, install it | |
| 35 | + | The plugin's hooks use the g1t CLI (0.3.2 or later) and Node 18 or later. | |
| 36 | + | If you do not have the CLI yet, or have an older one, install it | |
| 36 | 37 | (curl -fsSL $BASE/install | sh) and sign in with \`g1t auth login\`. | |
| 37 | − | Then, in Claude Code, run /mcp and choose g1t to sign its tools in. | |
| 38 | + | Then, in Claude Code, run /mcp and choose plugin:g1t:g1t to sign its tools | |
| 39 | + | in. The plugin adds g1t's MCP server itself: skip \`claude mcp add\`. | |
| 38 | 40 | ||
| 39 | 41 | Guide: https://docs.g1t.sh/guides/claude-code-plugin/ | |
| 40 | 42 | EOF |
| 69 | 69 | --header "Authorization: Bearer $G1T_TOKEN" | |
| 70 | 70 | ``` | |
| 71 | 71 | ||
| 72 | + | Skip this if g1t's Claude Code plugin is installed (`g1t@g1t`): it adds | |
| 73 | + | the server itself, as `plugin:g1t:g1t`, and adding it again lists every | |
| 74 | + | tool twice. | |
| 75 | + | ||
| 72 | 76 | Codex, in `~/.codex/config.toml`: | |
| 73 | 77 | ||
| 74 | 78 | ```toml | |
| ⋯ | |||
| 93 | 97 | ||
| 94 | 98 | Without the header, a client that supports MCP authorization signs the | |
| 95 | 99 | person in through their browser instead (Claude Code: `/mcp`, then | |
| 96 | − | choose g1t; Codex: `codex mcp login g1t`; OpenCode: `opencode mcp auth | |
| 100 | + | choose g1t, or `plugin:g1t:g1t` with g1t's plugin; Codex: `codex mcp login g1t`; OpenCode: `opencode mcp auth | |
| 97 | 101 | g1t`; Cursor: when it first connects). The MCP server always needs one | |
| 98 | 102 | or the other. | |
| 99 | 103 | ||
| 1 | 1 | [package] | |
| 2 | 2 | name = "g1t" | |
| 3 | − | version = "0.3.1" | |
| 3 | + | version = "0.3.2" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | license.workspace = true | |
| 6 | 6 | repository.workspace = true |
| 365 | 365 | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 366 | 366 | pub enum CloseReason { | |
| 367 | 367 | Completed, | |
| 368 | + | // Spelled as the API spells it. | |
| 369 | + | #[value(name = "not_planned", alias = "not-planned")] | |
| 368 | 370 | NotPlanned, | |
| 369 | 371 | } | |
| 370 | 372 | ||
| ⋯ | |||
| 1030 | 1032 | Note, | |
| 1031 | 1033 | Prompt, | |
| 1032 | 1034 | Message, | |
| 1035 | + | // Spelled as the API spells it, which is what the plugin sends. | |
| 1036 | + | #[value(name = "tool_call", alias = "tool-call")] | |
| 1033 | 1037 | ToolCall, | |
| 1034 | 1038 | /// A skill the session used: give its name. | |
| 1035 | 1039 | Skill, | |
| ⋯ | |||
| 1417 | 1421 | fn parse_chunk(text: &str) -> Result<u64, String> { | |
| 1418 | 1422 | crate::chunk::check_chunk(crate::chunk::parse_size(text)?) | |
| 1419 | 1423 | } | |
| 1424 | + | ||
| 1425 | + | #[cfg(test)] | |
| 1426 | + | mod tests { | |
| 1427 | + | use super::Cli; | |
| 1428 | + | use clap::Parser; | |
| 1429 | + | use serde_json::Value; | |
| 1430 | + | ||
| 1431 | + | /// Every command the Claude Code plugin's hooks run, from the contract | |
| 1432 | + | /// their tests hold them to (plugins/claude-code/test/cli-contract.json), | |
| 1433 | + | /// with every value each flag is given there, parses here. | |
| 1434 | + | #[test] | |
| 1435 | + | fn the_plugin_hooks_commands_parse() { | |
| 1436 | + | let path = concat!( | |
| 1437 | + | env!("CARGO_MANIFEST_DIR"), | |
| 1438 | + | "/../../plugins/claude-code/test/cli-contract.json" | |
| 1439 | + | ); | |
| 1440 | + | let contract: Value = | |
| 1441 | + | serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); | |
| 1442 | + | let mut checked = 0; | |
| 1443 | + | for command in contract["commands"].as_array().unwrap() { | |
| 1444 | + | let words: Vec<String> = command["command"] | |
| 1445 | + | .as_array() | |
| 1446 | + | .unwrap() | |
| 1447 | + | .iter() | |
| 1448 | + | .map(|w| w.as_str().unwrap().to_owned()) | |
| 1449 | + | .collect(); | |
| 1450 | + | let flags = command["flags"].as_object().unwrap(); | |
| 1451 | + | // One argv for each value of each flag, with every other flag too. | |
| 1452 | + | let mut variants: Vec<Vec<(String, Option<String>)>> = vec![vec![]]; | |
| 1453 | + | for (flag, kind) in flags { | |
| 1454 | + | let values: Vec<Option<String>> = match kind { | |
| 1455 | + | Value::Array(values) => values | |
| 1456 | + | .iter() | |
| 1457 | + | .map(|v| Some(v.as_str().unwrap().to_owned())) | |
| 1458 | + | .collect(), | |
| 1459 | + | Value::String(kind) => match kind.as_str() { | |
| 1460 | + | // Spaced, and as --flag=VALUE, which the hooks use for | |
| 1461 | + | // text that may start with a dash. | |
| 1462 | + | "text" => vec![ | |
| 1463 | + | Some("a text, with spaces".into()), | |
| 1464 | + | Some("=- a text that starts with a dash".into()), | |
| 1465 | + | ], | |
| 1466 | + | "number" => vec![Some("42".into())], | |
| 1467 | + | "switch" | "json" => vec![None], | |
| 1468 | + | other => panic!("{flag}: no type {other}"), | |
| 1469 | + | }, | |
| 1470 | + | other => panic!("{flag}: {other}"), | |
| 1471 | + | }; | |
| 1472 | + | let mut next = Vec::new(); | |
| 1473 | + | for variant in &variants { | |
| 1474 | + | for value in &values { | |
| 1475 | + | let mut v = variant.clone(); | |
| 1476 | + | v.push((flag.clone(), value.clone())); | |
| 1477 | + | next.push(v); | |
| 1478 | + | } | |
| 1479 | + | } | |
| 1480 | + | variants = next; | |
| 1481 | + | } | |
| 1482 | + | let dash_dash = command["dash_dash"].as_bool().unwrap_or(false); | |
| 1483 | + | // A text positional after -- may start with a dash. | |
| 1484 | + | let positionals: Vec<String> = command["positionals"] | |
| 1485 | + | .as_array() | |
| 1486 | + | .map(|p| { | |
| 1487 | + | p.iter() | |
| 1488 | + | .map(|k| match (k.as_str(), dash_dash) { | |
| 1489 | + | (Some("number"), _) => "42".to_owned(), | |
| 1490 | + | (_, true) => "-a text that starts with a dash".to_owned(), | |
| 1491 | + | _ => "a_text".to_owned(), | |
| 1492 | + | }) | |
| 1493 | + | .collect() | |
| 1494 | + | }) | |
| 1495 | + | .unwrap_or_default(); | |
| 1496 | + | for variant in variants { | |
| 1497 | + | let mut argv = vec!["g1t".to_owned()]; | |
| 1498 | + | argv.extend(words.iter().cloned()); | |
| 1499 | + | // --json last: with nothing after it, it takes no fields. | |
| 1500 | + | let mut json = false; | |
| 1501 | + | for (flag, value) in &variant { | |
| 1502 | + | if flag == "--json" { | |
| 1503 | + | json = true; | |
| 1504 | + | continue; | |
| 1505 | + | } | |
| 1506 | + | match value { | |
| 1507 | + | Some(v) if v.starts_with('=') => argv.push(format!("{flag}{v}")), | |
| 1508 | + | _ => { | |
| 1509 | + | argv.push(flag.clone()); | |
| 1510 | + | argv.extend(value.iter().cloned()); | |
| 1511 | + | } | |
| 1512 | + | } | |
| 1513 | + | } | |
| 1514 | + | if dash_dash { | |
| 1515 | + | argv.push("--".into()); | |
| 1516 | + | } | |
| 1517 | + | argv.extend(positionals.iter().cloned()); | |
| 1518 | + | if json { | |
| 1519 | + | argv.push("--json".into()); | |
| 1520 | + | } | |
| 1521 | + | match Cli::try_parse_from(&argv) { | |
| 1522 | + | Ok(_) => {} | |
| 1523 | + | Err(e) if e.kind() == clap::error::ErrorKind::DisplayVersion => {} | |
| 1524 | + | Err(e) => panic!("the plugin runs {argv:?}, which the CLI refuses: {e}"), | |
| 1525 | + | } | |
| 1526 | + | checked += 1; | |
| 1527 | + | } | |
| 1528 | + | } | |
| 1529 | + | assert!(checked >= 10, "checked {checked}"); | |
| 1530 | + | // This CLI is at least the version the hooks ask for. | |
| 1531 | + | let version = |v: &str| { | |
| 1532 | + | v.split('.') | |
| 1533 | + | .map(|n| n.parse::<u32>().unwrap()) | |
| 1534 | + | .collect::<Vec<_>>() | |
| 1535 | + | }; | |
| 1536 | + | let minimum = contract["minimum_version"].as_str().unwrap(); | |
| 1537 | + | assert!( | |
| 1538 | + | version(env!("CARGO_PKG_VERSION")) >= version(minimum), | |
| 1539 | + | "the hooks need {minimum}" | |
| 1540 | + | ); | |
| 1541 | + | } | |
| 1542 | + | ||
| 1543 | + | #[test] | |
| 1544 | + | fn entry_kinds_take_both_spellings() { | |
| 1545 | + | for kind in ["tool_call", "tool-call"] { | |
| 1546 | + | let cli = Cli::try_parse_from(["g1t", "session", "log", "--kind", kind, "x"]).unwrap(); | |
| 1547 | + | let super::Command::Session(super::SessionCommand::Log(args)) = cli.command else { | |
| 1548 | + | panic!("not session log") | |
| 1549 | + | }; | |
| 1550 | + | assert_eq!(args.kind.name(), "tool_call"); | |
| 1551 | + | } | |
| 1552 | + | } | |
| 1553 | + | } | |
| 354 | 354 | if let Some(seen) = session["last_seen_at"].as_str() { | |
| 355 | 355 | facts.push(format!("last seen {}", ago(seen))); | |
| 356 | 356 | } | |
| 357 | − | println!("{}", facts.join(" · ")); | |
| 357 | + | println!("{}", facts.join(" · ")); | |
| 358 | 358 | let on = place(&session); | |
| 359 | 359 | if !on.is_empty() { | |
| 360 | 360 | println!("{} {on}", out.dim("On:")); | |
| ⋯ | |||
| 365 | 365 | if let Some(reason) = session["end_reason"].as_str() { | |
| 366 | 366 | println!("{} {reason}", out.dim("Ended:")); | |
| 367 | 367 | } | |
| 368 | + | // A subagent: who started it, what it was asked and what it reported. | |
| 369 | + | if let Some(parent) = session["parent_id"].as_str() { | |
| 370 | + | let kind = session["agent_type"].as_str().map(|t| format!(" ({t})")).unwrap_or_default(); | |
| 371 | + | println!("{} {parent}{kind}", out.dim("Subagent of:")); | |
| 372 | + | } | |
| 373 | + | if let Some(task) = session["task"].as_str().filter(|t| !t.trim().is_empty()) { | |
| 374 | + | println!("{} {}", out.dim("Task:"), task.trim()); | |
| 375 | + | } | |
| 376 | + | if let Some(result) = session["result"].as_str().filter(|r| !r.trim().is_empty()) { | |
| 377 | + | println!("{}", out.dim("Result:")); | |
| 378 | + | for line in result.trim().lines() { | |
| 379 | + | println!(" {line}"); | |
| 380 | + | } | |
| 381 | + | } | |
| 368 | 382 | let entries = session["entries"].as_array().cloned().unwrap_or_default(); | |
| 369 | 383 | if !entries.is_empty() { | |
| 370 | 384 | println!(); | |
| ⋯ | |||
| 447 | 461 | ); | |
| 448 | 462 | } | |
| 449 | 463 | ||
| 464 | + | /// UTF-8 read as Windows-1252 and saved again (the middle dot as | |
| 465 | + | /// U+00C2 U+00B7) printed as mojibake in `g1t session view`; no source | |
| 466 | + | /// holds it. | |
| 467 | + | #[test] | |
| 468 | + | fn no_source_holds_mojibake() { | |
| 469 | + | let marks = ['\u{00C2}', '\u{00E2}'].map(|lead| { | |
| 470 | + | // U+00C2 before a Latin-1 symbol, or U+00E2 U+20AC (how an | |
| 471 | + | // ellipsis, a dash or a curly quote starts when read so). | |
| 472 | + | move |text: &str| { | |
| 473 | + | text.char_indices().any(|(i, c)| { | |
| 474 | + | c == lead | |
| 475 | + | && text[i + c.len_utf8()..] | |
| 476 | + | .chars() | |
| 477 | + | .next() | |
| 478 | + | .is_some_and(|next| ('\u{00A0}'..='\u{00BF}').contains(&next) || next == '\u{20AC}') | |
| 479 | + | }) | |
| 480 | + | } | |
| 481 | + | }); | |
| 482 | + | let mut dirs = vec![std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/src"))]; | |
| 483 | + | while let Some(dir) = dirs.pop() { | |
| 484 | + | for entry in std::fs::read_dir(dir).unwrap() { | |
| 485 | + | let path = entry.unwrap().path(); | |
| 486 | + | if path.is_dir() { | |
| 487 | + | dirs.push(path); | |
| 488 | + | } else if path.extension().is_some_and(|e| e == "rs") { | |
| 489 | + | let text = std::fs::read_to_string(&path).unwrap(); | |
| 490 | + | assert!(!marks.iter().any(|found| found(&text)), "{} holds mojibake", path.display()); | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | ||
| 450 | 496 | #[test] | |
| 451 | 497 | fn a_session_says_where_it_is() { | |
| 452 | 498 | assert_eq!(place(&json!({ "issue": 12, "pull": 14, "branch": "fix-x" })), "#12, pull #14, fix-x"); | |
| 192 | 192 | out.trim_end().to_owned() + "\n" | |
| 193 | 193 | } | |
| 194 | 194 | ||
| 195 | + | /// The `g1t …` command lines in a page: in code blocks (a line starting | |
| 196 | + | /// with `g1t `, or `$ g1t `) and in inline code. Each is cut at the first | |
| 197 | + | /// shell operator or comment. | |
| 198 | + | pub fn command_lines(page: &str) -> Vec<String> { | |
| 199 | + | let mut found = Vec::new(); | |
| 200 | + | let mut in_block = false; | |
| 201 | + | let mut take = |text: &str| { | |
| 202 | + | let text = text.trim(); | |
| 203 | + | let text = text.strip_prefix("$ ").unwrap_or(text); | |
| 204 | + | if !text.starts_with("g1t ") { | |
| 205 | + | return; | |
| 206 | + | } | |
| 207 | + | let end = [ | |
| 208 | + | " | ", " && ", " || ", " ; ", "; ", " # ", " > ", " < ", "`", " 2>", | |
| 209 | + | ] | |
| 210 | + | .iter() | |
| 211 | + | .filter_map(|op| text.find(op)) | |
| 212 | + | .min() | |
| 213 | + | .unwrap_or(text.len()); | |
| 214 | + | found.push(text[..end].trim().to_owned()); | |
| 215 | + | }; | |
| 216 | + | for line in page.lines() { | |
| 217 | + | if line.trim_start().starts_with("```") { | |
| 218 | + | in_block = !in_block; | |
| 219 | + | continue; | |
| 220 | + | } | |
| 221 | + | if in_block { | |
| 222 | + | take(line); | |
| 223 | + | continue; | |
| 224 | + | } | |
| 225 | + | for (i, part) in line.split('`').enumerate() { | |
| 226 | + | if i % 2 == 1 { | |
| 227 | + | take(part); | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | found | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// What is wrong with a documented command line: a command the CLI does | |
| 235 | + | /// not have, or a flag the command does not take. None when it is right, or | |
| 236 | + | /// when it cannot be told (an alias, a placeholder). | |
| 237 | + | pub fn wrong(line: &str) -> Option<String> { | |
| 238 | + | let mut root = <crate::cli::Cli as clap::CommandFactory>::command(); | |
| 239 | + | root.build(); | |
| 240 | + | let words = shell_words(line); | |
| 241 | + | // Not a command: "g1t / deploy" (a check's name), "g1t <noreply@…>". | |
| 242 | + | let command_word = |w: &String| { | |
| 243 | + | w.chars() | |
| 244 | + | .next() | |
| 245 | + | .is_some_and(|c| c.is_ascii_lowercase() || c == '-') | |
| 246 | + | }; | |
| 247 | + | if !words.get(1).is_some_and(command_word) { | |
| 248 | + | return None; | |
| 249 | + | } | |
| 250 | + | let mut cmd = &root; | |
| 251 | + | let mut path = vec!["g1t".to_owned()]; | |
| 252 | + | let mut rest = words.iter().skip(1).peekable(); | |
| 253 | + | while let Some(word) = rest.peek() { | |
| 254 | + | if word.starts_with('-') || !cmd.has_subcommands() { | |
| 255 | + | break; | |
| 256 | + | } | |
| 257 | + | match cmd.find_subcommand(word.as_str()) { | |
| 258 | + | Some(sub) => { | |
| 259 | + | path.push(word.to_string()); | |
| 260 | + | cmd = sub; | |
| 261 | + | rest.next(); | |
| 262 | + | } | |
| 263 | + | // The aliases the docs make, and placeholders like COMMAND, are not checked. | |
| 264 | + | None if path.len() == 1 && DOC_ALIASES.contains(&word.as_str()) => return None, | |
| 265 | + | None => { | |
| 266 | + | if word | |
| 267 | + | .chars() | |
| 268 | + | .all(|c| c.is_ascii_uppercase() || c == '_' || c == '<' || c == '>') | |
| 269 | + | { | |
| 270 | + | return None; | |
| 271 | + | } | |
| 272 | + | return Some(format!("`{}` has no command `{word}`", path.join(" "))); | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | let globals: Vec<&clap::Arg> = root.get_arguments().collect(); | |
| 277 | + | let rest: Vec<&String> = rest.collect(); | |
| 278 | + | for (i, word) in rest.iter().enumerate() { | |
| 279 | + | if *word == "--" { | |
| 280 | + | break; | |
| 281 | + | } | |
| 282 | + | let Some(flag) = word.strip_prefix("--") else { | |
| 283 | + | continue; | |
| 284 | + | }; | |
| 285 | + | let (flag, inline) = match flag.split_once('=') { | |
| 286 | + | Some((flag, value)) => (flag, Some(value)), | |
| 287 | + | None => (flag, None), | |
| 288 | + | }; | |
| 289 | + | if flag.is_empty() | |
| 290 | + | || flag.starts_with(['<', '[']) | |
| 291 | + | || flag.chars().any(|c| c.is_ascii_uppercase()) | |
| 292 | + | { | |
| 293 | + | continue; | |
| 294 | + | } | |
| 295 | + | let known = |arg: &&clap::Arg| { | |
| 296 | + | arg.get_long() == Some(flag) || arg.get_all_aliases().is_some_and(|a| a.contains(&flag)) | |
| 297 | + | }; | |
| 298 | + | let Some(arg) = cmd | |
| 299 | + | .get_arguments() | |
| 300 | + | .find(known) | |
| 301 | + | .or_else(|| globals.iter().copied().find(known)) | |
| 302 | + | else { | |
| 303 | + | if matches!(flag, "help" | "version") { | |
| 304 | + | continue; | |
| 305 | + | } | |
| 306 | + | return Some(format!("`{}` takes no --{flag}", path.join(" "))); | |
| 307 | + | }; | |
| 308 | + | // A value from a fixed list: one of them (a placeholder is not checked). | |
| 309 | + | let values = arg.get_possible_values(); | |
| 310 | + | let value = inline.or_else(|| rest.get(i + 1).map(|v| v.as_str())); | |
| 311 | + | let literal = |v: &&str| { | |
| 312 | + | v.chars() | |
| 313 | + | .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') | |
| 314 | + | }; | |
| 315 | + | let unknown = |v: &&str| !values.iter().any(|p| p.matches(v, false)); | |
| 316 | + | if let Some(value) = value.filter(|v| !values.is_empty() && literal(v) && unknown(v)) { | |
| 317 | + | let names: Vec<String> = values.iter().map(|v| v.get_name().to_owned()).collect(); | |
| 318 | + | return Some(format!( | |
| 319 | + | "`{} --{flag}` takes {}, not {value}", | |
| 320 | + | path.join(" "), | |
| 321 | + | names.join(", ") | |
| 322 | + | )); | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | None | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | /// Aliases the docs show being made (`g1t alias set …`) and then used. | |
| 329 | + | const DOC_ALIASES: &[&str] = &["bugs", "co", "mine"]; | |
| 330 | + | ||
| 331 | + | /// Splits a command line at spaces outside quotes. | |
| 332 | + | fn shell_words(line: &str) -> Vec<String> { | |
| 333 | + | let mut words = Vec::new(); | |
| 334 | + | let mut word = String::new(); | |
| 335 | + | let mut quote = None; | |
| 336 | + | for c in line.chars() { | |
| 337 | + | match (quote, c) { | |
| 338 | + | (None, '"' | '\'') => quote = Some(c), | |
| 339 | + | (Some(q), c) if c == q => quote = None, | |
| 340 | + | (None, ' ' | '\t') => { | |
| 341 | + | if !word.is_empty() { | |
| 342 | + | words.push(std::mem::take(&mut word)); | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | (_, c) => word.push(c), | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | if !word.is_empty() { | |
| 349 | + | words.push(word); | |
| 350 | + | } | |
| 351 | + | words | |
| 352 | + | } | |
| 353 | + | ||
| 195 | 354 | #[cfg(test)] | |
| 196 | 355 | mod tests { | |
| 356 | + | /// Every `g1t …` command in the docs is one the CLI has, with flags it takes. | |
| 357 | + | #[test] | |
| 358 | + | fn docs_name_real_commands_and_flags() { | |
| 359 | + | // The docs, and the plugins' skills, commands and instructions. | |
| 360 | + | let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../.."); | |
| 361 | + | let mut pages: Vec<std::path::PathBuf> = | |
| 362 | + | ["apps/docs/src/content/docs", "plugins", "README.md"] | |
| 363 | + | .iter() | |
| 364 | + | .map(|p| std::path::Path::new(root).join(p)) | |
| 365 | + | .collect(); | |
| 366 | + | let mut problems = Vec::new(); | |
| 367 | + | let mut checked = 0; | |
| 368 | + | while let Some(path) = pages.pop() { | |
| 369 | + | if path.is_dir() { | |
| 370 | + | pages.extend(std::fs::read_dir(&path).unwrap().map(|e| e.unwrap().path())); | |
| 371 | + | continue; | |
| 372 | + | } | |
| 373 | + | let name = path.to_string_lossy().replace('\\', "/"); | |
| 374 | + | // The reference is written from the CLI itself. | |
| 375 | + | if !(name.ends_with(".md") || name.ends_with(".mdx")) | |
| 376 | + | || name.ends_with("reference/cli.md") | |
| 377 | + | { | |
| 378 | + | continue; | |
| 379 | + | } | |
| 380 | + | let page = std::fs::read_to_string(&path).unwrap(); | |
| 381 | + | for line in super::command_lines(&page) { | |
| 382 | + | checked += 1; | |
| 383 | + | if let Some(problem) = super::wrong(&line) { | |
| 384 | + | let short = name | |
| 385 | + | .split("content/docs/") | |
| 386 | + | .last() | |
| 387 | + | .unwrap_or(&name) | |
| 388 | + | .to_owned(); | |
| 389 | + | problems.push(format!("{short}: {line}: {problem}")); | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | assert!(checked > 100, "found only {checked} command lines"); | |
| 394 | + | assert!( | |
| 395 | + | problems.is_empty(), | |
| 396 | + | "commands the CLI does not have:\n{}", | |
| 397 | + | problems.join("\n") | |
| 398 | + | ); | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | #[test] | |
| 402 | + | fn a_wrong_command_or_flag_is_found() { | |
| 403 | + | assert_eq!(super::wrong("g1t session start --harness codex"), None); | |
| 404 | + | assert_eq!(super::wrong("g1t pr create --issue N --draft --fill"), None); | |
| 405 | + | assert!(super::wrong("g1t session pause").is_some()); | |
| 406 | + | assert!(super::wrong("g1t session start --hearbeat").is_some()); | |
| 407 | + | assert!(super::wrong("g1t session start --harness claude-managed-agents").is_some()); | |
| 408 | + | assert_eq!(super::wrong("g1t session log --kind tool-call x"), None); | |
| 409 | + | assert_eq!( | |
| 410 | + | super::wrong("g1t session start --harness=claude_code"), | |
| 411 | + | None | |
| 412 | + | ); | |
| 413 | + | assert_eq!(super::wrong("g1t / deploy"), None, "not a command"); | |
| 414 | + | assert_eq!(super::wrong("g1t co 12"), None, "an alias is not checked"); | |
| 415 | + | assert_eq!( | |
| 416 | + | super::command_lines( | |
| 417 | + | "Run `g1t work claim 12 --note x` and\n```sh\n$ g1t pr view | head\n```" | |
| 418 | + | ), | |
| 419 | + | vec!["g1t work claim 12 --note x", "g1t pr view"] | |
| 420 | + | ); | |
| 421 | + | } | |
| 422 | + | ||
| 197 | 423 | #[test] | |
| 198 | 424 | fn cli_reference_is_current() { | |
| 199 | 425 | let path = concat!( |
| 481 | 481 | ) | |
| 482 | 482 | } | |
| 483 | 483 | ||
| 484 | − | /// `Bearer realm="https://g1t.sh/v2/token",service="g1t.sh",scope="…"`. | |
| 484 | + | /// `Bearer realm="https://g1t.sh/v2/token",service="g1t.sh",scope="…"`. | |
| 485 | 485 | fn parse_challenge(header: &str) -> Option<(String, String)> { | |
| 486 | 486 | let rest = header | |
| 487 | 487 | .trim() | |
| ⋯ | |||
| 513 | 513 | }) | |
| 514 | 514 | } | |
| 515 | 515 | ||
| 516 | − | /// 1s, 2s, 4s … at most 30s. | |
| 516 | + | /// 1s, 2s, 4s … at most 30s. | |
| 517 | 517 | pub fn backoff(attempt: u32) -> Duration { | |
| 518 | 518 | Duration::from_secs((1u64 << attempt.min(5)).min(30)) | |
| 519 | 519 | } | |
| 1 | 1 | { | |
| 2 | 2 | "name": "g1t", | |
| 3 | − | "version": "0.2.0", | |
| 3 | + | "version": "0.2.1", | |
| 4 | 4 | "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.", | |
| 5 | 5 | "author": { "name": "Flagon, Inc.", "url": "https://g1t.sh" }, | |
| 6 | 6 | "homepage": "https://docs.g1t.sh/guides/claude-code-plugin/", |
| 8 | 8 | // and, when the branch, G1T_ISSUE or the session names an | |
| 9 | 9 | // issue, g1t work context N, given to Claude as context | |
| 10 | 10 | // UserPromptSubmit g1t session log --kind prompt TEXT (first 2,000 characters) | |
| 11 | + | // and, for a plugin's skill typed as /plugin:name, | |
| 12 | + | // g1t session log --kind skill NAME | |
| 11 | 13 | // PreToolUse Agent: the task it is given, kept until its subagent starts. | |
| 12 | 14 | // Inside a subagent, its own g1t session for the g1t | |
| 13 | − | // commands and g1t MCP claims it makes. | |
| 15 | + | // commands (Bash or PowerShell) and g1t MCP claims it makes. | |
| 14 | 16 | // PostToolUse g1t session log --kind tool_call "Tool: what it was called with" | |
| 15 | 17 | // (a Skill: g1t session log --kind skill NAME), and | |
| 16 | 18 | // g1t session touch for the files it wrote, the commits | |
| ⋯ | |||
| 32 | 34 | // | |
| 33 | 35 | // Every hook returns at once: the CLI calls run in a detached process of | |
| 34 | 36 | // their own, so a slow network never holds Claude up. If `g1t` is not | |
| 35 | − | // installed or not signed in, nothing is recorded and Claude Code says so | |
| 36 | − | // once. Nothing is ever installed. No dependencies; Node 18 or later. | |
| 37 | + | // installed, not signed in or older than MIN_CLI (or refuses a command, | |
| 38 | + | // as an old CLI does), nothing is recorded and Claude Code says so once. | |
| 39 | + | // Nothing is ever installed. No dependencies; Node 18 or later, and on an | |
| 40 | + | // older Node it says so once and does nothing. | |
| 41 | + | // | |
| 42 | + | // Every CLI call is in test/cli-contract.json, which the tests hold this | |
| 43 | + | // file and the CLI's own parser (crates/g1t) to. | |
| 37 | 44 | ||
| 38 | 45 | import { spawn, spawnSync } from "node:child_process"; | |
| 39 | 46 | import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; | |
| ⋯ | |||
| 57 | 64 | const CONTEXT_WAIT_MS = 900; | |
| 58 | 65 | /** Exit code the g1t CLI gives when signing in is needed. */ | |
| 59 | 66 | const EXIT_AUTH = 4; | |
| 67 | + | /** Exit code the g1t CLI gives for a command it does not take, as a CLI older than the plugin does. */ | |
| 68 | + | const EXIT_USAGE = 2; | |
| 69 | + | /** The oldest g1t CLI that takes every command the hooks run (test/cli-contract.json's minimum_version). */ | |
| 70 | + | export const MIN_CLI = "0.3.2"; | |
| 71 | + | /** The oldest Node the hooks run on. */ | |
| 72 | + | export const MIN_NODE = 18; | |
| 73 | + | /** How to update the CLI: its installer, run again. */ | |
| 74 | + | const INSTALL = "`curl -fsSL https://g1t.sh/install | sh` (on Windows, `irm https://g1t.sh/install.ps1 | iex`)"; | |
| 75 | + | /** The tools that run shell commands. */ | |
| 76 | + | const SHELL_TOOLS = new Set(["Bash", "PowerShell"]); | |
| 60 | 77 | /** The tool that starts subagents (Task before Claude Code renamed it). */ | |
| 61 | 78 | const AGENT_TOOLS = new Set(["Agent", "Task"]); | |
| 62 | 79 | /** Tools that write a file, and where they say which. */ | |
| ⋯ | |||
| 91 | 108 | return rel.replaceAll("\\", "/"); | |
| 92 | 109 | } | |
| 93 | 110 | ||
| 111 | + | /** What a subagent's shell command starts with to act as its session (see preToolUse), in Bash or PowerShell. */ | |
| 112 | + | export function sessionPrefix(tool, session) { | |
| 113 | + | return tool === "PowerShell" ? `$env:G1T_SESSION='${session}'; ` : `export G1T_SESSION=${session}; `; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | const PREFIX = /^(?:export G1T_SESSION=[A-Za-z0-9_]+; |\$env:G1T_SESSION='[A-Za-z0-9_]+'; )/; | |
| 117 | + | ||
| 94 | 118 | /** One line on what a tool was called with: never what it returned. */ | |
| 95 | 119 | export function summarizeTool(name, input, cwd) { | |
| 96 | 120 | const i = input && typeof input === "object" ? input : {}; | |
| ⋯ | |||
| 99 | 123 | return repoPath(p, cwd) ?? p; | |
| 100 | 124 | }; | |
| 101 | 125 | let what = ""; | |
| 102 | − | if (typeof i.command === "string") what = i.command.split("\n")[0] + (i.command.includes("\n") ? " …" : ""); | |
| 126 | + | if (typeof i.command === "string") { | |
| 127 | + | // Without the session the plugin gave a subagent's command. | |
| 128 | + | const command = i.command.replace(PREFIX, ""); | |
| 129 | + | what = command.split("\n")[0] + (command.includes("\n") ? " …" : ""); | |
| 130 | + | } | |
| 103 | 131 | else if (i.file_path || i.notebook_path) what = path(i.file_path ?? i.notebook_path); | |
| 104 | 132 | else if (typeof i.pattern === "string") what = `${i.pattern}${i.path ? ` in ${path(i.path)}` : ""}`; | |
| 105 | 133 | else if (typeof i.url === "string") what = i.url; | |
| ⋯ | |||
| 167 | 195 | .trim(); | |
| 168 | 196 | } | |
| 169 | 197 | ||
| 198 | + | /** The plugin skill a prompt runs when it is typed as one: `g1t:work-on-issue` for `/g1t:work-on-issue 42`. */ | |
| 199 | + | export function typedSkill(prompt) { | |
| 200 | + | const text = String(prompt ?? "").replace(/^/, ""); | |
| 201 | + | const match = /^\s*\/([A-Za-z0-9][\w.-]*:[A-Za-z0-9][\w.-]*)(?=\s|$)/.exec(text); | |
| 202 | + | return match ? match[1] : null; | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /** | |
| 206 | + | * A skill's step: its name only. `skill` keys it (see hook), so a skill | |
| 207 | + | * typed as /g1t:name, which Claude Code may then also run as a Skill tool | |
| 208 | + | * call, is recorded once in the turn. | |
| 209 | + | */ | |
| 210 | + | function skillStep(name) { | |
| 211 | + | return { args: ["session", "log", "--kind", "skill", "--", clip(name, 100)], skill: name.replace(/^.*:/, "") }; | |
| 212 | + | } | |
| 213 | + | ||
| 170 | 214 | /** The g1t MCP tool a call is to, by name: `issue` for mcp__plugin_g1t_g1t__issue or mcp__g1t__issue. */ | |
| 171 | 215 | export function g1tTool(name) { | |
| 172 | 216 | const match = /^mcp__(?:plugin_g1t_)?g1t__([a-z_]+)$/.exec(String(name ?? "")); | |
| ⋯ | |||
| 194 | 238 | if (issue > 0) job.issue = issue; | |
| 195 | 239 | return job; | |
| 196 | 240 | } | |
| 197 | − | case "UserPromptSubmit": | |
| 198 | − | if (env.G1T_RECORD_PROMPTS === "0" || typeof event.prompt !== "string" || !event.prompt.trim()) return null; | |
| 199 | − | job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", clip(event.prompt, PROMPT_LIMIT)] }); | |
| 200 | − | return job; | |
| 241 | + | case "UserPromptSubmit": { | |
| 242 | + | const prompt = typeof event.prompt === "string" ? event.prompt.replace(/^/, "") : ""; | |
| 243 | + | // A turn Claude Code starts itself for a background task carries that | |
| 244 | + | // task's output, which is never sent: it is not a prompt of yours. | |
| 245 | + | if (!prompt.trim() || /^\s*<task-notification>/.test(prompt)) return null; | |
| 246 | + | if (env.G1T_RECORD_PROMPTS !== "0") job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", clip(prompt, PROMPT_LIMIT)] }); | |
| 247 | + | // A skill typed as /plugin:name is a skill used (its name), whether or not prompts are sent. | |
| 248 | + | const skill = typedSkill(prompt); | |
| 249 | + | if (skill) job.steps.push(skillStep(skill)); | |
| 250 | + | return job.steps.length ? job : null; | |
| 251 | + | } | |
| 201 | 252 | case "SubagentStart": | |
| 202 | 253 | if (!job.agent) return null; | |
| 203 | 254 | job.agent.start = true; | |
| ⋯ | |||
| 206 | 257 | if (!job.agent) return null; | |
| 207 | 258 | const args = ["session", "end", "--reason", "done"]; | |
| 208 | 259 | const result = typeof event.last_assistant_message === "string" ? event.last_assistant_message.trim() : ""; | |
| 209 | − | if (result) args.push("--result", clip(result, RESULT_LIMIT)); | |
| 260 | + | // --flag=VALUE throughout for text: one that starts with a dash ("- Found …") is still a value. | |
| 261 | + | if (result) args.push(`--result=${clip(result, RESULT_LIMIT)}`); | |
| 210 | 262 | job.steps.push({ args }); | |
| 211 | 263 | return job; | |
| 212 | 264 | } | |
| ⋯ | |||
| 218 | 270 | const skill = typeof input.skill === "string" ? input.skill.trim() : ""; | |
| 219 | 271 | if (!skill) return null; | |
| 220 | 272 | // Its name, nothing more. | |
| 221 | − | job.steps.push({ args: ["session", "log", "--kind", "skill", "--", clip(skill, 100)] }); | |
| 273 | + | job.steps.push(skillStep(skill)); | |
| 222 | 274 | return job; | |
| 223 | 275 | } | |
| 224 | 276 | job.steps.push({ args: ["session", "log", "--kind", "tool_call", "--", summarizeTool(tool, input, cwd)] }); | |
| 225 | 277 | const touch = []; | |
| 226 | 278 | if (WRITERS.has(tool)) { | |
| 227 | 279 | const file = repoPath(input.file_path ?? input.notebook_path, cwd); | |
| 228 | − | if (file) touch.push("--file", file); | |
| 280 | + | if (file) touch.push(`--file=${file}`); | |
| 229 | 281 | } | |
| 230 | 282 | if (AGENT_TOOLS.has(tool) && !job.agent) { | |
| 231 | 283 | const response = event.tool_response ?? {}; | |
| ⋯ | |||
| 241 | 293 | }; | |
| 242 | 294 | } | |
| 243 | 295 | } | |
| 244 | − | if (tool === "Bash") { | |
| 296 | + | if (SHELL_TOOLS.has(tool)) { | |
| 245 | 297 | const issue = issueFromCommand(input.command); | |
| 246 | 298 | if (issue) { | |
| 247 | 299 | job.issue = issue; | |
| ⋯ | |||
| 349 | 401 | marker: join(dir, `${safe}.json`), | |
| 350 | 402 | id: join(dir, `${safe}.id`), | |
| 351 | 403 | issue: join(dir, `${safe}.issue`), | |
| 404 | + | turn: join(dir, `${safe}.turn`), | |
| 352 | 405 | pending: join(dir, `${safe}.pending.json`), | |
| 353 | 406 | agent: (agentId) => join(dir, `${safe}.agent.${safeName(String(agentId).replace(/^agent-/, ""))}.id`), | |
| 354 | 407 | }; | |
| ⋯ | |||
| 392 | 445 | ||
| 393 | 446 | // --- saying so once ---------------------------------------------------- | |
| 394 | 447 | ||
| 448 | + | const NOT_RECORDED = "g1t: this session is not being recorded on g1t, because"; | |
| 449 | + | ||
| 395 | 450 | const MESSAGES = { | |
| 396 | − | missing: | |
| 397 | − | "g1t: this session is not being recorded on g1t, because the g1t CLI is not installed. Install it (https://docs.g1t.sh/guides/cli/) and run `g1t auth login`. This is said once.", | |
| 398 | − | signed_out: | |
| 399 | − | "g1t: this session is not being recorded on g1t, because the g1t CLI is not signed in. Run `g1t auth login`. This is said once.", | |
| 451 | + | missing: () => `${NOT_RECORDED} the g1t CLI is not installed. Install it (https://docs.g1t.sh/guides/cli/) and run \`g1t auth login\`. This is said once.`, | |
| 452 | + | signed_out: () => `${NOT_RECORDED} the g1t CLI is not signed in. Run \`g1t auth login\`. This is said once.`, | |
| 453 | + | outdated: (version) => | |
| 454 | + | `${NOT_RECORDED} the g1t CLI is ${version}, and the plugin needs ${MIN_CLI} or later. Update it by running the installer again: ${INSTALL}. This is said once.`, | |
| 455 | + | refused: (command) => | |
| 456 | + | `${NOT_RECORDED} the g1t CLI refused \`g1t ${command}\`: it is older than the plugin, which needs ${MIN_CLI} or later. Update it by running the installer again: ${INSTALL}. This is said once.`, | |
| 457 | + | old_node: (version) => | |
| 458 | + | `${NOT_RECORDED} its hooks need Node ${MIN_NODE} or later, and \`node\` is ${version}. Install a current Node (https://nodejs.org) and start Claude Code again. This is said once.`, | |
| 400 | 459 | }; | |
| 401 | 460 | ||
| 402 | − | /** Remembers a problem (missing, signed_out), or that there is none (null). */ | |
| 403 | − | export function setProblem(env, problem) { | |
| 461 | + | /** Remembers a problem (missing, signed_out, outdated, refused, old_node) and what it was about, or that there is none (null). */ | |
| 462 | + | export function setProblem(env, problem, detail = null) { | |
| 404 | 463 | const path = join(dataDir(env), "notice.json"); | |
| 405 | 464 | const notice = readJson(path, {}); | |
| 406 | − | if ((notice.problem ?? null) === problem) return; | |
| 407 | − | write(path, JSON.stringify({ problem, shown: false })); | |
| 465 | + | if ((notice.problem ?? null) === problem && (notice.detail ?? null) === detail) return; | |
| 466 | + | write(path, JSON.stringify({ problem, detail, shown: false })); | |
| 408 | 467 | } | |
| 409 | 468 | ||
| 410 | 469 | /** The message for a problem not shown yet, marking it shown. */ | |
| ⋯ | |||
| 413 | 472 | const notice = readJson(path, {}); | |
| 414 | 473 | if (!notice.problem || notice.shown || !MESSAGES[notice.problem]) return null; | |
| 415 | 474 | write(path, JSON.stringify({ ...notice, shown: true })); | |
| 416 | − | return MESSAGES[notice.problem]; | |
| 475 | + | return MESSAGES[notice.problem](notice.detail); | |
| 417 | 476 | } | |
| 418 | 477 | ||
| 478 | + | /** Whether version a (1.2.3) is older than b. */ | |
| 479 | + | export function older(a, b) { | |
| 480 | + | const pa = String(a).split(".").map(Number); | |
| 481 | + | const pb = String(b).split(".").map(Number); | |
| 482 | + | for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0); | |
| 483 | + | return false; | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | /** | |
| 487 | + | * The CLI's version, from `g1t --version`, asked once for each build of | |
| 488 | + | * it: kept with the file's size and time, so updating it asks again. | |
| 489 | + | * Null when it cannot be told. | |
| 490 | + | */ | |
| 491 | + | export function cliVersion(bin, env = process.env) { | |
| 492 | + | let stamp; | |
| 493 | + | try { | |
| 494 | + | const stat = statSync(bin); | |
| 495 | + | stamp = `${bin}|${stat.size}|${stat.mtimeMs}`; | |
| 496 | + | } catch { | |
| 497 | + | return null; | |
| 498 | + | } | |
| 499 | + | const path = join(dataDir(env), "cli.json"); | |
| 500 | + | const kept = readJson(path, {}); | |
| 501 | + | if (kept.stamp === stamp) return kept.version ?? null; | |
| 502 | + | const out = cli(bin, ["--version"], { timeout: 5000, env }); | |
| 503 | + | const version = out.status === 0 ? (/\b(\d+\.\d+\.\d+)\b/.exec(out.stdout ?? "")?.[1] ?? null) : null; | |
| 504 | + | write(path, JSON.stringify({ stamp, version })); | |
| 505 | + | return version; | |
| 506 | + | } | |
| 507 | + | ||
| 419 | 508 | /** Whether a working copy's remotes are on g1t (g1t.sh, or G1T_HOST's host). */ | |
| 420 | 509 | export function isG1tRemote(urls, env = process.env) { | |
| 421 | 510 | if (env.G1T_REPO) return true; | |
| ⋯ | |||
| 466 | 555 | parentId, | |
| 467 | 556 | "--no-remember", | |
| 468 | 557 | ]; | |
| 469 | − | if (agent.type) args.push("--agent-type", String(agent.type)); | |
| 470 | − | if (agent.task) args.push("--task", agent.task); | |
| 471 | − | if (agent.prompt) args.push("--prompt", agent.prompt); | |
| 472 | − | if (agent.model) args.push("--model", String(agent.model)); | |
| 558 | + | if (agent.type) args.push(`--agent-type=${agent.type}`); | |
| 559 | + | if (agent.task) args.push(`--task=${agent.task}`); | |
| 560 | + | if (agent.prompt) args.push(`--prompt=${agent.prompt}`); | |
| 561 | + | if (agent.model) args.push(`--model=${agent.model}`); | |
| 473 | 562 | args.push("--json"); | |
| 474 | 563 | const out = cli(bin, args, { ...opts, env: { ...opts.env, G1T_SESSION: parentId } }); | |
| 475 | 564 | if (out.status === EXIT_AUTH) return { auth: true }; | |
| 565 | + | if (out.status === EXIT_USAGE) return { refused: "session start" }; | |
| 476 | 566 | const id = out.status === 0 ? startedId(out) : null; | |
| 477 | 567 | if (id) write(files.agent(agent.id), id); | |
| 478 | 568 | return { id }; | |
| ⋯ | |||
| 482 | 572 | export function work(job, env = process.env) { | |
| 483 | 573 | const bin = findCli(env); | |
| 484 | 574 | if (!bin) return setProblem(env, "missing"); | |
| 575 | + | // An older CLI refuses some of what the hooks send: nothing is sent to it. | |
| 576 | + | const version = cliVersion(bin, env); | |
| 577 | + | if (version && older(version, MIN_CLI)) return setProblem(env, "outdated", version); | |
| 485 | 578 | const files = sessionFiles(job.sid, env); | |
| 486 | 579 | if (job.issue && !job.agent) write(files.issue, String(job.issue)); | |
| 487 | 580 | const opts = { cwd: job.cwd, timeout: 20000, env: { ...env } }; | |
| ⋯ | |||
| 493 | 586 | args.push("--json"); | |
| 494 | 587 | const out = cli(bin, args, opts); | |
| 495 | 588 | if (out.status === EXIT_AUTH) return setProblem(env, "signed_out"); | |
| 589 | + | if (out.status === EXIT_USAGE) return setProblem(env, "refused", "session start"); | |
| 496 | 590 | if (out.status !== 0) return; | |
| 497 | 591 | setProblem(env, null); | |
| 498 | 592 | id = startedId(out); | |
| 499 | 593 | if (!id) return; | |
| 500 | 594 | write(files.id, id); | |
| 595 | + | if (job.envFile && /^[A-Za-z0-9_]+$/.test(id)) { | |
| 596 | + | // Claude's own g1t commands act in this session, not another one in | |
| 597 | + | // the same working copy. A plain assignment, which Claude Code gives | |
| 598 | + | // PowerShell commands too; it reads the file at the session's first | |
| 599 | + | // command, so a command before this (rare: it is the first second) | |
| 600 | + | // uses the working copy's remembered session. | |
| 601 | + | try { | |
| 602 | + | writeFileSync(job.envFile, `export G1T_SESSION=${id}\n`, { flag: "a" }); | |
| 603 | + | } catch {} | |
| 604 | + | } | |
| 501 | 605 | } | |
| 502 | 606 | // A subagent's events go to its own session, started first if need be. | |
| 503 | 607 | let session = id; | |
| ⋯ | |||
| 506 | 610 | if (job.agent.start || !child) { | |
| 507 | 611 | const started = startSubagent(bin, job, files, job.agent, id, opts); | |
| 508 | 612 | if (started.auth) return setProblem(env, "signed_out"); | |
| 613 | + | if (started.refused) return setProblem(env, "refused", started.refused); | |
| 509 | 614 | child = started.id ?? child; | |
| 510 | 615 | } | |
| 511 | 616 | if (!child) return; | |
| ⋯ | |||
| 514 | 619 | opts.env.G1T_SESSION = session; | |
| 515 | 620 | for (const step of job.steps) { | |
| 516 | 621 | if (step.args) { | |
| 517 | − | const out = cli(bin, step.args, opts); | |
| 622 | + | const args = step.key ? [...step.args.slice(0, -2), `--key=${step.key}`, ...step.args.slice(-2)] : step.args; | |
| 623 | + | const out = cli(bin, args, opts); | |
| 518 | 624 | if (out.status === EXIT_AUTH) return setProblem(env, "signed_out"); | |
| 625 | + | if (out.status === EXIT_USAGE) return setProblem(env, "refused", step.args.slice(0, 2).join(" ")); | |
| 519 | 626 | } else if (step.headCommit) { | |
| 520 | 627 | // Only once the push reached the remote: HEAD is on a remote branch. | |
| 521 | 628 | const head = git(job.cwd, ["rev-parse", "HEAD"]); | |
| ⋯ | |||
| 537 | 644 | if (job.subagent) { | |
| 538 | 645 | const started = startSubagent(bin, job, files, job.subagent, id, opts); | |
| 539 | 646 | if (started.id && job.subagent.result) { | |
| 540 | − | cli(bin, ["session", "end", "--reason", "done", "--result", job.subagent.result], { ...opts, env: { ...opts.env, G1T_SESSION: started.id } }); | |
| 647 | + | cli(bin, ["session", "end", "--reason", "done", `--result=${job.subagent.result}`], { ...opts, env: { ...opts.env, G1T_SESSION: started.id } }); | |
| 541 | 648 | } | |
| 542 | 649 | } | |
| 543 | 650 | if (job.end) { | |
| ⋯ | |||
| 575 | 682 | const notice = takeNotice(env); | |
| 576 | 683 | if (notice) output.systemMessage = notice; | |
| 577 | 684 | if (!bin) return output; | |
| 578 | − | ||
| 579 | − | if (env.CLAUDE_ENV_FILE) { | |
| 580 | − | // Claude's own `g1t` commands then act in this session, not in another | |
| 581 | − | // one in the same working copy. Read as each command runs, since the | |
| 582 | − | // session's id arrives after this hook has returned. | |
| 583 | − | const p = files.id.replaceAll("\\", "/"); | |
| 584 | − | try { | |
| 585 | − | writeFileSync(env.CLAUDE_ENV_FILE, `[ -f "${p}" ] && export G1T_SESSION="$(cat "${p}")"\n`, { flag: "a" }); | |
| 586 | − | } catch {} | |
| 587 | − | } | |
| 588 | 685 | ||
| 589 | 686 | const remembered = Number(readText(files.issue)) || null; | |
| 590 | 687 | const handedOff = Number(env.G1T_ISSUE) || null; | |
| ⋯ | |||
| 635 | 732 | if (!session) return null; | |
| 636 | 733 | let updated = null; | |
| 637 | 734 | const command = typeof input.command === "string" ? input.command : ""; | |
| 638 | − | const namesG1t = /\bg1t\s/.test(command) || /\$\{?G1T_SESSION\b/.test(command); | |
| 639 | − | if (tool === "Bash" && event.agent_id && namesG1t && !command.includes("G1T_SESSION=")) { | |
| 640 | − | updated = { ...input, command: `export G1T_SESSION=${session}; ${input.command}` }; | |
| 735 | + | const namesG1t = /\bg1t(?:\.exe)?\s/.test(command) || /G1T_SESSION\b/.test(command); | |
| 736 | + | const setsIt = /G1T_SESSION\s*=/.test(command); | |
| 737 | + | // A subagent's g1t commands act as its own session. Only these commands | |
| 738 | + | // are changed. Claude Code checks its permission rules against the | |
| 739 | + | // changed command; no permission decision is made here. | |
| 740 | + | if (SHELL_TOOLS.has(tool) && event.agent_id && namesG1t && !setsIt && /^[A-Za-z0-9_]+$/.test(session)) { | |
| 741 | + | updated = { ...input, command: `${sessionPrefix(tool, session)}${input.command}` }; | |
| 641 | 742 | } | |
| 642 | 743 | const g1t = g1tTool(tool); | |
| 643 | 744 | if (g1t === "issue" && ["claim", "release", "handoff"].includes(input.action) && !input.session_id) { | |
| ⋯ | |||
| 654 | 755 | if (!event.session_id || !existsSync(sessionFiles(event.session_id, env).marker)) return { output: null, job: null }; | |
| 655 | 756 | return { output: preToolUse(event, env), job: null }; | |
| 656 | 757 | } | |
| 758 | + | // Each prompt is a turn; a skill is recorded once in a turn, however it was run. | |
| 759 | + | const files = event?.session_id ? sessionFiles(event.session_id, env) : null; | |
| 760 | + | let turn = files ? Number(readText(files.turn)) || 0 : 0; | |
| 761 | + | if (name === "UserPromptSubmit" && files && existsSync(files.marker)) write(files.turn, String(++turn)); | |
| 657 | 762 | const job = plan(event, env); | |
| 658 | 763 | if (!job) return { output: null, job: null }; | |
| 659 | 764 | if (name === "SessionStart") { | |
| 660 | 765 | const bin = findCli(env); | |
| 661 | 766 | const output = sessionStart(event, env, bin); | |
| 662 | 767 | if (!output) return { output: null, job: null }; | |
| 768 | + | if (env.CLAUDE_ENV_FILE) job.envFile = env.CLAUDE_ENV_FILE; | |
| 663 | 769 | return { output: Object.keys(output).length ? output : null, job: bin ? job : null }; | |
| 664 | 770 | } | |
| 665 | 771 | // Only sessions SessionStart found in a g1t working copy are recorded. | |
| 666 | − | const files = sessionFiles(event.session_id, env); | |
| 667 | 772 | if (!existsSync(files.marker) || !findCli(env)) return { output: null, job: null }; | |
| 773 | + | for (const step of job.steps) if (step.skill) step.key = `skill:${safeName(step.skill)}:${turn}`; | |
| 668 | 774 | if (name === "SubagentStart") { | |
| 669 | 775 | const task = takeTask(files, job.agent.type); | |
| 670 | 776 | if (task) Object.assign(job.agent, { task: task.task, prompt: env.G1T_RECORD_PROMPTS === "0" ? null : task.prompt }); | |
| ⋯ | |||
| 675 | 781 | return { output: notice ? { systemMessage: notice } : null, job }; | |
| 676 | 782 | } | |
| 677 | 783 | ||
| 784 | + | /** | |
| 785 | + | * On a Node older than MIN_NODE, nothing is recorded: what to print (the | |
| 786 | + | * notice, once, at a session's start or a prompt), or null. Undefined on a | |
| 787 | + | * current Node. | |
| 788 | + | */ | |
| 789 | + | export function oldNode(event, env = process.env, version = process.versions.node) { | |
| 790 | + | if (Number(String(version).split(".")[0]) >= MIN_NODE) return undefined; | |
| 791 | + | if (env.G1T_RECORD === "0" || !["SessionStart", "UserPromptSubmit"].includes(event?.hook_event_name)) return null; | |
| 792 | + | setProblem(env, "old_node", `v${version}`); | |
| 793 | + | const notice = takeNotice(env); | |
| 794 | + | return notice ? { systemMessage: notice } : null; | |
| 795 | + | } | |
| 796 | + | ||
| 678 | 797 | async function main() { | |
| 679 | 798 | if (process.argv[2] === "--worker") { | |
| 680 | 799 | work(JSON.parse(Buffer.from(process.argv[3], "base64url").toString("utf8"))); | |
| ⋯ | |||
| 682 | 801 | } | |
| 683 | 802 | let raw = ""; | |
| 684 | 803 | for await (const chunk of process.stdin) raw += chunk; | |
| 685 | − | const { output, job } = hook(JSON.parse(raw || "{}")); | |
| 804 | + | const event = JSON.parse(raw || "{}"); | |
| 805 | + | const old = oldNode(event); | |
| 806 | + | if (old !== undefined) { | |
| 807 | + | if (old) process.stdout.write(JSON.stringify(old)); | |
| 808 | + | return; | |
| 809 | + | } | |
| 810 | + | const { output, job } = hook(event); | |
| 686 | 811 | if (output) process.stdout.write(JSON.stringify(output)); | |
| 687 | 812 | if (job) background(job); | |
| 688 | 813 | } | |
| 13 | 13 | ], | |
| 14 | 14 | "PreToolUse": [ | |
| 15 | 15 | { | |
| 16 | − | "matcher": "Agent|Task|Bash|mcp__plugin_g1t_g1t__issue|mcp__g1t__issue", | |
| 16 | + | "matcher": "Agent|Task|Bash|PowerShell|mcp__plugin_g1t_g1t__issue|mcp__g1t__issue", | |
| 17 | 17 | "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/g1t.mjs\"", "timeout": 5 }] | |
| 18 | 18 | } | |
| 19 | 19 | ], |
| 1 | + | { | |
| 2 | + | "about": "Every g1t CLI invocation the plugin's hooks (hooks/g1t.mjs) can make: each command, the flags it passes and the values they take. The hook tests' stand-in CLI (test/fake-g1t.mjs) refuses anything not here, and crates/g1t's tests parse every command here, with every value, with the real CLI's parser, so the hooks never send what the CLI rejects. minimum_version is the oldest CLI that accepts all of it: the hooks say so when the installed CLI is older. Types: text, number, switch, json (--json, optionally with fields), or a list of the values accepted.", | |
| 3 | + | "minimum_version": "0.3.2", | |
| 4 | + | "commands": [ | |
| 5 | + | { "command": [], "flags": { "--version": "switch" } }, | |
| 6 | + | { | |
| 7 | + | "command": ["session", "start"], | |
| 8 | + | "flags": { | |
| 9 | + | "--harness": ["claude_code"], | |
| 10 | + | "--harness-session-id": "text", | |
| 11 | + | "--issue": "number", | |
| 12 | + | "--parent": "text", | |
| 13 | + | "--no-remember": "switch", | |
| 14 | + | "--agent-type": "text", | |
| 15 | + | "--task": "text", | |
| 16 | + | "--prompt": "text", | |
| 17 | + | "--model": "text", | |
| 18 | + | "--json": "json" | |
| 19 | + | }, | |
| 20 | + | "required": ["--harness", "--harness-session-id"] | |
| 21 | + | }, | |
| 22 | + | { | |
| 23 | + | "command": ["session", "log"], | |
| 24 | + | "flags": { "--kind": ["prompt", "tool_call", "skill"], "--key": "text" }, | |
| 25 | + | "positionals": ["text"], | |
| 26 | + | "dash_dash": true, | |
| 27 | + | "required": ["--kind"] | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | "command": ["session", "touch"], | |
| 31 | + | "flags": { "--file": "text", "--commit": "text", "--branch": "text", "--issue": "number" }, | |
| 32 | + | "repeatable": ["--file", "--commit", "--branch", "--issue"], | |
| 33 | + | "one_of": ["--file", "--commit", "--branch", "--issue"] | |
| 34 | + | }, | |
| 35 | + | { "command": ["session", "end"], "flags": { "--reason": "text", "--result": "text" } }, | |
| 36 | + | { "command": ["session", "heartbeat"], "flags": {} }, | |
| 37 | + | { "command": ["work", "context"], "flags": {}, "positionals": ["number"] }, | |
| 38 | + | { | |
| 39 | + | "command": ["api"], | |
| 40 | + | "flags": { "-f": "text", "--silent": "switch" }, | |
| 41 | + | "positionals": ["text"], | |
| 42 | + | "repeatable": ["-f"] | |
| 43 | + | } | |
| 44 | + | ] | |
| 45 | + | } |
| 1 | 1 | #!/usr/bin/env node | |
| 2 | 2 | // A stand-in for the g1t CLI in the hook tests: writes each call to | |
| 3 | 3 | // FAKE_G1T_LOG as a JSON line and answers as FAKE_G1T_* say. | |
| 4 | − | import { appendFileSync } from "node:fs"; | |
| 4 | + | // | |
| 5 | + | // It is as strict as the real CLI, or stricter: a call that is not in | |
| 6 | + | // cli-contract.json (a command, a flag, a value, a missing argument) is | |
| 7 | + | // refused with exit code 2, as the real CLI refuses what its parser does | |
| 8 | + | // not take. crates/g1t's tests parse everything in cli-contract.json with | |
| 9 | + | // the real parser, so what this accepts, the real CLI accepts too. | |
| 10 | + | import { appendFileSync, readFileSync } from "node:fs"; | |
| 11 | + | import { resolve } from "node:path"; | |
| 12 | + | import { fileURLToPath } from "node:url"; | |
| 5 | 13 | ||
| 6 | − | const args = process.argv.slice(2); | |
| 7 | − | appendFileSync(process.env.FAKE_G1T_LOG, `${JSON.stringify({ args, session: process.env.G1T_SESSION ?? null })}\n`); | |
| 8 | − | const exit = Number(process.env.FAKE_G1T_EXIT ?? 0); | |
| 9 | − | if (exit) process.exit(exit); | |
| 10 | − | if (args[0] === "session" && args[1] === "start") { | |
| 11 | − | // A subagent's session has an id of its own, from its harness id. | |
| 12 | − | const harness = args[args.indexOf("--harness-session-id") + 1] ?? ""; | |
| 13 | − | const id = args.includes("--parent") ? `ses_sub_${harness.split("/agent/")[1]}` : "ses_fake01"; | |
| 14 | − | process.stdout.write(JSON.stringify({ id, state: "active" })); | |
| 14 | + | export const CONTRACT = JSON.parse(readFileSync(new URL("./cli-contract.json", import.meta.url), "utf8")); | |
| 15 | + | ||
| 16 | + | /** Why the real CLI would refuse `args`, or null when it takes them. */ | |
| 17 | + | export function refusal(args, contract = CONTRACT) { | |
| 18 | + | const command = contract.commands | |
| 19 | + | .filter((c) => c.command.every((word, i) => args[i] === word)) | |
| 20 | + | .sort((a, b) => b.command.length - a.command.length)[0]; | |
| 21 | + | if (!command || args.length === 0) return `not a command: ${args.join(" ")}`; | |
| 22 | + | const name = command.command.join(" ") || "g1t"; | |
| 23 | + | const rest = args.slice(command.command.length); | |
| 24 | + | const seen = new Map(); | |
| 25 | + | const positionals = []; | |
| 26 | + | const check = (flag, type, value) => { | |
| 27 | + | if (Array.isArray(type) && !type.includes(value)) return `${flag} takes ${type.join(", ")}, not ${value}`; | |
| 28 | + | if (type === "number" && !/^\d+$/.test(value)) return `${flag} takes a number, not ${value}`; | |
| 29 | + | return null; | |
| 30 | + | }; | |
| 31 | + | for (let i = 0; i < rest.length; i++) { | |
| 32 | + | const token = rest[i]; | |
| 33 | + | if (token === "--" && command.dash_dash) { | |
| 34 | + | positionals.push(...rest.slice(i + 1)); | |
| 35 | + | break; | |
| 36 | + | } | |
| 37 | + | if (token.startsWith("-") && token !== "-") { | |
| 38 | + | // --flag=value: the value is taken as it is, even one that starts with a dash. | |
| 39 | + | const eq = token.startsWith("--") ? token.indexOf("=") : -1; | |
| 40 | + | const flag = eq > 0 ? token.slice(0, eq) : token; | |
| 41 | + | const type = command.flags[flag]; | |
| 42 | + | if (type === undefined) return `${name} takes no ${flag}`; | |
| 43 | + | if (seen.has(flag) && !(command.repeatable ?? []).includes(flag)) return `${flag} given twice`; | |
| 44 | + | let value = null; | |
| 45 | + | if (eq > 0) { | |
| 46 | + | if (type === "switch") return `${flag} takes no value`; | |
| 47 | + | value = token.slice(eq + 1); | |
| 48 | + | } else if (type === "json") { | |
| 49 | + | if (rest[i + 1] !== undefined && !rest[i + 1].startsWith("-")) value = rest[++i]; | |
| 50 | + | } else if (type !== "switch") { | |
| 51 | + | value = rest[++i]; | |
| 52 | + | if (value === undefined) return `${flag} needs a value`; | |
| 53 | + | // As clap does: a value after a space may not look like a flag. | |
| 54 | + | if (value.startsWith("-") && value !== "-") return `unexpected argument '${value}' found (use ${flag}=VALUE)`; | |
| 55 | + | } | |
| 56 | + | if (value !== null && type !== "json") { | |
| 57 | + | const wrong = check(flag, type, value); | |
| 58 | + | if (wrong) return wrong; | |
| 59 | + | } | |
| 60 | + | seen.set(flag, value); | |
| 61 | + | continue; | |
| 62 | + | } | |
| 63 | + | positionals.push(token); | |
| 64 | + | } | |
| 65 | + | const want = command.positionals ?? []; | |
| 66 | + | if (positionals.length !== want.length) return `${name} takes ${want.length} argument(s), given ${positionals.length}`; | |
| 67 | + | for (const [i, kind] of want.entries()) { | |
| 68 | + | const wrong = check("an argument", kind, positionals[i]); | |
| 69 | + | if (wrong) return wrong; | |
| 70 | + | } | |
| 71 | + | for (const flag of command.required ?? []) if (!seen.has(flag)) return `${name} needs ${flag}`; | |
| 72 | + | if (command.one_of && !command.one_of.some((flag) => seen.has(flag))) return `${name} needs one of ${command.one_of.join(", ")}`; | |
| 73 | + | return null; | |
| 15 | 74 | } | |
| 16 | − | if (args[0] === "work" && args[1] === "context") process.stdout.write(`#${args[2]} The issue's title\nDefinition of done: it works.\n`); | |
| 75 | + | ||
| 76 | + | function main() { | |
| 77 | + | const args = process.argv.slice(2); | |
| 78 | + | appendFileSync(process.env.FAKE_G1T_LOG, `${JSON.stringify({ args, session: process.env.G1T_SESSION ?? null })}\n`); | |
| 79 | + | const refused = refusal(args); | |
| 80 | + | if (refused) { | |
| 81 | + | process.stderr.write(`g1t: ${refused} See --help.\n`); | |
| 82 | + | process.exit(2); | |
| 83 | + | } | |
| 84 | + | if (args[0] === "--version") { | |
| 85 | + | process.stdout.write(`g1t ${process.env.FAKE_G1T_VERSION ?? CONTRACT.minimum_version}\n`); | |
| 86 | + | process.exit(0); | |
| 87 | + | } | |
| 88 | + | const exit = Number(process.env.FAKE_G1T_EXIT ?? 0); | |
| 89 | + | if (exit) process.exit(exit); | |
| 90 | + | if (args[0] === "session" && args[1] === "start") { | |
| 91 | + | // A subagent's session has an id of its own, from its harness id. | |
| 92 | + | const harness = args.find((a) => a.startsWith("--harness-session-id="))?.split("=")[1] ?? args[args.indexOf("--harness-session-id") + 1] ?? ""; | |
| 93 | + | const id = args.some((a) => a === "--parent" || a.startsWith("--parent=")) ? `ses_sub_${harness.split("/agent/")[1]}` : "ses_fake01"; | |
| 94 | + | process.stdout.write(JSON.stringify({ id, state: "active" })); | |
| 95 | + | } | |
| 96 | + | if (args[0] === "work" && args[1] === "context") process.stdout.write(`#${args[2]} The issue's title\nDefinition of done: it works.\n`); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); |
| 14 | 14 | branchFromCommand, | |
| 15 | 15 | g1tTool, | |
| 16 | 16 | hook, | |
| 17 | + | MIN_CLI, | |
| 18 | + | oldNode, | |
| 19 | + | older, | |
| 20 | + | typedSkill, | |
| 17 | 21 | isG1tRemote, | |
| 18 | 22 | isGitCommit, | |
| 19 | 23 | isGitPush, | |
| ⋯ | |||
| 27 | 31 | work, | |
| 28 | 32 | } from "../hooks/g1t.mjs"; | |
| 29 | 33 | ||
| 34 | + | import { CONTRACT, refusal } from "./fake-g1t.mjs"; | |
| 35 | + | ||
| 30 | 36 | const HERE = fileURLToPath(new URL(".", import.meta.url)); | |
| 31 | 37 | const SCRIPT = join(HERE, "..", "hooks", "g1t.mjs"); | |
| 32 | 38 | const FAKE = join(HERE, "fake-g1t.mjs"); | |
| ⋯ | |||
| 49 | 55 | assert.ok(args[5].endsWith("…")); | |
| 50 | 56 | }); | |
| 51 | 57 | ||
| 58 | + | test("a turn Claude Code starts itself for a background task is not a prompt", () => { | |
| 59 | + | const notification = "<task-notification>\n<task-id>a1</task-id>\n<result>SECRET OUTPUT</result>\n</task-notification>"; | |
| 60 | + | assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: notification }, {}), null); | |
| 61 | + | assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "hi" }, {}).steps[0].args.at(-1), "hi"); | |
| 62 | + | }); | |
| 63 | + | ||
| 52 | 64 | test("G1T_RECORD_PROMPTS=0 keeps prompts back, and G1T_RECORD=0 everything", () => { | |
| 53 | 65 | assert.equal(plan({ ...base, hook_event_name: "UserPromptSubmit", prompt: "hi" }, { G1T_RECORD_PROMPTS: "0" }), null); | |
| 54 | 66 | assert.ok(plan({ ...base, hook_event_name: "Stop" }, { G1T_RECORD_PROMPTS: "0" })); | |
| ⋯ | |||
| 168 | 180 | test("SubagentStop ends it with its final message, cut to the limit", () => { | |
| 169 | 181 | const job = plan(fixtures.subagentStop, {}); | |
| 170 | 182 | assert.deepEqual(job.agent, { id: "def456", type: "Explore" }); | |
| 171 | − | assert.deepEqual(job.steps, [{ args: ["session", "end", "--reason", "done", "--result", "Analysis complete. Found 3 potential issues..."] }]); | |
| 183 | + | assert.deepEqual(job.steps, [{ args: ["session", "end", "--reason", "done", "--result=Analysis complete. Found 3 potential issues..."] }]); | |
| 172 | 184 | const long = plan({ ...fixtures.subagentStop, last_assistant_message: "z".repeat(RESULT_LIMIT * 2) }, {}); | |
| 173 | − | assert.equal(long.steps[0].args.at(-1).length, RESULT_LIMIT); | |
| 185 | + | assert.equal(long.steps[0].args.at(-1).length, "--result=".length + RESULT_LIMIT); | |
| 186 | + | const dash = plan({ ...fixtures.subagentStop, last_assistant_message: "- Found 3 issues" }, {}); | |
| 187 | + | assert.equal(dash.steps[0].args.at(-1), "--result=- Found 3 issues", "a message that starts with a dash is still a value"); | |
| 188 | + | assert.equal(refusal(dash.steps[0].args), null); | |
| 174 | 189 | }); | |
| 175 | 190 | ||
| 176 | 191 | test("an Agent call gives its subagent the task, and once finished its final message, and nothing else of its answer", () => { | |
| ⋯ | |||
| 197 | 212 | assert.deepEqual(job.agent, { id: "agent-def456", type: "Explore" }); | |
| 198 | 213 | assert.deepEqual(job.steps, [ | |
| 199 | 214 | { args: ["session", "log", "--kind", "tool_call", "--", "Edit: src/api/routes.ts"] }, | |
| 200 | − | { args: ["session", "touch", "--file", "src/api/routes.ts"] }, | |
| 215 | + | { args: ["session", "touch", "--file=src/api/routes.ts"] }, | |
| 201 | 216 | ]); | |
| 202 | 217 | assert.ok(!JSON.stringify(job).includes("SECRET PATCH")); | |
| 203 | 218 | }); | |
| 204 | 219 | ||
| 205 | 220 | test("a skill is its name only", () => { | |
| 206 | − | assert.deepEqual(plan(fixtures.skill, {}).steps, [{ args: ["session", "log", "--kind", "skill", "--", "work-on-issue"] }]); | |
| 221 | + | assert.deepEqual(plan(fixtures.skill, {}).steps, [{ args: ["session", "log", "--kind", "skill", "--", "work-on-issue"], skill: "work-on-issue" }]); | |
| 207 | 222 | }); | |
| 208 | 223 | ||
| 209 | 224 | test("branches made, commits, claims and g1t's MCP tools are touched", () => { | |
| ⋯ | |||
| 280 | 295 | let dir, env, log; | |
| 281 | 296 | ||
| 282 | 297 | const git = (...args) => spawnSync("git", args, { cwd: dir, encoding: "utf8" }); | |
| 283 | − | const calls = () => | |
| 298 | + | const allCalls = () => | |
| 284 | 299 | existsSync(log) | |
| 285 | 300 | ? readFileSync(log, "utf8") | |
| 286 | 301 | .trim() | |
| ⋯ | |||
| 288 | 303 | .filter(Boolean) | |
| 289 | 304 | .map((l) => JSON.parse(l)) | |
| 290 | 305 | : []; | |
| 306 | + | // The calls that do something: not the version check before them. | |
| 307 | + | const calls = () => allCalls().filter((c) => c.args[0] !== "--version"); | |
| 291 | 308 | ||
| 292 | 309 | beforeEach(() => { | |
| 293 | 310 | dir = mkdtempSync(join(tmpdir(), "g1t-hook-")); | |
| ⋯ | |||
| 296 | 313 | git("remote", "add", "origin", "https://g1t.sh/acme/app.git"); | |
| 297 | 314 | env = { PATH: process.env.PATH, G1T_BIN: FAKE, FAKE_G1T_LOG: log, CLAUDE_PLUGIN_DATA: join(dir, ".data") }; | |
| 298 | 315 | }); | |
| 299 | − | afterEach(() => rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })); | |
| 316 | + | afterEach(() => { | |
| 317 | + | // Every call the hooks made is one the real CLI takes (the stand-in refuses the rest). | |
| 318 | + | const refused = allCalls().filter((c) => refusal(c.args)); | |
| 319 | + | rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); | |
| 320 | + | assert.deepEqual(refused, [], "the CLI refuses these calls"); | |
| 321 | + | }); | |
| 300 | 322 | ||
| 301 | 323 | test("SessionStart gives Claude the branch's issue's work context", () => { | |
| 302 | 324 | const { output, job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart", source: "startup" }, env); | |
| ⋯ | |||
| 373 | 395 | const first = childStarts[0].args; | |
| 374 | 396 | assert.deepEqual(first.slice(0, 8), ["session", "start", "--harness", "claude_code", "--harness-session-id", "abc-123/agent/def456", "--parent", "ses_fake01"]); | |
| 375 | 397 | assert.ok(first.includes("--no-remember")); | |
| 376 | − | assert.equal(first[first.indexOf("--agent-type") + 1], "Explore"); | |
| 377 | − | assert.equal(first[first.indexOf("--task") + 1], "Find API endpoints"); | |
| 398 | + | assert.ok(first.includes("--agent-type=Explore")); | |
| 399 | + | assert.ok(first.includes("--task=Find API endpoints")); | |
| 378 | 400 | const edit = log.find((c) => c.args[2] === "--kind" && c.args.at(-1) === "Edit: src/api/routes.ts"); | |
| 379 | 401 | assert.equal(edit.session, "ses_sub_def456"); | |
| 380 | 402 | const touched = log.find((c) => c.args[1] === "touch"); | |
| 381 | − | assert.deepEqual(touched, { args: ["session", "touch", "--file", "src/api/routes.ts"], session: "ses_sub_def456" }); | |
| 403 | + | assert.deepEqual(touched, { args: ["session", "touch", "--file=src/api/routes.ts"], session: "ses_sub_def456" }); | |
| 382 | 404 | const ended = log.find((c) => c.args[1] === "end"); | |
| 383 | − | assert.deepEqual(ended, { args: ["session", "end", "--reason", "done", "--result", "Analysis complete. Found 3 potential issues..."], session: "ses_sub_def456" }); | |
| 405 | + | assert.deepEqual(ended, { args: ["session", "end", "--reason", "done", "--result=Analysis complete. Found 3 potential issues..."], session: "ses_sub_def456" }); | |
| 384 | 406 | }); | |
| 385 | 407 | ||
| 386 | 408 | test("a subagent whose stop arrives first is started, then ended", () => { | |
| ⋯ | |||
| 398 | 420 | const log = calls().filter((c) => c.args[0] === "session"); | |
| 399 | 421 | assert.equal(log.find((c) => c.args[1] === "log").session, "ses_fake01", "the Agent call is the parent's"); | |
| 400 | 422 | const end = log.find((c) => c.args[1] === "end"); | |
| 401 | − | assert.deepEqual(end, { args: ["session", "end", "--reason", "done", "--result", "Found 12 endpoints in src/api."], session: "ses_sub_a4d2c8f1e0b3a297" }); | |
| 423 | + | assert.deepEqual(end, { args: ["session", "end", "--reason", "done", "--result=Found 12 endpoints in src/api."], session: "ses_sub_a4d2c8f1e0b3a297" }); | |
| 402 | 424 | }); | |
| 403 | 425 | ||
| 404 | 426 | test("a hand-off names its issue at the start", () => { | |
| ⋯ | |||
| 470 | 492 | assert.equal(bad.status, 0); | |
| 471 | 493 | assert.equal(bad.stdout, ""); | |
| 472 | 494 | }); | |
| 495 | + | ||
| 496 | + | test("a CLI older than the plugin needs records nothing and says so once, with how to update it", () => { | |
| 497 | + | const old = { ...env, FAKE_G1T_VERSION: "0.3.1" }; | |
| 498 | + | hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, old); | |
| 499 | + | work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, old); | |
| 500 | + | work(plan({ ...base, cwd: dir, hook_event_name: "Stop" }, old), old); | |
| 501 | + | // SessionStart still asks it for the issue's context, which every CLI gives. | |
| 502 | + | assert.deepEqual(calls().map((c) => c.args.join(" ")), ["work context 42"], "nothing is recorded with it"); | |
| 503 | + | assert.equal(allCalls().filter((c) => c.args[0] === "--version").length, 1, "the version is asked once for each build of the CLI"); | |
| 504 | + | const first = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, old); | |
| 505 | + | assert.match(first.output.systemMessage, /g1t CLI is 0\.3\.1, and the plugin needs 0\.3\.2 or later/); | |
| 506 | + | assert.match(first.output.systemMessage, /curl -fsSL https:\/\/g1t\.sh\/install \| sh/); | |
| 507 | + | assert.match(first.output.systemMessage, /irm https:\/\/g1t\.sh\/install\.ps1 \| iex/); | |
| 508 | + | assert.equal(hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, old).output, null); | |
| 509 | + | assert.equal(older("0.3.1", MIN_CLI), true); | |
| 510 | + | assert.equal(older("0.3.2", MIN_CLI), false); | |
| 511 | + | assert.equal(older("0.10.0", MIN_CLI), false); | |
| 512 | + | assert.equal(older("1.0.0", MIN_CLI), false); | |
| 513 | + | }); | |
| 514 | + | ||
| 515 | + | test("a CLI that refuses a command (exit 2) is said once, as one to update", () => { | |
| 516 | + | hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env); | |
| 517 | + | work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, { ...env, FAKE_G1T_EXIT: "2" }); | |
| 518 | + | const first = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env); | |
| 519 | + | assert.match(first.output.systemMessage, /refused `g1t session start`/); | |
| 520 | + | assert.match(first.output.systemMessage, /installer again/); | |
| 521 | + | assert.equal(hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "hi" }, env).output, null); | |
| 522 | + | }); | |
| 523 | + | ||
| 524 | + | test("the stand-in CLI refuses what the real one refuses", () => { | |
| 525 | + | assert.equal(CONTRACT.minimum_version, MIN_CLI, "the hooks ask for the CLI the contract needs"); | |
| 526 | + | assert.match(refusal(["session", "log", "--kind", "tool-calls", "--", "x"]), /takes prompt, tool_call, skill/); | |
| 527 | + | assert.match(refusal(["session", "log", "--kind", "note", "--", "x"]), /not note/, "the hooks never send a note"); | |
| 528 | + | assert.match(refusal(["session", "end", "--result", "- a list"]), /unexpected argument/); | |
| 529 | + | assert.equal(refusal(["session", "end", "--result=- a list"]), null); | |
| 530 | + | assert.match(refusal(["session", "touch"]), /needs one of/); | |
| 531 | + | assert.match(refusal(["session", "heartbeat", "--now"]), /takes no --now/); | |
| 532 | + | assert.match(refusal(["session", "start", "--harness", "claude-managed-agents", "--harness-session-id", "x"]), /takes claude_code/); | |
| 533 | + | assert.match(refusal(["work", "context", "abc"]), /not abc/); | |
| 534 | + | assert.match(refusal(["session", "pause"]), /takes 0 argument/); | |
| 535 | + | assert.equal(refusal(["api", "update_session", "-f", "id=ses_1", "-f", "head_commit=abc", "--silent"]), null); | |
| 536 | + | }); | |
| 537 | + | ||
| 538 | + | test("PowerShell commands are read as Bash ones are, and a subagent's act as its session", () => { | |
| 539 | + | const ps = (command, extra = {}) => ({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "PowerShell", tool_input: { command }, ...extra }); | |
| 540 | + | assert.equal(plan(ps("g1t work claim 42"), env).issue, 42); | |
| 541 | + | assert.deepEqual(plan(ps("git checkout -b 42-fix; git status"), env).steps.at(-1), { args: ["session", "touch", "--branch", "42-fix"] }); | |
| 542 | + | assert.deepEqual(plan(ps("git commit -m x"), env).steps.at(-1), { touchHead: true }); | |
| 543 | + | assert.deepEqual(plan(ps("git push origin 42-fix"), env).steps.at(-1), { headCommit: true }); | |
| 544 | + | hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env); | |
| 545 | + | work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env); | |
| 546 | + | work(hook({ ...fixtures.subagentStart, cwd: dir }, env).job, env); | |
| 547 | + | const pre = (tool_name, command) => | |
| 548 | + | hook({ ...base, cwd: dir, hook_event_name: "PreToolUse", agent_id: "agent-def456", agent_type: "Explore", tool_name, tool_input: { command } }, env).output; | |
| 549 | + | assert.equal(pre("PowerShell", "g1t work claim 42").hookSpecificOutput.updatedInput.command, "$env:G1T_SESSION='ses_sub_def456'; g1t work claim 42"); | |
| 550 | + | assert.equal(pre("PowerShell", "g1t.exe session view").hookSpecificOutput.updatedInput.command, "$env:G1T_SESSION='ses_sub_def456'; g1t.exe session view"); | |
| 551 | + | assert.equal(pre("Bash", "echo $G1T_SESSION").hookSpecificOutput.updatedInput.command, "export G1T_SESSION=ses_sub_def456; echo $G1T_SESSION"); | |
| 552 | + | assert.equal(pre("PowerShell", "$env:G1T_SESSION='ses_other'; g1t work claim 42"), null, "a session the command sets itself is kept"); | |
| 553 | + | assert.equal(pre("PowerShell", "Get-ChildItem"), null, "other commands are left alone"); | |
| 554 | + | // The tool call is recorded as Claude wrote it. | |
| 555 | + | const after = plan(ps("$env:G1T_SESSION='ses_sub_def456'; g1t work claim 42", { agent_id: "agent-def456" }), env); | |
| 556 | + | assert.deepEqual(after.steps[0].args.at(-1), "PowerShell: g1t work claim 42"); | |
| 557 | + | assert.equal(summarizeTool("Bash", { command: "export G1T_SESSION=ses_1; g1t pr view" }), "Bash: g1t pr view"); | |
| 558 | + | }); | |
| 559 | + | ||
| 560 | + | test("a skill typed as /plugin:name is recorded once in its turn, however Claude Code then runs it", () => { | |
| 561 | + | assert.equal(typedSkill("/g1t:work-on-issue 42"), "g1t:work-on-issue"); | |
| 562 | + | assert.equal(typedSkill("/g1t:status"), "g1t:status"); | |
| 563 | + | assert.equal(typedSkill("/clear"), null, "Claude Code's own commands are not skills"); | |
| 564 | + | assert.equal(typedSkill("look at /g1t:status"), null); | |
| 565 | + | hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, env); | |
| 566 | + | work({ cwd: dir, sid: base.session_id, start: true, steps: [] }, env); | |
| 567 | + | const typed = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "/g1t:work-on-issue 42" }, env).job; | |
| 568 | + | assert.deepEqual(typed.steps.map((s) => s.args.slice(0, 4)), [["session", "log", "--kind", "prompt"], ["session", "log", "--kind", "skill"]]); | |
| 569 | + | work(typed, env); | |
| 570 | + | const tool = hook({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "Skill", tool_input: { skill: "g1t:work-on-issue", args: "42" } }, env).job; | |
| 571 | + | work(tool, env); | |
| 572 | + | const skills = calls().filter((c) => c.args.includes("skill")); | |
| 573 | + | assert.equal(skills.length, 2); | |
| 574 | + | assert.deepEqual(skills[0].args, ["session", "log", "--kind", "skill", "--key=skill:work-on-issue:1", "--", "g1t:work-on-issue"]); | |
| 575 | + | assert.equal(skills[1].args[4], "--key=skill:work-on-issue:1", "the same key: g1t keeps the first"); | |
| 576 | + | // Another skill in the turn has a key of its own; the next prompt is another turn. | |
| 577 | + | const next = hook({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "Skill", tool_input: { skill: "g1t:coordinate" } }, env).job; | |
| 578 | + | assert.equal(next.steps[0].key, "skill:coordinate:1"); | |
| 579 | + | hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "go on" }, { ...env, G1T_RECORD_PROMPTS: "0" }); | |
| 580 | + | const later = hook({ ...base, cwd: dir, hook_event_name: "PostToolUse", tool_name: "Skill", tool_input: { skill: "g1t:work-on-issue" } }, env).job; | |
| 581 | + | assert.equal(later.steps[0].key, "skill:work-on-issue:2", "a turn whose prompt is not sent is still a turn"); | |
| 582 | + | // Prompts kept back: the typed skill's name is still recorded, the prompt is not. | |
| 583 | + | const quiet = hook({ ...base, cwd: dir, hook_event_name: "UserPromptSubmit", prompt: "/g1t:status" }, { ...env, G1T_RECORD_PROMPTS: "0" }).job; | |
| 584 | + | assert.deepEqual(quiet.steps.map((s) => s.args.at(-1)), ["g1t:status"]); | |
| 585 | + | }); | |
| 586 | + | ||
| 587 | + | test("Claude's own commands get the session's id once it has started", () => { | |
| 588 | + | const envFile = join(dir, "env.sh"); | |
| 589 | + | const { job } = hook({ ...base, cwd: dir, hook_event_name: "SessionStart" }, { ...env, CLAUDE_ENV_FILE: envFile }); | |
| 590 | + | assert.ok(!existsSync(envFile), "nothing is written before the session's id is known"); | |
| 591 | + | work(job, env); | |
| 592 | + | // A plain assignment: Claude Code gives it to PowerShell commands too. | |
| 593 | + | assert.equal(readFileSync(envFile, "utf8"), "export G1T_SESSION=ses_fake01\n"); | |
| 594 | + | }); | |
| 595 | + | ||
| 596 | + | test("on a Node older than 18 nothing is recorded, and that is said once", () => { | |
| 597 | + | const at = (hook_event_name) => ({ ...base, cwd: dir, hook_event_name }); | |
| 598 | + | assert.equal(oldNode(at("SessionStart"), env, "18.0.0"), undefined); | |
| 599 | + | const first = oldNode(at("SessionStart"), env, "16.20.2"); | |
| 600 | + | assert.match(first.systemMessage, /need Node 18 or later, and `node` is v16\.20\.2/); | |
| 601 | + | assert.equal(oldNode(at("UserPromptSubmit"), env, "16.20.2"), null); | |
| 602 | + | assert.equal(oldNode(at("PostToolUse"), env, "16.20.2"), null); | |
| 603 | + | assert.deepEqual(calls(), []); | |
| 604 | + | }); | |
| 473 | 605 | }); | |