Skip to content

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

g1t.sh

Commit

g1t's Claude Code plugin installs from its own repository, published by a workspace token, and a token for chosen repositories no longer creates others by pushing. `claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git` then `claude plugin install g1t@g1t` clones only the plugin, not this monorepo, which stays its source (.claude-plugin/marketplace.json, plugins/claude-code/). scripts/plugins-release.mjs builds the published tree (the plugin and a marketplace that points at it), checks that both manifests agree and that nothing else is in it, and pushes it with a tag `g1t--v<version>`; it refuses changed plugin files under a version already published, and says "nothing to publish" when that version is there. .g1t/workflows/plugins-release.yml runs it on pushes to main with the flagon-io workspace token PLUGINS_PUBLISH_TOKEN, sent as Basic auth, never a person's token; CI runs its tests. The README, the plugin guide, the docs' agent list, the MCP reference and its instructions, bring-your-own-agent, why-blame, the site's agent setup and the Live now empty state, llms.txt, install/claude.sh and bench/ask all give the new address, and the earlier ~/.g1t/hook.mjs install path is gone. Automation is pointed at a workspace's own tokens: the API reference's Authentication section, Actions' note on changing workflows from a job (it said a personal token) and llms.txt (never ask your person for a personal token for that) now say so, as Settings → Access tokens, the authentication and workspaces guides and git's deploy keys already did. Pushing to an address that does not exist made a repository for any token holding code:write and a member's or the workspace's identity, so a workspace token for only g1t-plugins could create flagon-io/<anything> and push to it; repos' git_token_refusal now refuses push-to-create for a token whose reach is chosen repositories, public ones only, or another workspace, and the git guide says so. Checked: the plugin's hook tests (49), plugins-release tests (8) and its check (g1t@g1t 0.2.2), cargo test -p g1t-repos --lib (314, new: chosen repositories never create one, all of a workspace creates there only, a token without a reach as before) and its wasm32 check, the site's agent-setup and llms.txt tests (20), the docs build (513 pages).

syntaqxcommitted Parent22641e9Browse files
24 files+619−1910/24 viewed
+6−0
128128 run: node scripts/deploy.mjs manifest --check
129129 - name: The Claude Code plugin's tests
130130 run: node --test plugins/claude-code/test/hooks.test.mjs
131+ # What plugins-release.yml publishes to flagon-io/g1t-plugins: the
132+ # manifests agree, and the tree is only the plugin.
133+ - name: The plugin's published marketplace
134+ run: |
135+ node scripts/plugins-release.mjs check
136+ node --test scripts/plugins-release.test.mjs
131137
132138 build:
133139 name: Build
+72−0
1+# Publishes g1t's Claude Code plugin marketplace to flagon-io/g1t-plugins
2+# on g1t.sh, so people add it without cloning this repository:
3+#
4+# claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git
5+# claude plugin install g1t@g1t
6+#
7+# This repository is the plugin's source: .claude-plugin/marketplace.json
8+# and plugins/claude-code/. scripts/plugins-release.mjs builds the published
9+# tree from them (the manifest, the plugin without its tests, a README and
10+# the license, the plugin's source rewritten to ./plugins/g1t), checks it
11+# holds nothing else and nothing secret, and commits it to g1t-plugins'
12+# main only when it changed; each new version is tagged g1t--v<version>,
13+# as `claude plugin tag` would. A change to the plugin's files without a
14+# new version stops the run, as Claude Code updates installed plugins only
15+# when the version changes.
16+#
17+# Runs on a push to main that changes the plugin, its manifest or the
18+# script, and by hand.
19+#
20+# Needs the repository secret PLUGINS_PUBLISH_TOKEN: a workspace token of
21+# flagon-io (g1t.sh/flagon-io/-/tokens, made by an owner; it acts as the
22+# workspace, not a person), select repositories: g1t-plugins only, with
23+# Code: read and write (code:write) and nothing else. g1t-plugins must
24+# exist, public; this never creates it. The published tree has no workflow
25+# files, so the token needs no workflow_files:write. The job's own token
26+# reaches this repository only, and only to read it.
27+name: Plugins release
28+
29+on:
30+ push:
31+ branches: [main]
32+ paths:
33+ - .claude-plugin/**
34+ - plugins/claude-code/**
35+ - scripts/plugins-release.mjs
36+ - LICENSE
37+ workflow_dispatch:
38+
39+permissions:
40+ contents: read
41+
42+concurrency:
43+ group: plugins-release
44+ cancel-in-progress: false
45+
46+jobs:
47+ publish:
48+ name: Publish flagon-io/g1t-plugins
49+ runs-on: ubuntu-latest
50+ timeout-minutes: 10
51+ steps:
52+ - uses: actions/checkout@v5
53+ with:
54+ # Without the job's own token in git's config: it would go with
55+ # the push to g1t-plugins beside the publish token, and be refused.
56+ persist-credentials: false
57+ - name: The plugin's tests
58+ run: |
59+ node --test plugins/claude-code/test/hooks.test.mjs
60+ node --test scripts/plugins-release.test.mjs
61+ - name: Publish
62+ env:
63+ PLUGINS_PUBLISH_TOKEN: ${{ secrets.PLUGINS_PUBLISH_TOKEN }}
64+ G1T_PLUGINS_REMOTE: ${{ github.server_url }}/flagon-io/g1t-plugins.git
65+ G1T_SOURCE_COMMIT: ${{ github.sha }}
66+ run: |
67+ if [ -z "${PLUGINS_PUBLISH_TOKEN}" ]; then
68+ echo "::error::Set the repository secret PLUGINS_PUBLISH_TOKEN (this workflow's header says what it needs)."
69+ exit 1
70+ fi
71+ echo "::add-mask::${PLUGINS_PUBLISH_TOKEN}"
72+ node scripts/plugins-release.mjs publish
+1−0
1515 | Something a person does on g1t.sh | The guide for it in `apps/docs/src/content/docs/guides/` |
1616 | An API route, a field, or an MCP tool | `apps/api/src/operations.rs` descriptions (they feed the OpenAPI document and the API reference), the example and notes for it in `apps/api/src/reference.json`, `reference/mcp.md`, and any guide that shows the call. Then refresh the docs' copy of the OpenAPI document with `G1T_WRITE_OPENAPI=1 cargo test -p g1t-api openapi`; `cargo test` fails until you do. |
1717 | What coding agents are told about g1t | `guides/bring-your-own-agent.mdx`, `guides/claude-code-plugin.md`, the plugin's skills in `plugins/claude-code/`, and `apps/web/public/llms.txt` |
18+| The Claude Code plugin's files (`plugins/claude-code/`, except `test/`) | Its version, the same in `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`: Claude Code updates an installed plugin only when it changes. On main, `plugins-release.yml` publishes it to `flagon-io/g1t-plugins`, and refuses a changed plugin with a version already published. Never commit to g1t-plugins itself. |
1819 | Settings, limits or prices | The page that names them, and the table it is in |
1920 | A new feature | A section in the guide that owns it, linked from the docs home if it is a new task |
2021 | How g1t is put together: a service, a binding, a store, an event route, the path of a request | Its page in the docs' Architecture section (`apps/docs/src/content/docs/architecture/`), and its diagram in `apps/docs/src/diagrams/architecture.ts`. [How g1t ships](https://docs.g1t.sh/architecture/shipping/#keeping-this-section-true) says which page covers what. |
+2−2
3030
3131 # 3. Install g1t's Claude Code plugin. It adds g1t's MCP server and records
3232 # your sessions; run /mcp in Claude Code and sign plugin:g1t:g1t in.
33−claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git
33+claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git
3434 claude plugin install g1t@g1t
3535 ```
3636
143143 | `crates/sshd` | Git over SSH, bridged to Cloudflare Artifacts. Not deployed. | Rust |
144144 | `packages/contracts` | The same interfaces for TypeScript callers. | TypeScript |
145145 | `packages/theme` | Design tokens and the logo, shared by the site and the docs. | CSS |
146−| `plugins/claude-code` | g1t's Claude Code plugin: hooks, skills and commands. | JavaScript |
146+| `plugins/claude-code` | g1t's Claude Code plugin: hooks, skills and commands, published to `flagon-io/g1t-plugins` on every change. | JavaScript |
147147 | `plugins/other-harnesses` | The AGENTS.md snippet for Codex, Cursor and other harnesses. | Markdown |
148148 | `deploy` | `stack.jsonc`, every deployable part and its resources; `self-host`, the Docker Compose version. | JSON, Docker Compose |
149149
+1−1
148148 "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp),
149149 "claude_code_plugin": {
150150 "install": [
151− "claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git",
151+ "claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git",
152152 "claude plugin install g1t@g1t",
153153 ],
154154 "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.",
+3−2
18081808 even with `contents: write`. A push that does is declined, naming the file,
18091809 so a workflow cannot rewrite the workflows that run with its repository's
18101810 secrets. To change workflows from a job, push with a
1811−[personal access token](/guides/authentication/#workflow-files) that has
1812−Workflow files: write, kept as a secret.
1811+[workspace access token](/guides/workspaces/#workspace-access-tokens) that
1812+has [Workflow files: write](/guides/authentication/#workflow-files), kept
1813+as a secret.
18131814
18141815 The job's token is the repository's workspace acting with the Write role
18151816 at most, never Admin: it cannot manage webhooks, secrets, deploy keys or who
+1−1
181181 repository is recorded without the agent having to remember:
182182
183183 ```sh
184−claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git
184+claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git
185185 claude plugin install g1t@g1t
186186 ```
187187
+27−13
3939 2. Add g1t's plugin marketplace and install the plugin:
4040
4141 ```sh
42− claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git
42+ claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git
4343 claude plugin install g1t@g1t
4444 ```
4545
4646 Or run `/plugin` inside Claude Code and choose the marketplace there.
4747
48+ The marketplace, `flagon-io/g1t-plugins`, holds the plugin and nothing
49+ else, so adding it downloads about 100 KB. On Windows, use Claude Code
50+ 2.1.296 or later for PowerShell session support.
51+
4852 3. Start Claude Code again, run `/mcp` and choose **plugin:g1t:g1t** to
4953 sign g1t's MCP server in through your browser.
5054
51−The plugin adds g1t's MCP server itself. If you added it before with
52−`claude mcp add … g1t https://mcp.g1t.sh`, remove that one
53−(`claude mcp remove g1t`), or Claude sees every g1t tool twice.
55+The plugin adds g1t's MCP server itself, so do not also add it with
56+`claude mcp add`, or Claude sees every g1t tool twice.
5457
5558 `curl -fsSL https://g1t.sh/install/claude.sh | sh` prints the same
5659 commands; add `-s -- --cli` to have it install the CLI too.
5962 the host in `G1T_HOST` for a self-hosted g1t. Everywhere else its hooks do
6063 nothing.
6164
65+### Update
66+
67+Each release of the plugin has a new version, tagged `g1t--v<version>` in
68+`flagon-io/g1t-plugins`. To get the newest:
69+
70+```sh
71+claude plugin marketplace update g1t
72+claude plugin update g1t@g1t
73+```
74+
75+Then start Claude Code again. `claude plugin list` says which version you
76+have.
77+
78+### Where the plugin is made
79+
80+`flagon-io/g1t-plugins` is published from g1t's repository, where the
81+plugin is developed and tested (`plugins/claude-code/`). Every change there
82+is published automatically, so the two never differ; issues and changes
83+go to [flagon-io/g1t](https://g1t.sh/flagon-io/g1t).
84+
6285 ## What it records
6386
6487 The plugin's hooks call the `g1t` CLI, signed in as you, so a session is
263286 pull request's **Session** tab and the repository's **Work** page: who ran
264287 it, its subagents as a tree, the skills used, what it touched, and the
265288 issues and pull requests it worked on.
266−
267−## The earlier hook installer
268−
269−An earlier `curl -fsSL https://g1t.sh/install/claude.sh | sh` added a hook
270−of its own, `node ~/.g1t/hook.mjs`, to `~/.claude/settings.json`. The
271−plugin replaces it. Once the plugin is installed, remove those entries from
272−`~/.claude/settings.json` so a session is not recorded twice. Running the
273−installer again brings the old hook up to date and prints the plugin's
274−commands.
275289
276290 ## Other coding agents
277291
+3−1
4545
4646 Pushing to a repository that does not exist, in a workspace you belong to,
4747 creates it as a private repository, so nothing pushed by mistake is
48−published. To make it public, see
48+published. An access token can create one only where it reaches all of the
49+workspace's repositories; a token for chosen repositories is refused. To
50+make it public, see
4951 [change who can see a repository](/guides/managing-repositories/#change-who-can-see-a-repository).
5052
5153 ```sh
+1−1
3838 agent having to remember:
3939
4040 ```sh
41−claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git
41+claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git
4242 claude plugin install g1t@g1t
4343 ```
4444
+5−0
5151 same, so Octokit and the actions built on it, such as `actions/checkout`
5252 and `actions/github-script`, are signed in with the token they are given.
5353
54+For CI and other automation that works for a team rather than for you, use
55+one of the workspace's own
56+[workspace access tokens](/guides/workspaces/#workspace-access-tokens):
57+it acts as the workspace and keeps working when you leave.
58+
5459 Public data can be read without a token. A token that is not valid is
5560 rejected with `401` rather than treated as anonymous. Each endpoint's page
5661 says whether it needs a token.
+1−1
2020 (`plugin:g1t:g1t` in `/mcp`) and records your sessions:
2121
2222 ```sh
23−claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git
23+claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git
2424 claude plugin install g1t@g1t
2525 ```
2626
+1−1
77 export const MCP_URL = 'https://mcp.g1t.sh';
88
99 /** 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';
10+export const PLUGIN_INSTALL = 'claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git\nclaude plugin install g1t@g1t';
1111
1212 export type AgentSetup = {
1313 label: string;
+4−2
6363 sessionTitle,
6464 sessionTree,
6565 } from "../lib/coordination";
66+import { PLUGIN_INSTALL } from "../lib/agent-setup";
6667 import { CopyLine, EmptyState, Input, SubmitButton, TimeAgo } from "./ui";
6768 import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
6869 import { Avatar } from "./ui/avatar";
895896 <EmptyState title={title}>
896897 <div className="mx-auto mt-3 max-w-lg space-y-3 text-left">
897898 <p>Install the g1t plugin in Claude Code, and each session shows here as it works:</p>
898− <CopyLine text="claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git" prompt />
899− <CopyLine text="claude plugin install g1t@g1t" prompt />
899+ {PLUGIN_INSTALL.split("\n").map((line) => (
900+ <CopyLine key={line} text={line} prompt />
901+ ))}
900902 <p>From any other harness, or by hand, claim an issue with the g1t CLI:</p>
901903 <CopyLine text="g1t work claim 12" prompt />
902904 <p>
+1−1
8585 test("every snippet points at the MCP server", () => {
8686 // Claude Code's is g1t's plugin, which adds the server itself.
8787 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$/);
88+ assert.match(AGENTS["claude-code"].code, /^claude plugin marketplace add https:\/\/g1t\.sh\/flagon-io\/g1t-plugins\.git\nclaude plugin install g1t@g1t$/);
8989 assert.match(AGENTS["claude-code"].then, /plugin:g1t:g1t/);
9090 assert.deepEqual(JSON.parse(AGENTS.cursor.code), { mcpServers: { g1t: { url: MCP_URL } } });
9191 assert.equal(JSON.parse(AGENTS.opencode.code).mcp.g1t.type, "remote");
+1−1
1515 export const MCP_URL = "https://mcp.g1t.sh";
1616
1717 /** 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";
18+export const PLUGIN_INSTALL = "claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git\nclaude plugin install g1t@g1t";
1919
2020 export const AGENT_IDS = ["claude-code", "codex", "opencode", "cursor"] as const;
2121 export type AgentId = (typeof AGENT_IDS)[number];
+4−12
77 # curl -fsSL https://g1t.sh/install/claude.sh | sh
88 # curl -fsSL https://g1t.sh/install/claude.sh | sh -s -- --cli
99 #
10−# It changes nothing else. An earlier version of this script installed a
11−# hook in ~/.g1t; if that is there, it is brought up to date, and you can
12−# remove it once the plugin is installed (see below).
10+# It changes nothing else.
1311 set -eu
1412
1513 BASE="${G1T_SITE:-https://g1t.sh}"
1816 curl -fsSL "$BASE/install" | sh
1917 fi
2018
21−if [ -f "$HOME/.g1t/hook.mjs" ]; then
22− curl -fsSL "$BASE/install/hook.mjs" -o "$HOME/.g1t/hook.mjs"
23− echo "Updated the earlier g1t hook in ~/.g1t/hook.mjs. The plugin replaces it:"
24− echo "once the plugin is installed, remove the \`node \"$HOME/.g1t/hook.mjs\"\`"
25− echo "entries from ~/.claude/settings.json, so a session is not recorded twice."
26− echo
27−fi
28−
19+# The plugin's marketplace is published only on g1t.sh, whichever g1t it
20+# works with (a self-hosted one is G1T_HOST to its hooks).
2921 cat <<EOF
3022 Install g1t's plugin for Claude Code:
3123
32− claude plugin marketplace add $BASE/flagon-io/g1t.git
24+ claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git
3325 claude plugin install g1t@g1t
3426
3527 The plugin's hooks use the g1t CLI (0.3.2 or later) and Node 18 or later.
+0−148
1−#!/usr/bin/env node
2−// g1t's Claude Code hook: records what your agent does onto the pull
3−// request you are working on, so its session sits beside the code.
4−//
5−// Claude Code runs this for UserPromptSubmit, PostToolUse and Stop, with
6−// the event as JSON on stdin. It works out the pull request from the
7−// working copy's git remote: a g1t fork (g1t.sh/pulls/<id>) or a branch
8−// of a g1t repository with an open pull request. Anywhere else it does
9−// nothing. It never fails the agent's turn.
10−//
11−// It records what each tool was called with, never what it returned.
12−//
13−// Installed by an earlier `curl -fsSL https://g1t.sh/install/claude.sh | sh`.
14−// g1t's Claude Code plugin replaces it:
15−// https://docs.g1t.sh/guides/claude-code-plugin/
16−
17−import { execFileSync } from "node:child_process";
18−import { existsSync, readFileSync, writeFileSync } from "node:fs";
19−import { homedir } from "node:os";
20−import { join } from "node:path";
21−
22−const HOME = join(homedir(), ".g1t");
23−const CONFIG = join(HOME, "config.json");
24−const CACHE = join(HOME, "pulls.json");
25−/** The longest any one entry is sent. */
26−const MAX_TEXT = 4000;
27−
28−function read(path, fallback) {
29− try {
30− return JSON.parse(readFileSync(path, "utf8"));
31− } catch {
32− return fallback;
33− }
34−}
35−
36−function git(cwd, ...args) {
37− try {
38− return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
39− } catch {
40− return "";
41− }
42−}
43−
44−function clip(text) {
45− const value = typeof text === "string" ? text : JSON.stringify(text ?? "");
46− return value.length > MAX_TEXT ? `${value.slice(0, MAX_TEXT)}…` : value;
47−}
48−
49−/** What a tool call did, in one line where possible. */
50−function describeCall(name, input = {}) {
51− if (input.command) return input.command;
52− if (input.file_path) return input.file_path;
53− if (input.pattern) return `${input.pattern}${input.path ? ` in ${input.path}` : ""}`;
54− if (input.url) return input.url;
55− return clip(input);
56−}
57−
58−/**
59− * The pull request this working copy belongs to, if any. Only a pull
60− * request found is cached: a branch whose pull request is opened later in
61− * the session is looked up again, and recorded from then on.
62− */
63−async function pullFor(cwd, config) {
64− const remote = git(cwd, "remote", "get-url", "origin");
65− const branch = git(cwd, "branch", "--show-current");
66− if (!remote.includes("g1t.sh/")) return null;
67− const key = `${remote}#${branch}`;
68− const cache = read(CACHE, {});
69− if (cache[key]) return cache[key];
70− const headers = { authorization: `Bearer ${config.token}` };
71− let found = null;
72− const fork = /g1t\.sh\/pulls\/([a-z0-9_]+?)(?:\.git)?$/.exec(remote);
73− const repo = /g1t\.sh\/([a-z0-9-]+)\/([a-z0-9._-]+?)(?:\.git)?$/i.exec(remote);
74− if (fork) {
75− const response = await fetch(`${config.api}/pulls/${fork[1]}`, { headers });
76− if (response.ok) {
77− const pull = await response.json();
78− found = { repo: `${pull.repo.namespace}/${pull.repo.name}`, number: pull.number };
79− }
80− } else if (repo && branch) {
81− const response = await fetch(`${config.api}/repos/${repo[1]}/${repo[2]}/pulls?state=open`, { headers });
82− if (response.ok) {
83− const pulls = await response.json();
84− const pull = (Array.isArray(pulls) ? pulls : []).find((p) => p.branch === branch);
85− if (pull) found = { repo: `${repo[1]}/${repo[2]}`, number: pull.number };
86− }
87− }
88− if (found) {
89− cache[key] = found;
90− try {
91− writeFileSync(CACHE, JSON.stringify(cache));
92− } catch {}
93− }
94− return found;
95−}
96−
97−/** The agent's last words this turn, from its transcript. */
98−function lastMessage(transcriptPath) {
99− if (!transcriptPath || !existsSync(transcriptPath)) return null;
100− const lines = readFileSync(transcriptPath, "utf8").trim().split("\n").reverse();
101− for (const line of lines) {
102− try {
103− const entry = JSON.parse(line);
104− if (entry.type !== "assistant") continue;
105− const content = entry.message?.content;
106− const text = Array.isArray(content)
107− ? content.filter((part) => part.type === "text").map((part) => part.text).join("\n")
108− : typeof content === "string"
109− ? content
110− : "";
111− if (text.trim()) return text.trim();
112− } catch {}
113− }
114− return null;
115−}
116−
117−async function main() {
118− const config = read(CONFIG, null);
119− if (!config?.token) return;
120− let raw = "";
121− for await (const chunk of process.stdin) raw += chunk;
122− const event = JSON.parse(raw || "{}");
123− const pull = await pullFor(event.cwd ?? process.cwd(), config);
124− if (!pull) return;
125−
126− const entries = [];
127− switch (event.hook_event_name) {
128− case "UserPromptSubmit":
129− if (event.prompt && process.env.G1T_RECORD_PROMPTS !== "0") entries.push({ kind: "prompt", text: clip(event.prompt) });
130− break;
131− case "PostToolUse":
132− entries.push({ kind: "tool_call", tool: event.tool_name, text: clip(describeCall(event.tool_name, event.tool_input)) });
133− break;
134− case "Stop": {
135− const message = lastMessage(event.transcript_path);
136− if (message) entries.push({ kind: "message", text: clip(message) });
137− break;
138− }
139− }
140− if (entries.length === 0) return;
141− await fetch(`${config.api}/repos/${pull.repo}/pulls/${pull.number}/session`, {
142− method: "POST",
143− headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
144− body: JSON.stringify({ entries }),
145− });
146−}
147−
148−main().catch(() => {}).finally(() => process.exit(0));
+6−2
138138 person installs it:
139139
140140 ```sh
141− claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git
141+ claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git
142142 claude plugin install g1t@g1t
143143 ```
144144
353353 `issues:write`, `pull_requests:write`, `notifications:write`). For CI, the CI preset (`repo:read`, `code:read`,
354354 `code:write`, `packages:read`, `packages:write`, `workflows:read`,
355355 `workflows:write`, `checks:read`, `checks:write`, `deployments:read`,
356−`deployments:write`).
356+`deployments:write`). Automation that works for a team rather than a
357+person (CI, publishing, integrations) takes a workspace token, made by an
358+owner at g1t.sh/<workspace>/-/tokens: it acts as the workspace, reaches
359+only that workspace's chosen repositories, and keeps working when its
360+creator leaves; never ask your person for a personal token for that.
357361 `chat:read` and `chat:write` are in no preset either: with them a person's
358362 token, OAuth or personal, uses Chat's JSON routes (`/{workspace}/-/chat/api`)
359363 and live sockets on the website without "Use the website as you", and
+1−1
7575 const pluginDir = join(ROOT, "plugins", "claude-code");
7676 if (flags.installed) {
7777 const list = spawnSync("claude", ["plugin", "list"], { encoding: "utf8" });
78− add("g1t plugin installed", /\bg1t\b/.test(list.stdout ?? ""), "claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git && claude plugin install g1t@g1t");
78+ add("g1t plugin installed", /\bg1t\b/.test(list.stdout ?? ""), "claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git && claude plugin install g1t@g1t");
7979 } else add("g1t plugin (--plugin-dir)", existsSync(join(pluginDir, ".claude-plugin", "plugin.json")), pluginDir);
8080
8181 const hooks = existsSync(join(pluginDir, "hooks", "hooks.json")) ? JSON.parse(readFileSync(join(pluginDir, "hooks", "hooks.json"), "utf8")) : {};
+1−1
1616 "test:deploy": "node --test \"scripts/deploy/*.test.mjs\"",
1717 "test:ops": "node --test \"scripts/ops/*.test.mjs\"",
1818 "test:conformance": "node --test \"conformance/*.test.mjs\"",
19− "test:release": "node --test \"scripts/desktop-release.test.mjs\" \"scripts/mobile-release.test.mjs\" \"scripts/cli-release.test.mjs\""
19+ "test:release": "node --test \"scripts/desktop-release.test.mjs\" \"scripts/mobile-release.test.mjs\" \"scripts/cli-release.test.mjs\" \"scripts/plugins-release.test.mjs\""
2020 },
2121 "devDependencies": {
2222 "typescript": "^5.9.3",
+295−0
1+#!/usr/bin/env node
2+// Publishes g1t's Claude Code plugin marketplace to its own repository,
3+// flagon-io/g1t-plugins on g1t.sh, so adding the marketplace clones a few
4+// files rather than this monorepo:
5+//
6+// claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git
7+// claude plugin install g1t@g1t
8+//
9+// This repository stays the source: .claude-plugin/marketplace.json and
10+// plugins/claude-code/ are where the plugin is changed and tested, and
11+// .g1t/workflows/plugins-release.yml runs this on every push to main that
12+// touches them. Nobody commits to g1t-plugins by hand.
13+//
14+// node scripts/plugins-release.mjs check # the manifests agree; nothing is written
15+// node scripts/plugins-release.mjs build [DIR] # the published tree (default target/g1t-plugins)
16+// node scripts/plugins-release.mjs publish [--dry-run]
17+//
18+// What is published, and nothing else:
19+//
20+// .claude-plugin/marketplace.json this repository's, its plugin's source rewritten to ./plugins/g1t
21+// plugins/g1t/ plugins/claude-code/ without its tests (PLUGIN_FILES)
22+// README.md written here (readme())
23+// LICENSE this repository's
24+//
25+// A file added to plugins/claude-code/ that PLUGIN_FILES does not name
26+// stops the build, so nothing is published or left out without a change
27+// here. Every published file is scanned for anything that looks like a
28+// credential.
29+//
30+// `publish` clones g1t-plugins, replaces its tree with the build, and
31+// commits on main only when something changed, naming this repository's
32+// commit; then tags g1t--v<version> once, the tag Claude Code's own
33+// `claude plugin tag` makes. Claude Code updates an installed
34+// plugin when its version changes, so a change to the plugin's files with
35+// the version already published stops the run: bump the version in
36+// plugins/claude-code/.claude-plugin/plugin.json and in marketplace.json.
37+//
38+// G1T_PLUGINS_REMOTE where to publish (default https://g1t.sh/flagon-io/g1t-plugins.git)
39+// PLUGINS_PUBLISH_TOKEN a g1t access token that can push there; sent as a
40+// git header through the environment, never on a
41+// command line
42+// G1T_SOURCE_COMMIT this repository's commit, for the commit message
43+// (default: git rev-parse HEAD)
44+
45+import { spawnSync } from "node:child_process";
46+import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
47+import { tmpdir } from "node:os";
48+import { dirname, join, relative } from "node:path";
49+import { fileURLToPath } from "node:url";
50+
51+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
52+export const SOURCE = "plugins/claude-code";
53+export const PUBLISHED = "plugins/g1t";
54+export const REMOTE = "https://g1t.sh/flagon-io/g1t-plugins.git";
55+export const INSTALL = ["claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git", "claude plugin install g1t@g1t"];
56+
57+// What of plugins/claude-code/ is the plugin: entries published whole.
58+// DEV_FILES are this repository's only (the hooks' tests and their stand-in
59+// CLI). Anything else in the folder stops the build.
60+export const PLUGIN_FILES = [".claude-plugin", ".mcp.json", "commands", "hooks", "skills"];
61+export const DEV_FILES = ["test"];
62+
63+// Names no published file may have, and text no published file may hold.
64+const FORBIDDEN_NAMES = [/^\.env/, /^\.credentials/, /\.(pem|key|p12|pfx)$/i, /^id_(rsa|ed25519|ecdsa)/, /^\.npmrc$/, /^node_modules$/];
65+const SECRETS = [
66+ ["a g1t token", /\bg1t[a-z]*_[0-9a-f]{32,}/],
67+ ["an Anthropic key", /\bsk-ant-[A-Za-z0-9_-]{20,}/],
68+ ["an OpenAI key", /\bsk-(proj-)?[A-Za-z0-9]{32,}/],
69+ ["a GitHub token", /\b(gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/],
70+ ["an AWS key", /\bAKIA[0-9A-Z]{16}\b/],
71+ ["a Slack token", /\bxox[abpr]-[A-Za-z0-9-]{10,}/],
72+ ["a Stripe key", /\b[rs]k_(live|test)_[A-Za-z0-9]{16,}/],
73+ ["a private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
74+];
75+
76+const readJson = (root, file) => JSON.parse(readFileSync(join(root, file), "utf8"));
77+
78+/** Every file under `dir` but git's own, as paths relative to it with forward slashes, sorted. */
79+export function walk(dir, base = dir) {
80+ const out = [];
81+ for (const name of readdirSync(dir)) {
82+ if (name === ".git") continue;
83+ const path = join(dir, name);
84+ if (statSync(path).isDirectory()) out.push(...walk(path, base));
85+ else out.push(relative(base, path).replaceAll("\\", "/"));
86+ }
87+ return out.sort();
88+}
89+
90+/**
91+ * The marketplace and plugin manifests, checked against each other: one
92+ * plugin, g1t, in a marketplace g1t (so it installs as g1t@g1t wherever it
93+ * is published from), whose source is plugins/claude-code and whose version
94+ * is the plugin's own. Returns them, and the version.
95+ */
96+export function manifests(root = ROOT) {
97+ const marketplace = readJson(root, ".claude-plugin/marketplace.json");
98+ const plugin = readJson(root, `${SOURCE}/.claude-plugin/plugin.json`);
99+ const problems = [];
100+ if (marketplace.name !== "g1t") problems.push(`the marketplace is named ${JSON.stringify(marketplace.name)}, not "g1t": installs are g1t@g1t`);
101+ if (!Array.isArray(marketplace.plugins) || marketplace.plugins.length !== 1) problems.push("the marketplace lists one plugin, g1t");
102+ const entry = marketplace.plugins?.[0] ?? {};
103+ if (entry.name !== "g1t" || plugin.name !== "g1t") problems.push(`the plugin is named ${JSON.stringify(entry.name)} in marketplace.json and ${JSON.stringify(plugin.name)} in plugin.json, not "g1t"`);
104+ if (entry.source !== `./${SOURCE}`) problems.push(`marketplace.json's source is ${JSON.stringify(entry.source)}, not "./${SOURCE}"`);
105+ if (!/^\d+\.\d+\.\d+$/.test(plugin.version ?? "")) problems.push(`plugin.json's version ${JSON.stringify(plugin.version)} is not x.y.z`);
106+ if (entry.version !== plugin.version) problems.push(`marketplace.json says version ${entry.version} and plugin.json ${plugin.version}: make them the same`);
107+ if (entry.description !== plugin.description) problems.push("marketplace.json's description is not plugin.json's");
108+ if (problems.length) throw new Error(`The plugin's manifests disagree:\n- ${problems.join("\n- ")}`);
109+ return { marketplace, plugin, version: plugin.version };
110+}
111+
112+/** The marketplace manifest as published: its plugin's source is ./plugins/g1t. */
113+export function publishedMarketplace(marketplace) {
114+ return { ...marketplace, plugins: marketplace.plugins.map((p) => ({ ...p, source: `./${PUBLISHED}` })) };
115+}
116+
117+export function readme(version) {
118+ return `# g1t plugins
119+
120+g1t's plugin marketplace for Claude Code. Its one plugin, \`g1t\` (${version}),
121+records your Claude Code sessions on g1t, gives Claude the issue it is
122+working on, claims and hands off work, and adds g1t's MCP server, skills
123+and commands.
124+
125+\`\`\`sh
126+${INSTALL.join("\n")}
127+\`\`\`
128+
129+Then start Claude Code again, run \`/mcp\` and sign **plugin:g1t:g1t** in.
130+The plugin's hooks need the [g1t CLI](https://docs.g1t.sh/guides/cli/)
131+0.3.2 or later and Node 18 or later.
132+
133+The guide, with what each hook records and never records and how to opt
134+out: https://docs.g1t.sh/guides/claude-code-plugin/
135+
136+This repository is published automatically from
137+[flagon-io/g1t](https://g1t.sh/flagon-io/g1t) (\`.claude-plugin/\` and
138+\`${SOURCE}/\`). Changes and issues go there.
139+`;
140+}
141+
142+/** A release's tag: `claude plugin tag`'s {name}--v{version}. */
143+export const tagName = (version) => `g1t--v${version}`;
144+
145+/** Why the folder cannot be published as it is: entries PLUGIN_FILES and DEV_FILES do not name. */
146+function unlisted(root) {
147+ return readdirSync(join(root, SOURCE)).filter((name) => !PLUGIN_FILES.includes(name) && !DEV_FILES.includes(name));
148+}
149+
150+/** Credentials and forbidden files in a built tree, as "path: what". */
151+export function scan(dir) {
152+ const found = [];
153+ for (const file of walk(dir)) {
154+ for (const part of file.split("/")) if (FORBIDDEN_NAMES.some((re) => re.test(part))) found.push(`${file}: a file that is never published`);
155+ const text = readFileSync(join(dir, file), "utf8");
156+ for (const [what, re] of SECRETS) if (re.test(text)) found.push(`${file}: looks like ${what}`);
157+ }
158+ return found;
159+}
160+
161+/** The files the build writes, relative to its folder: the only ones it may contain. */
162+export function expectedFiles(root = ROOT) {
163+ const plugin = PLUGIN_FILES.flatMap((name) => {
164+ const path = join(root, SOURCE, name);
165+ return statSync(path).isDirectory() ? walk(path).map((f) => `${PUBLISHED}/${name}/${f}`) : [`${PUBLISHED}/${name}`];
166+ });
167+ return [".claude-plugin/marketplace.json", "LICENSE", "README.md", ...plugin].sort();
168+}
169+
170+/** Writes the published tree to `out`, replacing what was there, and checks it. */
171+export function build(out = join(ROOT, "target", "g1t-plugins"), root = ROOT) {
172+ const { marketplace, version } = manifests(root);
173+ const extra = unlisted(root);
174+ if (extra.length) throw new Error(`${SOURCE}/ has ${extra.join(", ")}, which scripts/plugins-release.mjs neither publishes nor keeps back: add each to PLUGIN_FILES or DEV_FILES.`);
175+ for (const name of PLUGIN_FILES) if (!existsSync(join(root, SOURCE, name))) throw new Error(`${SOURCE}/${name} is missing`);
176+
177+ rmSync(out, { recursive: true, force: true });
178+ mkdirSync(join(out, ".claude-plugin"), { recursive: true });
179+ writeFileSync(join(out, ".claude-plugin", "marketplace.json"), `${JSON.stringify(publishedMarketplace(marketplace), null, 2)}\n`);
180+ for (const name of PLUGIN_FILES) cpSync(join(root, SOURCE, name), join(out, PUBLISHED, name), { recursive: true });
181+ writeFileSync(join(out, "README.md"), readme(version));
182+ cpSync(join(root, "LICENSE"), join(out, "LICENSE"));
183+ verify(out, root);
184+ return { out, version };
185+}
186+
187+/** Checks a built tree: exactly the expected files, manifests that agree, nothing secret. */
188+export function verify(dir, root = ROOT) {
189+ const { version } = manifests(root);
190+ const problems = [];
191+ const files = walk(dir);
192+ const expected = expectedFiles(root);
193+ const more = files.filter((f) => !expected.includes(f));
194+ const fewer = expected.filter((f) => !files.includes(f));
195+ if (more.length) problems.push(`files that are not the plugin's: ${more.join(", ")}`);
196+ if (fewer.length) problems.push(`missing: ${fewer.join(", ")}`);
197+ const marketplace = readJson(dir, ".claude-plugin/marketplace.json");
198+ const entry = marketplace.plugins?.[0] ?? {};
199+ if (entry.source !== `./${PUBLISHED}`) problems.push(`the published source is ${JSON.stringify(entry.source)}`);
200+ const plugin = readJson(dir, `${PUBLISHED}/.claude-plugin/plugin.json`);
201+ if (marketplace.name !== "g1t" || entry.name !== "g1t" || plugin.name !== "g1t") problems.push("the names are not g1t@g1t");
202+ if (entry.version !== version || plugin.version !== version) problems.push(`versions: marketplace ${entry.version}, plugin ${plugin.version}, source ${version}`);
203+ for (const file of [`${PUBLISHED}/.mcp.json`, `${PUBLISHED}/hooks/hooks.json`]) readJson(dir, file);
204+ problems.push(...scan(dir));
205+ if (problems.length) throw new Error(`The published tree is wrong:\n- ${problems.join("\n- ")}`);
206+}
207+
208+function git(cwd, args, { env = {}, allowFail = false } = {}) {
209+ const r = spawnSync("git", args, { cwd, encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env } });
210+ if (r.status !== 0 && !allowFail) throw new Error(`git ${args[0]} failed (${r.status}): ${(r.stderr || r.stdout).trim()}`);
211+ return r;
212+}
213+
214+/**
215+ * The token as a git header, through git's environment config
216+ * (GIT_CONFIG_COUNT), so it is on no command line and in no file.
217+ */
218+export function authEnv(token) {
219+ if (!token) return {};
220+ const basic = Buffer.from(`g1t:${token}`).toString("base64");
221+ return { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "http.extraHeader", GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}` };
222+}
223+
224+/**
225+ * Publishes the build to `remote`'s main and tags v<version>. Returns
226+ * { changed, commit, tagged }. An empty repository gets its first commit.
227+ */
228+export function publish({ remote = process.env.G1T_PLUGINS_REMOTE || REMOTE, token = process.env.PLUGINS_PUBLISH_TOKEN, source, dryRun = false, root = ROOT, work } = {}) {
229+ source ??= process.env.G1T_SOURCE_COMMIT || git(root, ["rev-parse", "HEAD"]).stdout.trim();
230+ const env = authEnv(token);
231+ work ??= mkdtempSync(join(tmpdir(), "g1t-plugins-"));
232+ const built = join(work, "build");
233+ const clone = join(work, "clone");
234+ const { version } = build(built, root);
235+
236+ git(work, ["clone", "--quiet", remote, clone], { env });
237+ const hasMain = git(clone, ["rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"], { allowFail: true }).status === 0;
238+ if (hasMain) git(clone, ["checkout", "--quiet", "-B", "main", "origin/main"]);
239+ else git(clone, ["checkout", "--quiet", "--orphan", "main"]);
240+
241+ const before = hasMain && existsSync(join(clone, PUBLISHED, ".claude-plugin", "plugin.json")) ? readJson(clone, `${PUBLISHED}/.claude-plugin/plugin.json`).version : null;
242+ for (const name of readdirSync(clone)) if (name !== ".git") rmSync(join(clone, name), { recursive: true, force: true });
243+ cpSync(built, clone, { recursive: true });
244+ verify(clone, root);
245+
246+ git(clone, ["add", "--all"]);
247+ const changed = git(clone, ["status", "--porcelain"]).stdout.trim() !== "";
248+ const pluginChanged = hasMain ? git(clone, ["diff", "--cached", "--quiet", "--", PUBLISHED], { allowFail: true }).status !== 0 : true;
249+ if (pluginChanged && before === version) {
250+ throw new Error(`The plugin's files changed, but ${version} is already published, and Claude Code updates installed plugins only when the version changes. Bump "version" in ${SOURCE}/.claude-plugin/plugin.json and .claude-plugin/marketplace.json.`);
251+ }
252+
253+ const tag = tagName(version);
254+ const remoteTag = git(clone, ["ls-remote", "--tags", "origin", `refs/tags/${tag}`], { env }).stdout.trim();
255+ let commit = hasMain ? git(clone, ["rev-parse", "HEAD"]).stdout.trim() : null;
256+ if (changed) {
257+ const ident = ["-c", "user.name=g1t", "-c", "user.email=g1t@users.noreply.g1t.sh"];
258+ git(clone, [...ident, "commit", "--quiet", "-m", `g1t ${version}, from flagon-io/g1t@${source.slice(0, 12)}\n\nPublished by scripts/plugins-release.mjs from https://g1t.sh/flagon-io/g1t/commit/${source}.\nChange the plugin there; this repository is overwritten.`]);
259+ commit = git(clone, ["rev-parse", "HEAD"]).stdout.trim();
260+ }
261+ const tagged = !remoteTag && commit != null;
262+ if (dryRun) return { changed, commit, tagged, version, dir: clone };
263+ if (changed) git(clone, ["push", "--quiet", "origin", "main:refs/heads/main"], { env });
264+ if (tagged) git(clone, ["push", "--quiet", "origin", `${commit}:refs/tags/${tag}`], { env });
265+ return { changed, commit, tagged, version, dir: clone };
266+}
267+
268+if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/plugins-release.mjs")) {
269+ const [command, ...rest] = process.argv.slice(2);
270+ try {
271+ if (command === "check") {
272+ const { version } = manifests();
273+ const dir = mkdtempSync(join(tmpdir(), "g1t-plugins-check-"));
274+ build(dir);
275+ rmSync(dir, { recursive: true, force: true });
276+ console.log(`g1t@g1t ${version}: the manifests agree and the published tree is only the plugin`);
277+ } else if (command === "build") {
278+ const { out, version } = build(rest[0] ? join(process.cwd(), rest[0]) : undefined);
279+ console.log(`Built g1t@g1t ${version} in ${out}:\n${walk(out).map((f) => ` ${f}`).join("\n")}`);
280+ } else if (command === "publish") {
281+ const dryRun = rest.includes("--dry-run");
282+ const r = publish({ dryRun });
283+ const where = process.env.G1T_PLUGINS_REMOTE || REMOTE;
284+ console.log(r.changed ? `${dryRun ? "Would publish" : "Published"} g1t ${r.version} to ${where} as ${r.commit}` : `${where} already has g1t ${r.version}; nothing to publish`);
285+ if (r.tagged) console.log(`${dryRun ? "Would tag" : "Tagged"} ${tagName(r.version)}`);
286+ if (process.env.GITHUB_STEP_SUMMARY) writeFileSync(process.env.GITHUB_STEP_SUMMARY, `- g1t@g1t ${r.version}: ${r.changed ? `published as \`${r.commit}\`` : "unchanged"}${r.tagged ? `, tagged \`${tagName(r.version)}\`` : ""}\n`, { flag: "a" });
287+ } else {
288+ console.error("usage: node scripts/plugins-release.mjs check | build [DIR] | publish [--dry-run]");
289+ process.exit(2);
290+ }
291+ } catch (e) {
292+ console.error(e.message);
293+ process.exit(1);
294+ }
295+}
+135−0
1+// node --test scripts/plugins-release.test.mjs
2+import assert from "node:assert/strict";
3+import { spawnSync } from "node:child_process";
4+import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5+import { tmpdir } from "node:os";
6+import { dirname, join } from "node:path";
7+import { after, test } from "node:test";
8+import { fileURLToPath } from "node:url";
9+
10+import { INSTALL, PUBLISHED, SOURCE, authEnv, tagName, build, expectedFiles, manifests, publish, scan, walk } from "./plugins-release.mjs";
11+
12+const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
13+const temps = [];
14+const temp = (prefix) => {
15+ const dir = mkdtempSync(join(tmpdir(), prefix));
16+ temps.push(dir);
17+ return dir;
18+};
19+after(() => temps.forEach((d) => rmSync(d, { recursive: true, force: true })));
20+
21+/** A copy of what the build reads from this repository, to change freely. */
22+function source() {
23+ const root = temp("plugins-src-");
24+ for (const path of [".claude-plugin", SOURCE, "LICENSE"]) cpSync(join(ROOT, path), join(root, path), { recursive: true });
25+ return root;
26+}
27+const editJson = (root, file, fn) => {
28+ const path = join(root, file);
29+ writeFileSync(path, JSON.stringify(fn(JSON.parse(readFileSync(path, "utf8"))), null, 2));
30+};
31+const bump = (root, version) => {
32+ editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, plugins: [{ ...m.plugins[0], version }] }));
33+ editJson(root, `${SOURCE}/.claude-plugin/plugin.json`, (p) => ({ ...p, version }));
34+};
35+const git = (cwd, ...args) => spawnSync("git", args, { cwd, encoding: "utf8" });
36+
37+test("this repository's manifests agree: g1t@g1t, one version", () => {
38+ const { marketplace, plugin, version } = manifests();
39+ assert.equal(marketplace.plugins[0].version, version);
40+ assert.equal(plugin.version, version);
41+});
42+
43+test("the build is the marketplace, the plugin without its tests, a README and the license", () => {
44+ const out = join(temp("plugins-out-"), "tree");
45+ const { version } = build(out);
46+ const files = walk(out);
47+ assert.deepEqual(files, expectedFiles());
48+ assert.ok(files.includes(`${PUBLISHED}/hooks/g1t.mjs`));
49+ assert.ok(!files.some((f) => f.includes("test/") || f.endsWith(".test.mjs")), files.join(" "));
50+ const marketplace = JSON.parse(readFileSync(join(out, ".claude-plugin/marketplace.json"), "utf8"));
51+ assert.equal(marketplace.name, "g1t");
52+ assert.deepEqual(marketplace.plugins.map((p) => [p.name, p.source, p.version]), [["g1t", "./plugins/g1t", version]]);
53+ // The plugin's files are the source's, byte for byte.
54+ for (const f of files.filter((f) => f.startsWith(`${PUBLISHED}/`))) {
55+ assert.equal(readFileSync(join(out, f), "utf8"), readFileSync(join(ROOT, SOURCE, f.slice(PUBLISHED.length + 1)), "utf8"), f);
56+ }
57+ const readme = readFileSync(join(out, "README.md"), "utf8");
58+ for (const line of INSTALL) assert.ok(readme.includes(line), line);
59+});
60+
61+test("manifests that disagree stop the build", () => {
62+ const root = source();
63+ editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, plugins: [{ ...m.plugins[0], version: "9.9.9" }] }));
64+ assert.throws(() => build(join(root, "out"), root), /marketplace.json says version 9.9.9/);
65+ const renamed = source();
66+ editJson(renamed, ".claude-plugin/marketplace.json", (m) => ({ ...m, name: "g1t-plugins" }));
67+ assert.throws(() => build(join(renamed, "out"), renamed), /installs are g1t@g1t/);
68+});
69+
70+test("a file the script does not know about stops the build", () => {
71+ const root = source();
72+ writeFileSync(join(root, SOURCE, "notes.md"), "draft");
73+ assert.throws(() => build(join(root, "out"), root), /has notes\.md, which .* neither publishes nor keeps back/);
74+});
75+
76+test("anything that looks like a credential, or a credentials file, is never published", () => {
77+ const root = source();
78+ writeFileSync(join(root, SOURCE, "commands", "status.md"), `token: g1t_${"a1".repeat(20)}\n`);
79+ assert.throws(() => build(join(root, "out"), root), /commands\/status\.md: looks like a g1t token/);
80+ const dir = temp("plugins-scan-");
81+ writeFileSync(join(dir, ".env"), "X=1");
82+ writeFileSync(join(dir, "k.md"), "-----BEGIN OPENSSH PRIVATE KEY-----");
83+ assert.deepEqual(scan(dir), [".env: a file that is never published", "k.md: looks like a private key"]);
84+ // The plugin as it is holds none.
85+ assert.deepEqual(scan(join(ROOT, SOURCE, "hooks")), []);
86+});
87+
88+test("releases are tagged as claude plugin tag tags them", () => {
89+ assert.equal(tagName("0.2.2"), "g1t--v0.2.2");
90+});
91+
92+test("the token goes to git through its environment, never its arguments", () => {
93+ const env = authEnv("g1t_secret");
94+ assert.equal(env.GIT_CONFIG_KEY_0, "http.extraHeader");
95+ assert.equal(env.GIT_CONFIG_VALUE_0, `Authorization: Basic ${Buffer.from("g1t:g1t_secret").toString("base64")}`);
96+ assert.deepEqual(authEnv(undefined), {});
97+});
98+
99+test("publishing: the first commit and tag, nothing when unchanged, a new version for a changed plugin", () => {
100+ const root = source();
101+ const remote = join(temp("plugins-remote-"), "g1t-plugins.git");
102+ git(dirname(remote), "init", "--quiet", "--bare", "--initial-branch=main", remote);
103+ const run = (sourceCommit) => publish({ remote, token: "", source: sourceCommit, root, work: temp("plugins-work-") });
104+ const { version } = manifests(root);
105+
106+ const first = run("a".repeat(40));
107+ assert.equal(first.changed, true);
108+ assert.equal(first.tagged, true);
109+ assert.equal(git(remote, "rev-parse", "main").stdout.trim(), first.commit);
110+ assert.equal(git(remote, "rev-parse", `g1t--v${version}^{commit}`).stdout.trim(), first.commit);
111+ assert.match(git(remote, "log", "-1", "--format=%an <%ae>%n%B", "main").stdout, /^g1t <g1t@users\.noreply\.g1t\.sh>\ng1t .*, from flagon-io\/g1t@aaaaaaaaaaaa/);
112+ const listed = git(remote, "ls-tree", "-r", "--name-only", "main").stdout.trim().split("\n").sort();
113+ assert.deepEqual(listed, expectedFiles(root));
114+
115+ const again = run("b".repeat(40));
116+ assert.deepEqual([again.changed, again.tagged, again.commit], [false, false, first.commit]);
117+
118+ // The plugin changed with its version already out: refused.
119+ writeFileSync(join(root, SOURCE, "commands", "status.md"), "changed\n");
120+ assert.throws(() => run("c".repeat(40)), /already published.*Bump "version"/s);
121+ assert.equal(git(remote, "rev-parse", "main").stdout.trim(), first.commit);
122+
123+ bump(root, "99.0.0");
124+ const next = run("d".repeat(40));
125+ assert.equal(next.changed, true);
126+ assert.equal(next.tagged, true);
127+ assert.equal(git(remote, "rev-parse", "g1t--v99.0.0^{commit}").stdout.trim(), next.commit);
128+ assert.equal(git(remote, "rev-parse", `g1t--v${version}^{commit}`).stdout.trim(), first.commit);
129+ assert.equal(git(remote, "rev-parse", "main~1").stdout.trim(), first.commit);
130+
131+ // The marketplace's own text may change without a new version.
132+ editJson(root, ".claude-plugin/marketplace.json", (m) => ({ ...m, description: "g1t's plugins for Claude Code, and more." }));
133+ const text = run("e".repeat(40));
134+ assert.deepEqual([text.changed, text.tagged], [true, false]);
135+});
+47−0
30593059 if access.deploy_key.is_some() && !exists {
30603060 return Some(format!("This deploy key is for {repo}, which is not there any more."));
30613061 }
3062+ // A token narrowed to chosen repositories, or to another workspace, never
3063+ // makes one outside them by pushing to an empty address.
3064+ if write
3065+ && !exists
3066+ && let Some(reach) = &access.reach
3067+ {
3068+ let namespace = repo.split('/').next().unwrap_or_default();
3069+ let all = reach.repositories == g1t_contracts::scopes::RepositorySelection::All;
3070+ if !all || !reach.workspace.as_deref().is_some_and(|w| w.eq_ignore_ascii_case(namespace)) {
3071+ return Some(format!(
3072+ "This access token cannot create {repo}: it reaches only the repositories chosen for it. Create the repository on g1t.sh, then add it to the token."
3073+ ));
3074+ }
3075+ }
30623076 None
30633077 }
30643078
31423156 assert!(args.is_private);
31433157 assert_eq!((args.namespace.as_str(), args.name.as_str()), ("acme", "site"));
31443158 }
3159+
3160+ fn token(repositories: &str, workspace: Option<&str>) -> g1t_contracts::scopes::TokenAccess {
3161+ serde_json::from_value(serde_json::json!({
3162+ "token_id": "tok_1",
3163+ "scopes": ["code:read", "code:write"],
3164+ "fine_grained": { "workspace": workspace, "repositories": repositories, "repo_ids": ["rep_plugins"] },
3165+ }))
3166+ .unwrap()
3167+ }
3168+
3169+ #[test]
3170+ fn a_token_for_chosen_repositories_never_creates_one_by_pushing() {
3171+ let why = git_token_refusal(&token("selected", Some("acme")), "acme/new", None, true, false, false).unwrap();
3172+ assert!(why.contains("cannot create acme/new"), "{why}");
3173+ // It still pushes to a repository that is there; its selection is
3174+ // the role check's.
3175+ assert_eq!(git_token_refusal(&token("selected", Some("acme")), "acme/plugins", None, true, false, true), None);
3176+ }
3177+
3178+ #[test]
3179+ fn a_token_for_all_of_a_workspace_creates_there_and_nowhere_else() {
3180+ assert_eq!(git_token_refusal(&token("all", Some("acme")), "Acme/new", None, true, false, false), None);
3181+ assert!(git_token_refusal(&token("all", Some("acme")), "other/new", None, true, false, false).is_some());
3182+ assert!(git_token_refusal(&token("public", Some("acme")), "acme/new", None, true, false, false).is_some());
3183+ assert!(git_token_refusal(&token("all", None), "acme/new", None, true, false, false).is_some());
3184+ }
3185+
3186+ #[test]
3187+ fn a_token_without_a_reach_creates_as_its_owner_would() {
3188+ let wide: g1t_contracts::scopes::TokenAccess =
3189+ serde_json::from_value(serde_json::json!({ "token_id": "tok_1", "scopes": ["code:write"] })).unwrap();
3190+ assert_eq!(git_token_refusal(&wide, "acme/new", None, true, false, false), None);
3191+ }
31453192 }
31463193
31473194 #[cfg(test)]

Comments

Sign in to comment.