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

Customize replaces the Marketplace underneath: the agents service reads marketplaces, plugins, skills, subagents and MCP servers in the formats Claude Code reads, so public ones install on g1t as they are. A workspace starts with claude-plugins-official and anthropic-agent-skills and adds marketplaces by owner/repo, a repository here, a git address or a link to marketplace.json; installing a plugin (read from GitHub, from this g1t or from a zip, at a pinned commit) puts its skills in the library as plugin:skill, keeps its subagents as templates to hire from, and turns the remote servers of its .mcp.json into connectors. Connectors are MCP servers the workspace connects once, from the directory, a plugin or an address, signing in with the browser (OAuth with PKCE and dynamic client registration), a key, or not at all, their secrets sealed under CONNECTORS_KEY, and given to every agent, a team or one agent like skills; an agent's turn gets them beside its own servers, so levels, Ask-first cards and audit are unchanged. The linked skills repository is .claude/skills now (.g1t/skills still read). The old extension installs go; requests stay and name plugins, skills and connectors too. In the rail, Agents follows Code, and the top bar's + menu loses Put an agent on it and New workspace.

syntaqxcommitted Parenta85f7e2Browse files
41 files+5777−8010/41 viewed
+1−1
590590 | **Home** | Where you're needed across the workspace, and what happened while you were away. See [Home](/guides/home/). |
591591 | **Chat** | Channels and messages. See [Chat](/guides/chat/). |
592592 | **Notifications** | Reviews, mentions, failures and what agents wait on. See [notifications](/guides/notifications/). |
593−| **Agents** | The workspace's agents and their sessions. See [agents](/guides/agents/). |
594593 | **Code** | Projects, pull requests and checks. Not shown to a member without [Code access](/guides/agent-access/). |
594+| **Agents** | The workspace's agents and their sessions, and how they are set up. See [agents](/guides/agents/). |
595595 | **Artifacts** | Documents, decks and pages. See [artifacts](/guides/artifacts/). |
596596 | **People** | Everyone in the workspace (its people; agents are under Agents); its [teams](/guides/teams/), which agents can be on; and the org chart. See [people and teams](/guides/people-and-teams/). |
597597 | **Workspace** | The workspace's settings: who has access, money, compute, and how each part of g1t is set up. See [Workspace mode](#workspace-mode). |
+2−1
235235 label: "Chat",
236236 badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
237237 },
238− { key: "agents", label: "Agents" },
239238 ...(code ? [{ key: "code" as const, label: "Code" }] : []),
239+ // Agents comes after Code: it is where agents are configured, not where the work is.
240+ { key: "agents", label: "Agents" },
240241 { key: "artifacts", label: "Artifacts" },
241242 ];
242243 const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null);
+1431−400
A large diff (2,615 lines) is not shown until you ask for it.
+2−1
2020 //
2121 // Usage: node configs.mjs [outDir]
2222 // Environment: PUBLIC_URL, GITSTORE_URL, GITSTORE_SECRET, MAIL_URL,
23−// ACTIONS_KEY, INTEGRATIONS_KEY, WEBHOOKS_KEY, IDENTITY_KEY, USERCONTENT_KEY,
23+// ACTIONS_KEY, INTEGRATIONS_KEY, WEBHOOKS_KEY, IDENTITY_KEY, USERCONTENT_KEY, CONNECTORS_KEY,
2424 // USERCONTENT_URL,
2525 // PACKAGES_TOKEN_SECRET, S3_ENDPOINT, S3_BUCKET, BACKUP_S3_BUCKET, DOCS_S3_BUCKET, S3_REGION,
2626 // S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_PUBLIC_ENDPOINT, and optionally
8282 g1t: "USERCONTENT_KEY",
8383 "g1t-actions": "ACTIONS_KEY",
8484 "g1t-integrations": "INTEGRATIONS_KEY",
85+ "g1t-agents": "CONNECTORS_KEY",
8586 "g1t-webhooks": "WEBHOOKS_KEY",
8687 };
8788
+5−0
2828 if ! grep -q '^IDENTITY_KEY=' "$KEYS"; then
2929 echo "IDENTITY_KEY=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" >> "$KEYS"
3030 fi
31+# The agents service's key seals connectors' keys and OAuth tokens
32+# (Customize); added to keys made before it existed.
33+if ! grep -q '^CONNECTORS_KEY=' "$KEYS"; then
34+ echo "CONNECTORS_KEY=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" >> "$KEYS"
35+fi
3136 # The site's key, which signs the short-lived addresses of private
3237 # repositories' files.
3338 if ! grep -q '^USERCONTENT_KEY=' "$KEYS"; then
+1−1
173173 "worker": "g1t-agents",
174174 "d1": { "database": "g1t-agents", "migrations": "migrations" },
175175 "stage": "core",
176− "secrets": [],
176+ "secrets": ["CONNECTORS_KEY"],
177177 "setup": [
178178 "The D1 database, before the first deploy: npx wrangler d1 create g1t-agents, then put its id in services/agents/wrangler.jsonc"
179179 ],
+1−1
44 "private": true,
55 "type": "module",
66 "license": "MIT",
7− "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./customize": "./src/customize.ts", "./skills": "./src/skills.ts", "./discovery": "./src/discovery.ts" },
7+ "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./customize": "./src/customize.ts", "./claude-plugin": "./src/claude-plugin.ts", "./skills": "./src/skills.ts", "./discovery": "./src/discovery.ts" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+281−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ BUILTIN_MARKETPLACES,
6+ fetchPlan,
7+ githubRepoOf,
8+ isReservedMarketplaceName,
9+ marketplaceNameProblem,
10+ namespaced,
11+ parseMarketplaceAddress,
12+ parseMarketplaceManifest,
13+ parseMcpServers,
14+ parsePluginManifest,
15+ parsePluginRef,
16+ parsePluginSource,
17+ pluginLayout,
18+ pluginNameProblem,
19+ pluginRef,
20+ subagentOf,
21+} from "./claude-plugin.ts";
22+import { parseFrontMatter, splitFrontMatter } from "./skill-format.ts";
23+
24+/** A subagent file read the way the agents service reads one. */
25+function parseSubagentFile(text: string) {
26+ const split = splitFrontMatter(text);
27+ if (!split.ok) return { ok: false as const, message: split.message };
28+ try {
29+ return subagentOf(parseFrontMatter(split.yaml), split.body);
30+ } catch (error) {
31+ return { ok: false as const, message: (error as Error).message };
32+ }
33+}
34+
35+// The two public marketplaces as they are published, in miniature.
36+const OFFICIAL = {
37+ name: "claude-plugins-official",
38+ owner: { name: "Anthropic", email: "support@anthropic.com" },
39+ plugins: [
40+ { name: "agent-sdk-dev", source: "./plugins/agent-sdk-dev", description: "Development kit", category: "development", author: { name: "Anthropic" } },
41+ { name: "asana", source: "./external_plugins/asana", category: "productivity", description: "Asana" },
42+ {
43+ name: "airtable",
44+ source: { source: "git-subdir", url: "https://github.com/Airtable/skills.git", path: "plugins/airtable", ref: "main", sha: "812ee67f1fd3d76fb45ff8df40afaa0448602ba8" },
45+ category: "productivity",
46+ description: "Airtable",
47+ },
48+ { name: "aikido", source: { source: "url", url: "https://github.com/AikidoSec/aikido-claude-plugin.git", sha: "02f018ad4da175a2a5ad6ca9451c3d309d105fab" } },
49+ { name: "from-npm", source: { source: "npm", package: "@acme/plugin" } },
50+ { name: "bad entry", source: "./x" },
51+ { name: "no-source" },
52+ ],
53+};
54+
55+const SKILLS = {
56+ name: "anthropic-agent-skills",
57+ owner: { name: "Keith Lazuka", email: "klazuka@anthropic.com" },
58+ metadata: { description: "Anthropic example skills", version: "1.0.0" },
59+ plugins: [
60+ { name: "document-skills", description: "Documents", source: "./", strict: false, skills: ["./skills/xlsx", "./skills/docx", "./skills/pptx", "./skills/pdf"] },
61+ { name: "claude-api", description: "Claude API", source: "./", strict: false, skills: ["./skills/claude-api"] },
62+ ],
63+};
64+
65+test("a marketplace manifest reads as published, noting the entries it can't read", () => {
66+ const read = parseMarketplaceManifest(OFFICIAL);
67+ assert.ok(read.ok);
68+ assert.equal(read.value.name, "claude-plugins-official");
69+ assert.equal(read.value.owner.name, "Anthropic");
70+ assert.deepEqual(
71+ read.value.plugins.map((p) => [p.name, p.source.source, p.category]),
72+ [
73+ ["agent-sdk-dev", "path", "development"],
74+ ["asana", "path", "productivity"],
75+ ["airtable", "git-subdir", "productivity"],
76+ ["aikido", "url", null],
77+ ["from-npm", "npm", null],
78+ ],
79+ );
80+ assert.deepEqual(read.value.problems, ["Plugin 6: A plugin's name is letters, digits, dots, underscores and hyphens, like pr-review-toolkit.", "Plugin 7: no-source has no source."]);
81+ const skills = parseMarketplaceManifest(SKILLS);
82+ assert.ok(skills.ok);
83+ assert.equal(skills.value.description, "Anthropic example skills");
84+ assert.equal(skills.value.version, "1.0.0");
85+ assert.deepEqual(skills.value.plugins[0]!.skills, ["skills/xlsx", "skills/docx", "skills/pptx", "skills/pdf"]);
86+ assert.equal(skills.value.plugins[0]!.strict, false);
87+});
88+
89+test("a manifest without a name, an owner or plugins is refused", () => {
90+ assert.equal(parseMarketplaceManifest({ owner: { name: "x" }, plugins: [] }).ok, false);
91+ assert.equal(parseMarketplaceManifest({ name: "x", plugins: [] }).ok, false);
92+ assert.equal(parseMarketplaceManifest({ name: "x", owner: "Someone" }).ok, false);
93+ assert.equal(parseMarketplaceManifest({ name: "../x", owner: { name: "x" }, plugins: [] }).ok, false);
94+ assert.equal(parseMarketplaceManifest("[]").ok, false);
95+});
96+
97+test("plugin sources: every form Claude Code takes, and what each refuses", () => {
98+ assert.deepEqual(parsePluginSource("./plugins/foo/"), { ok: true, value: { source: "path", path: "./plugins/foo" } });
99+ assert.equal(parsePluginSource("plugins/foo").ok, false);
100+ assert.equal(parsePluginSource("./plugins/../foo").ok, false);
101+ assert.deepEqual(parsePluginSource({ source: "github", repo: "acme/tools", ref: "v1", sha: "ABCDEF0123456789abcdef0123456789abcdef01" }), {
102+ ok: true,
103+ value: { source: "github", repo: "acme/tools", ref: "v1", sha: "abcdef0123456789abcdef0123456789abcdef01" },
104+ });
105+ assert.equal(parsePluginSource({ source: "github", repo: "not a repo" }).ok, false);
106+ assert.equal(parsePluginSource({ source: "url", url: "ftp://x" }).ok, false);
107+ assert.equal(parsePluginSource({ source: "git-subdir", url: "https://github.com/a/b.git", path: "../x" }).ok, false);
108+ assert.deepEqual(parsePluginSource({ source: "archive", url: "https://x.dev/p.zip", sha256: "ab".repeat(32) }), { ok: true, value: { source: "archive", url: "https://x.dev/p.zip", sha256: "ab".repeat(32) } });
109+ assert.deepEqual(parsePluginSource({ source: "command", command: "make plugin", mode: "link" }), { ok: true, value: { source: "command", command: "make plugin", mode: "link" } });
110+ assert.equal(parsePluginSource({ source: "pip", package: "x" }).ok, false);
111+});
112+
113+test("where to fetch a plugin from follows its source and its marketplace", () => {
114+ const github = { source: "github", repo: "anthropics/claude-plugins-official" } as const;
115+ assert.deepEqual(fetchPlan({ source: "path", path: "./plugins/agent-sdk-dev" }, github), { kind: "github", repo: "anthropics/claude-plugins-official", ref: null, sha: null, path: "plugins/agent-sdk-dev" });
116+ assert.deepEqual(fetchPlan({ source: "path", path: "./" }, { source: "github", repo: "anthropics/skills", ref: "main" }), { kind: "github", repo: "anthropics/skills", ref: "main", sha: null, path: "" });
117+ assert.deepEqual(fetchPlan({ source: "path", path: "./foo" }, { source: "g1t", repo: "flagon-io/plugins" }), { kind: "g1t", repo: "flagon-io/plugins", ref: null, path: "foo" });
118+ assert.deepEqual(fetchPlan({ source: "git-subdir", url: "https://github.com/Airtable/skills.git", path: "plugins/airtable", ref: "main", sha: "8".repeat(40) }, github), {
119+ kind: "github",
120+ repo: "Airtable/skills",
121+ ref: "main",
122+ sha: "8".repeat(40),
123+ path: "plugins/airtable",
124+ });
125+ assert.deepEqual(fetchPlan({ source: "url", url: "https://github.com/AikidoSec/aikido-claude-plugin.git", sha: "0".repeat(40) }, github), { kind: "github", repo: "AikidoSec/aikido-claude-plugin", ref: null, sha: "0".repeat(40), path: "" });
126+ assert.deepEqual(fetchPlan({ source: "url", url: "https://g1t.sh/flagon-io/tools" }, github, { host: "g1t.sh" }), { kind: "g1t", repo: "flagon-io/tools", ref: null, path: "" });
127+ assert.equal(fetchPlan({ source: "url", url: "https://gitlab.com/a/b.git" }, github).kind, "unsupported");
128+ assert.equal(fetchPlan({ source: "npm", package: "x" }, github).kind, "unsupported");
129+ assert.equal(fetchPlan({ source: "command", command: "x" }, github).kind, "unsupported");
130+ assert.equal(fetchPlan({ source: "path", path: "./x" }, { source: "url", url: "https://x.dev/marketplace.json" }).kind, "unsupported");
131+ assert.deepEqual(fetchPlan({ source: "path", path: "foo" }, github, { pluginRoot: "plugins" }), { kind: "github", repo: "anthropics/claude-plugins-official", ref: null, sha: null, path: "plugins/foo" });
132+ assert.deepEqual(fetchPlan({ source: "archive", url: "https://x.dev/p.zip" }, github), { kind: "archive", url: "https://x.dev/p.zip", sha256: null });
133+});
134+
135+test("a marketplace address is read the ways people write one", () => {
136+ assert.deepEqual(parseMarketplaceAddress("anthropics/skills"), { ok: true, value: { source: "github", repo: "anthropics/skills" } });
137+ assert.deepEqual(parseMarketplaceAddress("acme/tools#v2"), { ok: true, value: { source: "github", repo: "acme/tools", ref: "v2" } });
138+ assert.deepEqual(parseMarketplaceAddress("https://github.com/anthropics/claude-plugins-official"), { ok: true, value: { source: "github", repo: "anthropics/claude-plugins-official" } });
139+ assert.deepEqual(parseMarketplaceAddress("https://github.com/acme/tools/tree/main"), { ok: true, value: { source: "github", repo: "acme/tools", ref: "main" } });
140+ assert.deepEqual(parseMarketplaceAddress("https://x.dev/plugins/marketplace.json"), { ok: true, value: { source: "url", url: "https://x.dev/plugins/marketplace.json" } });
141+ assert.deepEqual(parseMarketplaceAddress("https://gitlab.com/acme/tools.git#main"), { ok: true, value: { source: "git", url: "https://gitlab.com/acme/tools.git", ref: "main" } });
142+ assert.deepEqual(parseMarketplaceAddress("flagon-io/plugins", { here: (repo) => repo === "flagon-io/plugins" }), { ok: true, value: { source: "g1t", repo: "flagon-io/plugins" } });
143+ assert.deepEqual(parseMarketplaceAddress("https://g1t.sh/flagon-io/plugins", { host: "g1t.sh" }), { ok: true, value: { source: "g1t", repo: "flagon-io/plugins" } });
144+ assert.equal(parseMarketplaceAddress("").ok, false);
145+ assert.equal(parseMarketplaceAddress("just words").ok, false);
146+ assert.deepEqual(githubRepoOf("git@github.com:acme/tools.git"), { repo: "acme/tools" });
147+ assert.equal(githubRepoOf("https://example.com/acme/tools"), null);
148+});
149+
150+test("names: marketplaces, reserved ones, plugins and plugin@marketplace", () => {
151+ assert.equal(marketplaceNameProblem("my-plugins"), null);
152+ assert.ok(marketplaceNameProblem("-x"));
153+ assert.ok(marketplaceNameProblem("a..b"));
154+ assert.ok(isReservedMarketplaceName("claude-plugins-official"));
155+ assert.ok(isReservedMarketplaceName("claudeai-foo"));
156+ assert.ok(isReservedMarketplaceName("g1t"));
157+ assert.equal(isReservedMarketplaceName("acme"), false);
158+ assert.equal(pluginNameProblem("pr-review-toolkit"), null);
159+ assert.ok(pluginNameProblem("a:b"));
160+ assert.ok(pluginNameProblem("a/b"));
161+ assert.equal(pluginRef("pdf", "anthropic-agent-skills"), "pdf@anthropic-agent-skills");
162+ assert.deepEqual(parsePluginRef("pdf@anthropic-agent-skills"), { name: "pdf", marketplace: "anthropic-agent-skills" });
163+ assert.equal(parsePluginRef("pdf"), null);
164+ assert.equal(parsePluginRef("@x"), null);
165+ assert.equal(namespaced("document-skills", "pdf"), "document-skills:pdf");
166+});
167+
168+test("plugin.json needs only a name; component paths and inline servers are kept", () => {
169+ const read = parsePluginManifest({ name: "asana", description: "Asana", author: { name: "Asana" }, skills: ["./extra-skills"], mcpServers: { asana: { type: "http", url: "https://mcp.asana.com/v2/mcp" } }, hooks: "./hooks/hooks.json", other: 1 });
170+ assert.ok(read.ok);
171+ assert.deepEqual(read.value.skills, ["extra-skills"]);
172+ assert.deepEqual(read.value.hooks, ["hooks/hooks.json"]);
173+ assert.deepEqual(read.value.extra, { other: 1 });
174+ assert.ok(read.value.mcpServers && typeof read.value.mcpServers === "object");
175+ assert.equal(parsePluginManifest({ description: "no name" }).ok, false);
176+ assert.equal(parsePluginManifest({ name: "claude:x" }).ok, false);
177+});
178+
179+test(".mcp.json: remote servers, stdio ones, and what is skipped", () => {
180+ const { servers, problems } = parseMcpServers({
181+ mcpServers: {
182+ asana: { type: "http", url: "https://mcp.asana.com/v2/mcp", headers: { Authorization: "Bearer ${ASANA_TOKEN}" } },
183+ legacy: { type: "sse", url: "https://x.dev/sse" },
184+ local: { command: "npx", args: ["-y", "@acme/server", "${HOME}"], env: { KEY: "${ACME_KEY:-none}" } },
185+ untyped: { url: "https://x.dev/mcp" },
186+ broken: "nope",
187+ "bad name!": { type: "http", url: "https://x.dev" },
188+ },
189+ });
190+ assert.deepEqual(
191+ servers.map((s) => [s.name, s.type, s.url ?? s.command, s.variables]),
192+ [
193+ ["asana", "http", "https://mcp.asana.com/v2/mcp", ["ASANA_TOKEN"]],
194+ ["legacy", "sse", "https://x.dev/sse", []],
195+ ["local", "stdio", "npx", ["ACME_KEY", "HOME"]],
196+ ],
197+ );
198+ assert.equal(problems.length, 3);
199+ assert.match(problems[0]!, /untyped: a remote server says its type/);
200+ assert.equal(parseMcpServers([]).problems.length, 1);
201+});
202+
203+test("a subagent file: front-matter and the prompt, skipped the way Claude Code skips it", () => {
204+ const read = parseSubagentFile(`---
205+name: code-reviewer
206+description: Reviews pull requests for risk. Use after a change is ready.
207+tools: Read, Grep, Bash
208+model: sonnet
209+skills:
210+ - pr-review
211+color: green
212+---
213+
214+You are a careful reviewer. Read the diff first.
215+`);
216+ assert.ok(read.ok);
217+ assert.deepEqual(
218+ { name: read.value.name, tools: read.value.tools, model: read.value.model, skills: read.value.skills, color: read.value.color },
219+ { name: "code-reviewer", tools: ["Read", "Grep", "Bash"], model: "sonnet", skills: ["pr-review"], color: "green" },
220+ );
221+ assert.equal(read.value.prompt, "You are a careful reviewer. Read the diff first.");
222+ assert.equal(parseSubagentFile("no front matter").ok, false);
223+ assert.equal(parseSubagentFile("---\nname: x\n---\nbody").ok, false);
224+ assert.equal(parseSubagentFile("---\nname: a:b\ndescription: d\n---\nbody").ok, false);
225+ assert.equal(parseSubagentFile("---\nname: a\ndescription: d\n---\n\n").ok, false);
226+});
227+
228+test("a plugin's layout is found from its files, its manifest and a strict entry", () => {
229+ const files = [
230+ ".claude-plugin/plugin.json",
231+ "README.md",
232+ "skills/pdf/SKILL.md",
233+ "skills/pdf/scripts/fill.py",
234+ "skills/docx/SKILL.md",
235+ "commands/review.md",
236+ "agents/reviewer.md",
237+ "agents/security/audit.md",
238+ "hooks/hooks.json",
239+ ".mcp.json",
240+ "bin/tool",
241+ "extra/one/SKILL.md",
242+ ];
243+ const manifest = parsePluginManifest({ name: "kit", skills: ["./extra"] });
244+ assert.ok(manifest.ok);
245+ const layout = pluginLayout(files, manifest.value, null);
246+ assert.deepEqual(layout.skills, [
247+ { path: "extra/one", name: "one" },
248+ { path: "skills/docx", name: "docx" },
249+ { path: "skills/pdf", name: "pdf" },
250+ ]);
251+ assert.deepEqual(layout.commands, [{ path: "commands/review.md", name: "review" }]);
252+ assert.deepEqual(layout.agents, [
253+ { path: "agents/reviewer.md", name: "reviewer" },
254+ { path: "agents/security/audit.md", name: "audit" },
255+ ]);
256+ assert.deepEqual(layout.hooks, ["hooks/hooks.json"]);
257+ assert.equal(layout.mcp, ".mcp.json");
258+ assert.equal(layout.bin, true);
259+ assert.equal(layout.readme, "README.md");
260+ // The skills marketplace: a plugin whose folder is the marketplace root, with its skills named by the entry.
261+ const entry = parseMarketplaceManifest(SKILLS);
262+ assert.ok(entry.ok);
263+ const docs = pluginLayout(["skills/xlsx/SKILL.md", "skills/docx/SKILL.md", "skills/pdf/SKILL.md", "skills/pptx/SKILL.md", "skills/other/SKILL.md", "README.md"], null, entry.value.plugins[0]!);
264+ assert.deepEqual(
265+ docs.skills.map((s) => s.name),
266+ ["docx", "other", "pdf", "pptx", "xlsx"],
267+ );
268+ // A plugin that is one skill at its root.
269+ assert.deepEqual(pluginLayout(["SKILL.md", "reference.md"], null, { ...entry.value.plugins[1]!, skills: ["."] }).skills, [{ path: ".", name: "claude-api" }]);
270+});
271+
272+test("the built-in marketplaces are the public ones, under their own names", () => {
273+ assert.deepEqual(
274+ BUILTIN_MARKETPLACES.map((m) => [m.name, m.source]),
275+ [
276+ ["claude-plugins-official", { source: "github", repo: "anthropics/claude-plugins-official" }],
277+ ["anthropic-agent-skills", { source: "github", repo: "anthropics/skills" }],
278+ ],
279+ );
280+ for (const m of BUILTIN_MARKETPLACES) assert.ok(isReservedMarketplaceName(m.name));
281+});
+716−0
1+/**
2+ * The open plugin formats (docs.g1t.sh/guides/customize/, "The formats"):
3+ * what Claude Code reads, read here the same way, so a marketplace, a
4+ * plugin, a skill, a subagent or an MCP server published anywhere installs
5+ * on g1t as it is. g1t adds no keys of its own and ignores what it doesn't
6+ * know, as Claude Code does.
7+ *
8+ * - A **marketplace** is a repository (or a URL) holding
9+ * `.claude-plugin/marketplace.json`: a name, an owner and the plugins it
10+ * lists, each with a `source` saying where the plugin's files are.
11+ * - A **plugin** is a folder with an optional `.claude-plugin/plugin.json`
12+ * and a known layout: `skills/<name>/SKILL.md`, `commands/*.md`,
13+ * `agents/*.md`, `hooks/hooks.json`, `.mcp.json`, `.lsp.json`, `bin/`.
14+ * - A **subagent** is `agents/<name>.md`: front-matter (name, description,
15+ * tools, model, skills…) and the system prompt as its body.
16+ * - `.mcp.json` names MCP servers: `{ mcpServers: { name: { type, url… } } }`.
17+ *
18+ * Pure and standalone (no imports), so services, the site, the phone and
19+ * Node's tests use it as it is. A subagent file's front-matter is split
20+ * and parsed with ./skill-format.ts's functions, then read here.
21+ */
22+
23+export type Checked<T> = { ok: true; value: T } | { ok: false; message: string };
24+
25+const bad = (message: string): { ok: false; message: string } => ({ ok: false, message });
26+
27+// ── Names ────────────────────────────────────────────────────────────────
28+
29+/** A marketplace's name: letters, digits, `.`, `_` and `-`, starting with a letter or digit, no `..`. */
30+export function marketplaceNameProblem(name: unknown): string | null {
31+ if (typeof name !== "string" || !name) return "A marketplace has a name.";
32+ if (name.length > 64) return "A marketplace's name is at most 64 characters.";
33+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) return "A marketplace's name is letters, digits, dots, underscores and hyphens, starting with a letter or digit.";
34+ return null;
35+}
36+
37+/**
38+ * Names Claude Code keeps for its own marketplaces, which g1t keeps the
39+ * same way: a workspace can't add a marketplace under one of them, so
40+ * `plugin@claude-plugins-official` always means the real thing.
41+ */
42+export const RESERVED_MARKETPLACE_NAMES: readonly string[] = [
43+ "claude-code-marketplace",
44+ "claude-code-plugins",
45+ "claude-plugins-official",
46+ "anthropic-marketplace",
47+ "anthropic-plugins",
48+ "agent-skills",
49+ "anthropic-agent-skills",
50+ "life-sciences",
51+ "knowledge-work-plugins",
52+ "claude-for-legal",
53+ "claude-for-financial-services",
54+ "financial-services-plugins",
55+ "first-party-plugins",
56+ "claude-tag-plugins",
57+ "claude-community",
58+ "claude-plugins-community",
59+ "healthcare",
60+ "inline",
61+ "builtin",
62+ "skills-dir",
63+ "synced",
64+ "npm",
65+ "pip",
66+ "uv",
67+ "cargo",
68+ "github",
69+ "gh",
70+ "g1t",
71+];
72+
73+/** Whether a marketplace name is reserved (the list above, and anything starting `claudeai-`). */
74+export function isReservedMarketplaceName(name: string): boolean {
75+ const lower = name.toLowerCase();
76+ return RESERVED_MARKETPLACE_NAMES.includes(lower) || lower.startsWith("claudeai-");
77+}
78+
79+/** A plugin's name: kebab-case, no spaces, `@`, `:` or path separators. */
80+export function pluginNameProblem(name: unknown): string | null {
81+ if (typeof name !== "string" || !name) return "A plugin has a name.";
82+ if (name.length > 64) return "A plugin's name is at most 64 characters.";
83+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) return "A plugin's name is letters, digits, dots, underscores and hyphens, like pr-review-toolkit.";
84+ return null;
85+}
86+
87+/** `name@marketplace`: how a plugin is named everywhere (settings, installs, requests). */
88+export function pluginRef(name: string, marketplace: string): string {
89+ return `${name}@${marketplace}`;
90+}
91+
92+/** `name@marketplace` split; null when it isn't one. */
93+export function parsePluginRef(value: unknown): { name: string; marketplace: string } | null {
94+ if (typeof value !== "string") return null;
95+ const at = value.lastIndexOf("@");
96+ if (at <= 0 || at === value.length - 1) return null;
97+ const name = value.slice(0, at);
98+ const marketplace = value.slice(at + 1);
99+ if (pluginNameProblem(name) || marketplaceNameProblem(marketplace)) return null;
100+ return { name, marketplace };
101+}
102+
103+/** A plugin's component as it is named to agents and people: `plugin:skill`. */
104+export function namespaced(plugin: string, component: string): string {
105+ return `${plugin}:${component}`;
106+}
107+
108+// ── Sources ──────────────────────────────────────────────────────────────
109+
110+/** Where a plugin's files are, as a marketplace entry says (Claude Code's `source`). */
111+export type PluginSource =
112+ | { source: "path"; path: string }
113+ | { source: "github"; repo: string; ref?: string; sha?: string }
114+ | { source: "url"; url: string; ref?: string; sha?: string }
115+ | { source: "git-subdir"; url: string; path: string; ref?: string; sha?: string }
116+ | { source: "npm"; package: string; version?: string; registry?: string }
117+ | { source: "archive"; url: string; sha256?: string }
118+ | { source: "command"; command: string; timeout?: number; mode?: "copy" | "link" };
119+
120+/**
121+ * Where a marketplace is: a GitHub repository, a git URL, a direct link to
122+ * its `marketplace.json`, a repository on this g1t, or one g1t builds in.
123+ */
124+export type MarketplaceSource =
125+ | { source: "github"; repo: string; ref?: string }
126+ | { source: "git"; url: string; ref?: string }
127+ | { source: "url"; url: string }
128+ | { source: "g1t"; repo: string; ref?: string }
129+ | { source: "builtin"; id: string };
130+
131+const str = (value: unknown): string | undefined => (typeof value === "string" && value.trim() ? value.trim() : undefined);
132+const sha40 = (value: unknown): string | undefined => (typeof value === "string" && /^[0-9a-f]{40}$/i.test(value) ? value.toLowerCase() : undefined);
133+
134+/** `owner/repo` as GitHub names it; null otherwise. */
135+export function githubRepoProblem(repo: unknown): string | null {
136+ if (typeof repo !== "string") return "Name the repository as owner/repo.";
137+ return /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/.test(repo) && !repo.endsWith(".git") && !repo.includes("..") ? null : "Name the repository as owner/repo.";
138+}
139+
140+/** A plugin entry's `source`, read: a relative path, or an object naming where its files are. */
141+export function parsePluginSource(raw: unknown): Checked<PluginSource> {
142+ if (typeof raw === "string") {
143+ if (!raw.startsWith("./") || raw.includes("..")) return bad("A plugin's path starts with ./ and stays inside the marketplace.");
144+ return { ok: true, value: { source: "path", path: raw.replace(/\/+$/, "") } };
145+ }
146+ if (!raw || typeof raw !== "object") return bad("A plugin's source is a path or an object.");
147+ const s = raw as Record<string, unknown>;
148+ switch (s.source) {
149+ case "github": {
150+ const problem = githubRepoProblem(s.repo);
151+ if (problem) return bad(problem);
152+ return { ok: true, value: { source: "github", repo: s.repo as string, ...(str(s.ref) ? { ref: str(s.ref) } : {}), ...(sha40(s.sha) ? { sha: sha40(s.sha) } : {}) } };
153+ }
154+ case "url": {
155+ const url = str(s.url);
156+ if (!url || !/^https?:\/\//.test(url)) return bad("A url source is an https address.");
157+ return { ok: true, value: { source: "url", url, ...(str(s.ref) ? { ref: str(s.ref) } : {}), ...(sha40(s.sha) ? { sha: sha40(s.sha) } : {}) } };
158+ }
159+ case "git-subdir": {
160+ const url = str(s.url);
161+ const path = str(s.path);
162+ if (!url) return bad("A git-subdir source names the repository's url.");
163+ if (!path || path.startsWith("/") || path.includes("..")) return bad("A git-subdir source names a folder inside the repository.");
164+ return { ok: true, value: { source: "git-subdir", url, path: path.replace(/^\.\//, "").replace(/\/+$/, ""), ...(str(s.ref) ? { ref: str(s.ref) } : {}), ...(sha40(s.sha) ? { sha: sha40(s.sha) } : {}) } };
165+ }
166+ case "npm": {
167+ const pkg = str(s.package);
168+ if (!pkg) return bad("An npm source names the package.");
169+ return { ok: true, value: { source: "npm", package: pkg, ...(str(s.version) ? { version: str(s.version) } : {}), ...(str(s.registry) ? { registry: str(s.registry) } : {}) } };
170+ }
171+ case "archive": {
172+ const url = str(s.url);
173+ if (!url || !/^https:\/\//.test(url)) return bad("An archive source is an https address of a zip file.");
174+ return { ok: true, value: { source: "archive", url, ...(typeof s.sha256 === "string" && /^[0-9a-f]{64}$/i.test(s.sha256) ? { sha256: s.sha256.toLowerCase() } : {}) } };
175+ }
176+ case "command": {
177+ const command = str(s.command);
178+ if (!command) return bad("A command source says the command.");
179+ return { ok: true, value: { source: "command", command, ...(typeof s.timeout === "number" ? { timeout: s.timeout } : {}), ...(s.mode === "link" ? { mode: "link" as const } : {}) } };
180+ }
181+ default:
182+ return bad(`g1t doesn't know the plugin source "${String(s.source)}".`);
183+ }
184+}
185+
186+/**
187+ * A GitHub repository from the ways people write one: `owner/repo`,
188+ * `owner/repo#ref`, `owner/repo@ref`, or any github.com address (with or
189+ * without `.git`, with a `/tree/<ref>` path). Null when it isn't one.
190+ */
191+export function githubRepoOf(text: string): { repo: string; ref?: string; path?: string } | null {
192+ const value = text.trim();
193+ const short = /^([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+?)(?:\.git)?(?:[#@]([^\s#@]+))?$/.exec(value);
194+ if (short && !value.includes("://") && !value.includes(":")) {
195+ return { repo: short[1]!, ...(short[2] ? { ref: short[2] } : {}) };
196+ }
197+ const web = /^(?:https?:\/\/|git@)(?:www\.)?github\.com[/:]([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/(.*))?)?\/?(?:[#@]([^\s#@]+))?$/.exec(value);
198+ if (!web) return null;
199+ const ref = web[2] ?? web[4];
200+ return { repo: web[1]!, ...(ref ? { ref } : {}), ...(web[3] ? { path: web[3].replace(/\/+$/, "") } : {}) };
201+}
202+
203+/**
204+ * A marketplace as someone types it into **Add a marketplace**, the forms
205+ * Claude Code's `plugin marketplace add` takes: `owner/repo` (GitHub), a
206+ * github.com or git address, a direct link to `marketplace.json`, or, on
207+ * g1t, a repository here as `workspace/name` when `here` says that name is
208+ * one of this g1t's.
209+ */
210+export function parseMarketplaceAddress(text: string, options: { here?: (repo: string) => boolean; host?: string } = {}): Checked<MarketplaceSource> {
211+ const value = text.trim();
212+ if (!value) return bad("Say where the marketplace is: owner/repo on GitHub, a repository here, or a link.");
213+ if (/^https?:\/\/.*\/marketplace\.json(\?.*)?$/i.test(value)) return { ok: true, value: { source: "url", url: value } };
214+ if (options.host) {
215+ const own = new RegExp(`^https?://(?:www\\.)?${options.host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?/[A-Za-z0-9._-]+?)(?:\\.git)?/?(?:[#@]([^\\s#@]+))?$`, "i").exec(value);
216+ if (own) return { ok: true, value: { source: "g1t", repo: own[1]!, ...(own[2] ? { ref: own[2] } : {}) } };
217+ }
218+ const github = githubRepoOf(value);
219+ if (github && !value.includes("://") && options.here?.(github.repo)) return { ok: true, value: { source: "g1t", repo: github.repo, ...(github.ref ? { ref: github.ref } : {}) } };
220+ if (github) return { ok: true, value: { source: "github", repo: github.repo, ...(github.ref ? { ref: github.ref } : {}) } };
221+ if (/^(https?:\/\/|git@|ssh:\/\/)/.test(value)) {
222+ const ref = /[#]([^\s#]+)$/.exec(value)?.[1];
223+ return { ok: true, value: { source: "git", url: value.replace(/#[^\s#]+$/, ""), ...(ref ? { ref } : {}) } };
224+ }
225+ return bad("That isn't a place g1t can read a marketplace from. Give owner/repo on GitHub, a repository here, or a link to its marketplace.json.");
226+}
227+
228+// ── marketplace.json ─────────────────────────────────────────────────────
229+
230+/** One plugin as a marketplace lists it. Any plugin.json field may appear here too. */
231+export type MarketplacePluginEntry = {
232+ name: string;
233+ source: PluginSource;
234+ description: string;
235+ version: string | null;
236+ author: { name: string; email?: string; url?: string } | null;
237+ homepage: string | null;
238+ repository: string | null;
239+ license: string | null;
240+ keywords: string[];
241+ category: string | null;
242+ tags: string[];
243+ /** Entry-level component paths (added to the manifest's when `strict`). `skills` may be `.`. */
244+ skills: string[];
245+ commands: string[];
246+ agents: string[];
247+ /** Entry-level MCP servers, only for plugins without a plugin.json. */
248+ mcpServers: Record<string, unknown> | null;
249+ /** Default true: the entry's components are added to the manifest's. */
250+ strict: boolean;
251+ displayName: string | null;
252+ /** Whatever else the entry carried, kept as it was. */
253+ extra: Record<string, unknown>;
254+};
255+
256+export type MarketplaceManifest = {
257+ name: string;
258+ owner: { name: string; email?: string; url?: string };
259+ description: string | null;
260+ version: string | null;
261+ /** Bare plugin names resolve under this folder. */
262+ pluginRoot: string | null;
263+ plugins: MarketplacePluginEntry[];
264+ /** Entries that couldn't be read, by index, with why. */
265+ problems: string[];
266+};
267+
268+const strList = (value: unknown): string[] => (Array.isArray(value) ? value.filter((v): v is string => typeof v === "string" && !!v.trim()).map((v) => v.trim()) : typeof value === "string" && value.trim() ? [value.trim()] : []);
269+
270+function person(value: unknown): { name: string; email?: string; url?: string } | null {
271+ if (typeof value === "string") return value.trim() ? { name: value.trim() } : null;
272+ if (!value || typeof value !== "object") return null;
273+ const p = value as Record<string, unknown>;
274+ const name = str(p.name);
275+ if (!name) return null;
276+ return { name, ...(str(p.email) ? { email: str(p.email) } : {}), ...(str(p.url) ? { url: str(p.url) } : {}) };
277+}
278+
279+/** Component paths as a manifest or entry gives them: strings or a list, `./` kept off, `.` meaning the root. */
280+function paths(value: unknown): string[] {
281+ return strList(value).map((p) => (p === "." ? "." : p.replace(/^\.\//, "").replace(/\/+$/, ""))).filter((p) => p && !p.startsWith("/") && !p.split("/").includes(".."));
282+}
283+
284+const ENTRY_KEYS = ["name", "source", "description", "version", "author", "homepage", "repository", "license", "keywords", "category", "tags", "skills", "commands", "agents", "mcpServers", "strict", "displayName"];
285+
286+/** A plugin entry read, or why it can't be. */
287+export function parseMarketplaceEntry(raw: unknown): Checked<MarketplacePluginEntry> {
288+ if (!raw || typeof raw !== "object") return bad("A plugin entry is an object.");
289+ const e = raw as Record<string, unknown>;
290+ const nameProblem = pluginNameProblem(e.name);
291+ if (nameProblem) return bad(nameProblem);
292+ if (e.source === undefined) {
293+ return bad(`${String(e.name)} has no source.`);
294+ }
295+ const source = parsePluginSource(e.source);
296+ if (!source.ok) return bad(`${String(e.name)}: ${source.message}`);
297+ const extra: Record<string, unknown> = {};
298+ for (const [key, value] of Object.entries(e)) if (!ENTRY_KEYS.includes(key)) extra[key] = value;
299+ return {
300+ ok: true,
301+ value: {
302+ name: e.name as string,
303+ source: source.value,
304+ description: (str(e.description) ?? "").slice(0, 2000),
305+ version: str(e.version) ?? null,
306+ author: person(e.author),
307+ homepage: str(e.homepage) ?? null,
308+ repository: str(e.repository) ?? null,
309+ license: str(e.license) ?? null,
310+ keywords: strList(e.keywords).slice(0, 30),
311+ category: str(e.category)?.toLowerCase() ?? null,
312+ tags: strList(e.tags).slice(0, 30),
313+ skills: paths(e.skills),
314+ commands: paths(e.commands),
315+ agents: paths(e.agents),
316+ mcpServers: e.mcpServers && typeof e.mcpServers === "object" && !Array.isArray(e.mcpServers) ? (e.mcpServers as Record<string, unknown>) : null,
317+ strict: e.strict !== false,
318+ displayName: str(e.displayName) ?? null,
319+ extra,
320+ },
321+ };
322+}
323+
324+/** `marketplace.json` read, its unreadable entries noted rather than refused, as Claude Code loads it. */
325+export function parseMarketplaceManifest(raw: unknown): Checked<MarketplaceManifest> {
326+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return bad("marketplace.json is an object with a name, an owner and plugins.");
327+ const m = raw as Record<string, unknown>;
328+ const nameProblem = marketplaceNameProblem(m.name);
329+ if (nameProblem) return bad(nameProblem);
330+ const owner = person(m.owner);
331+ if (!owner) return bad("A marketplace names its owner.");
332+ if (!Array.isArray(m.plugins)) return bad("A marketplace lists its plugins.");
333+ const metadata = m.metadata && typeof m.metadata === "object" ? (m.metadata as Record<string, unknown>) : {};
334+ const plugins: MarketplacePluginEntry[] = [];
335+ const problems: string[] = [];
336+ const names = new Set<string>();
337+ m.plugins.forEach((entry, i) => {
338+ const read = parseMarketplaceEntry(entry);
339+ if (!read.ok) return problems.push(`Plugin ${i + 1}: ${read.message}`);
340+ if (names.has(read.value.name)) return problems.push(`Plugin ${i + 1}: ${read.value.name} is listed twice.`);
341+ names.add(read.value.name);
342+ plugins.push(read.value);
343+ });
344+ const pluginRoot = str(metadata.pluginRoot);
345+ return {
346+ ok: true,
347+ value: {
348+ name: m.name as string,
349+ owner,
350+ description: str(m.description) ?? str(metadata.description) ?? null,
351+ version: str(m.version) ?? str(metadata.version) ?? null,
352+ pluginRoot: pluginRoot ? pluginRoot.replace(/^\.\//, "").replace(/\/+$/, "") : null,
353+ plugins,
354+ problems,
355+ },
356+ };
357+}
358+
359+// ── plugin.json ──────────────────────────────────────────────────────────
360+
361+export type PluginManifest = {
362+ name: string;
363+ displayName: string | null;
364+ version: string | null;
365+ description: string;
366+ author: { name: string; email?: string; url?: string } | null;
367+ homepage: string | null;
368+ repository: string | null;
369+ license: string | null;
370+ keywords: string[];
371+ /** Component paths besides the standard folders, as given (`./` kept off; `.` the root). */
372+ skills: string[];
373+ commands: string[];
374+ agents: string[];
375+ hooks: string[];
376+ /** MCP servers given in the manifest itself (the same shape as .mcp.json's `mcpServers`), or a path to a file. */
377+ mcpServers: Record<string, unknown> | string | null;
378+ lspServers: boolean;
379+ extra: Record<string, unknown>;
380+};
381+
382+const MANIFEST_KEYS = ["$schema", "name", "displayName", "version", "description", "author", "homepage", "repository", "license", "keywords", "skills", "commands", "agents", "hooks", "mcpServers", "lspServers"];
383+
384+/** `.claude-plugin/plugin.json` read; only the name is required. */
385+export function parsePluginManifest(raw: unknown): Checked<PluginManifest> {
386+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return bad("plugin.json is an object naming the plugin.");
387+ const p = raw as Record<string, unknown>;
388+ const nameProblem = pluginNameProblem(p.name);
389+ if (nameProblem) return bad(nameProblem);
390+ const extra: Record<string, unknown> = {};
391+ for (const [key, value] of Object.entries(p)) if (!MANIFEST_KEYS.includes(key)) extra[key] = value;
392+ const hooks = typeof p.hooks === "string" ? paths(p.hooks) : p.hooks && typeof p.hooks === "object" ? ["(inline)"] : [];
393+ return {
394+ ok: true,
395+ value: {
396+ name: p.name as string,
397+ displayName: str(p.displayName) ?? null,
398+ version: str(p.version) ?? null,
399+ description: (str(p.description) ?? "").slice(0, 2000),
400+ author: person(p.author),
401+ homepage: str(p.homepage) ?? null,
402+ repository: str(p.repository) ?? null,
403+ license: str(p.license) ?? null,
404+ keywords: strList(p.keywords).slice(0, 30),
405+ skills: paths(p.skills),
406+ commands: paths(p.commands),
407+ agents: paths(p.agents),
408+ hooks,
409+ mcpServers: typeof p.mcpServers === "string" ? p.mcpServers.replace(/^\.\//, "") : p.mcpServers && typeof p.mcpServers === "object" ? (p.mcpServers as Record<string, unknown>) : null,
410+ lspServers: !!p.lspServers,
411+ extra,
412+ },
413+ };
414+}
415+
416+// ── .mcp.json ────────────────────────────────────────────────────────────
417+
418+export type McpTransport = "http" | "sse" | "stdio" | "ws" | "sdk";
419+
420+/** One server as `.mcp.json` names it, read. Remote servers are what g1t's own agents can use; stdio ones run only where Claude Code runs. */
421+export type McpServerConfig = {
422+ name: string;
423+ type: McpTransport;
424+ url: string | null;
425+ headers: Record<string, string>;
426+ command: string | null;
427+ args: string[];
428+ env: Record<string, string>;
429+ /** The `oauth` block, when the file carries one. */
430+ oauth: Record<string, unknown> | null;
431+ /** `${VAR}` references the config makes, by variable name, for the setup screen. */
432+ variables: string[];
433+};
434+
435+const VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g;
436+
437+function variablesIn(...values: unknown[]): string[] {
438+ const out = new Set<string>();
439+ const walk = (value: unknown) => {
440+ if (typeof value === "string") for (const m of value.matchAll(VARIABLE)) out.add(m[1]!);
441+ else if (Array.isArray(value)) value.forEach(walk);
442+ else if (value && typeof value === "object") Object.values(value as Record<string, unknown>).forEach(walk);
443+ };
444+ values.forEach(walk);
445+ return [...out].sort();
446+}
447+
448+const strMap = (value: unknown): Record<string, string> => {
449+ const out: Record<string, string> = {};
450+ if (value && typeof value === "object" && !Array.isArray(value)) for (const [k, v] of Object.entries(value as Record<string, unknown>)) if (typeof v === "string") out[k] = v;
451+ return out;
452+};
453+
454+/** The servers of an `.mcp.json` (or a manifest's `mcpServers`), those that can't be read noted. */
455+export function parseMcpServers(raw: unknown): { servers: McpServerConfig[]; problems: string[] } {
456+ const servers: McpServerConfig[] = [];
457+ const problems: string[] = [];
458+ const map = raw && typeof raw === "object" && !Array.isArray(raw) ? ((raw as Record<string, unknown>).mcpServers ?? raw) : null;
459+ if (!map || typeof map !== "object" || Array.isArray(map)) return { servers, problems: ["mcpServers is an object of servers by name."] };
460+ for (const [name, value] of Object.entries(map as Record<string, unknown>)) {
461+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name)) {
462+ problems.push(`${name}: a server's name is letters, digits, dots, underscores and hyphens.`);
463+ continue;
464+ }
465+ if (!value || typeof value !== "object") {
466+ problems.push(`${name}: a server is an object.`);
467+ continue;
468+ }
469+ const s = value as Record<string, unknown>;
470+ const url = str(s.url);
471+ const command = str(s.command);
472+ let type: McpTransport;
473+ const given = typeof s.type === "string" ? s.type : null;
474+ if (given === "http" || given === "streamable-http") type = "http";
475+ else if (given === "sse" || given === "ws" || given === "sdk" || given === "stdio") type = given;
476+ else if (given === null && command) type = "stdio";
477+ else if (given === null && url) {
478+ // Claude Code treats a url without a type as stdio and skips it; g1t says so instead.
479+ problems.push(`${name}: a remote server says its type (http or sse).`);
480+ continue;
481+ } else {
482+ problems.push(`${name}: g1t doesn't know the transport "${String(given)}".`);
483+ continue;
484+ }
485+ if ((type === "http" || type === "sse" || type === "ws") && !url) {
486+ problems.push(`${name}: a ${type} server has a url.`);
487+ continue;
488+ }
489+ if (type === "stdio" && !command) {
490+ problems.push(`${name}: a stdio server has a command.`);
491+ continue;
492+ }
493+ servers.push({
494+ name,
495+ type,
496+ url: url ?? null,
497+ headers: strMap(s.headers),
498+ command: command ?? null,
499+ args: strList(s.args),
500+ env: strMap(s.env),
501+ oauth: s.oauth && typeof s.oauth === "object" ? (s.oauth as Record<string, unknown>) : null,
502+ variables: variablesIn(s.url, s.headers, s.command, s.args, s.env),
503+ });
504+ }
505+ return { servers, problems };
506+}
507+
508+// ── Subagents ────────────────────────────────────────────────────────────
509+
510+/** `agents/<name>.md` read: the front-matter Claude Code reads, and the system prompt. */
511+export type SubagentFile = {
512+ name: string;
513+ description: string;
514+ /** The tools it may use, as named in the file (Claude Code's names, or MCP tool names); empty means every tool. */
515+ tools: string[];
516+ disallowedTools: string[];
517+ model: string | null;
518+ /** Skills to preload, by name. */
519+ skills: string[];
520+ mcpServers: string[];
521+ color: string | null;
522+ /** The system prompt: the body after the front-matter. */
523+ prompt: string;
524+ extra: Record<string, unknown>;
525+};
526+
527+const AGENT_KEYS = ["name", "description", "tools", "disallowedTools", "model", "skills", "mcpServers", "color"];
528+
529+/** A subagent's name: at most 256 characters, no colon, not starting with a hyphen. */
530+export function subagentNameProblem(name: unknown): string | null {
531+ if (typeof name !== "string" || !name.trim()) return "A subagent has a name.";
532+ if (name.length > 256) return "A subagent's name is at most 256 characters.";
533+ if (name.includes(":") || name.startsWith("-")) return "A subagent's name has no colon and doesn't start with a hyphen.";
534+ return null;
535+}
536+
537+/** A tools list as Claude Code writes it: a comma-separated string or a list. */
538+function toolList(value: unknown): string[] {
539+ if (Array.isArray(value)) return strList(value);
540+ if (typeof value !== "string") return [];
541+ return value
542+ .split(/[,\s]+/)
543+ .map((t) => t.trim())
544+ .filter(Boolean);
545+}
546+
547+/**
548+ * The file read from its parsed front-matter and body (./skill-format.ts's
549+ * `splitFrontMatter` and `parseFrontMatter` give them), or why Claude Code
550+ * would skip it.
551+ */
552+export function subagentOf(front: Record<string, unknown>, body: string): Checked<SubagentFile> {
553+ const nameProblem = subagentNameProblem(front.name);
554+ if (nameProblem) return bad(nameProblem);
555+ const description = str(front.description);
556+ if (!description) return bad(`${String(front.name)} says nothing about when to use it (description).`);
557+ const prompt = body.trim();
558+ if (!prompt) return bad(`${String(front.name)} has no instructions after the front-matter.`);
559+ const extra: Record<string, unknown> = {};
560+ for (const [key, value] of Object.entries(front)) if (!AGENT_KEYS.includes(key)) extra[key] = value;
561+ return {
562+ ok: true,
563+ value: {
564+ name: (front.name as string).trim(),
565+ description: description.slice(0, 2000),
566+ tools: toolList(front.tools),
567+ disallowedTools: toolList(front.disallowedTools),
568+ model: str(front.model) ?? null,
569+ skills: strList(front.skills),
570+ mcpServers: strList(front.mcpServers),
571+ color: str(front.color) ?? null,
572+ prompt,
573+ extra,
574+ },
575+ };
576+}
577+
578+// ── A plugin's layout ────────────────────────────────────────────────────
579+
580+/** What a plugin's folder holds, found from its file list and manifests. Paths are inside the plugin. */
581+export type PluginLayout = {
582+ /** Folders holding a `SKILL.md`, by path (`skills/pdf`), with the skill's folder name. */
583+ skills: { path: string; name: string }[];
584+ /** `commands/*.md` files: the older form of a skill, one file each. */
585+ commands: { path: string; name: string }[];
586+ /** `agents/**.md` files. */
587+ agents: { path: string; name: string }[];
588+ /** `hooks/hooks.json` (or the manifest's), when present. */
589+ hooks: string[];
590+ /** `.mcp.json` when present (the manifest may hold servers inline too). */
591+ mcp: string | null;
592+ lsp: boolean;
593+ /** `bin/`: executables on the PATH where Claude Code runs; never on g1t's own agents. */
594+ bin: boolean;
595+ outputStyles: boolean;
596+ readme: string | null;
597+};
598+
599+const mdName = (path: string): string => path.replace(/^.*\//, "").replace(/\.md$/i, "");
600+
601+/**
602+ * The plugin's components from its files: the standard folders, then the
603+ * manifest's and the marketplace entry's paths (the entry's added when it
604+ * is strict, as Claude Code does). `files` are paths inside the plugin.
605+ */
606+export function pluginLayout(files: string[], manifest: PluginManifest | null, entry: MarketplacePluginEntry | null): PluginLayout {
607+ const set = new Set(files.map((f) => f.replace(/^\.\//, "")));
608+ const has = (path: string) => set.has(path);
609+ const under = (dir: string) => [...set].filter((f) => f.startsWith(`${dir}/`));
610+ const skillDirs = new Set<string>();
611+ const addSkillDir = (dir: string) => {
612+ const clean = dir === "." ? "" : dir.replace(/\/+$/, "");
613+ if (has(clean ? `${clean}/SKILL.md` : "SKILL.md")) skillDirs.add(clean || ".");
614+ else for (const f of under(clean || ".")) if (/(^|\/)SKILL\.md$/.test(f) && f.slice(clean.length + (clean ? 1 : 0)).split("/").length === 2) skillDirs.add(f.replace(/\/SKILL\.md$/, ""));
615+ };
616+ addSkillDir("skills");
617+ const strictExtras = entry && entry.strict ? entry : null;
618+ for (const dir of [...(manifest?.skills ?? []), ...(strictExtras?.skills ?? []), ...(!manifest && entry ? entry.skills : [])]) addSkillDir(dir);
619+ const commandFiles = new Set<string>();
620+ const addCommands = (path: string) => {
621+ if (/\.md$/i.test(path) && has(path)) commandFiles.add(path);
622+ else for (const f of under(path)) if (/\.md$/i.test(f)) commandFiles.add(f);
623+ };
624+ addCommands("commands");
625+ for (const p of [...(manifest?.commands ?? []), ...(strictExtras?.commands ?? []), ...(!manifest && entry ? entry.commands : [])]) addCommands(p);
626+ const agentFiles = new Set<string>();
627+ const addAgents = (path: string) => {
628+ if (/\.md$/i.test(path) && has(path)) agentFiles.add(path);
629+ else for (const f of under(path)) if (/\.md$/i.test(f)) agentFiles.add(f);
630+ };
631+ addAgents("agents");
632+ for (const p of [...(manifest?.agents ?? []), ...(strictExtras?.agents ?? []), ...(!manifest && entry ? entry.agents : [])]) addAgents(p);
633+ const hooks = [...(has("hooks/hooks.json") ? ["hooks/hooks.json"] : []), ...(manifest?.hooks ?? []).filter((h) => h === "(inline)" || has(h))];
634+ return {
635+ skills: [...skillDirs].sort().map((path) => ({ path, name: path === "." ? (manifest?.name ?? entry?.name ?? "skill") : path.replace(/^.*\//, "") })),
636+ commands: [...commandFiles].sort().map((path) => ({ path, name: mdName(path) })),
637+ agents: [...agentFiles].sort().map((path) => ({ path, name: mdName(path) })),
638+ hooks,
639+ mcp: has(".mcp.json") ? ".mcp.json" : typeof manifest?.mcpServers === "string" && has(manifest.mcpServers) ? manifest.mcpServers : null,
640+ lsp: has(".lsp.json") || !!manifest?.lspServers,
641+ bin: under("bin").length > 0,
642+ outputStyles: under("output-styles").length > 0,
643+ readme: ["README.md", "readme.md", "Readme.md"].find(has) ?? null,
644+ };
645+}
646+
647+// ── Where to fetch from ──────────────────────────────────────────────────
648+
649+/**
650+ * Where g1t reads a plugin's files from, worked out from its entry and the
651+ * marketplace it is in: GitHub (public, by tarball at a commit or ref), a
652+ * repository on this g1t, or a zip archive. Everything else is listed but
653+ * not installable here, and says why.
654+ */
655+export type FetchPlan =
656+ | { kind: "github"; repo: string; ref: string | null; sha: string | null; path: string }
657+ | { kind: "g1t"; repo: string; ref: string | null; path: string }
658+ | { kind: "archive"; url: string; sha256: string | null }
659+ | { kind: "unsupported"; why: string };
660+
661+/** The GitHub repository a git URL names, when it is one on github.com. */
662+function githubOfUrl(url: string): { repo: string } | null {
663+ const found = githubRepoOf(url);
664+ return found && /github\.com/i.test(url) ? { repo: found.repo } : null;
665+}
666+
667+/** A g1t repository a URL names, when the URL is on this g1t (`host`). */
668+function g1tOfUrl(url: string, host: string | null): { repo: string } | null {
669+ if (!host) return null;
670+ const m = new RegExp(`^https?://(?:www\\.)?${host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?/[A-Za-z0-9._-]+?)(?:\\.git)?/?$`, "i").exec(url);
671+ return m ? { repo: m[1]! } : null;
672+}
673+
674+const join = (...parts: (string | null | undefined)[]): string => parts.filter((p) => p && p !== ".").join("/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
675+
676+export function fetchPlan(source: PluginSource, marketplace: MarketplaceSource, options: { pluginRoot?: string | null; host?: string | null } = {}): FetchPlan {
677+ const host = options.host ?? null;
678+ switch (source.source) {
679+ case "path": {
680+ const path = join(options.pluginRoot && !source.path.startsWith("./") ? options.pluginRoot : null, source.path.replace(/^\.\//, ""));
681+ if (marketplace.source === "github") return { kind: "github", repo: marketplace.repo, ref: marketplace.ref ?? null, sha: null, path };
682+ if (marketplace.source === "g1t") return { kind: "g1t", repo: marketplace.repo, ref: marketplace.ref ?? null, path };
683+ if (marketplace.source === "git") {
684+ const gh = githubOfUrl(marketplace.url);
685+ if (gh) return { kind: "github", repo: gh.repo, ref: marketplace.ref ?? null, sha: null, path };
686+ const own = g1tOfUrl(marketplace.url, host);
687+ if (own) return { kind: "g1t", repo: own.repo, ref: marketplace.ref ?? null, path };
688+ return { kind: "unsupported", why: "Its marketplace is on a git host g1t doesn't read yet. g1t reads GitHub and repositories here." };
689+ }
690+ return { kind: "unsupported", why: "Its marketplace was added by a link to its marketplace.json, so a plugin kept beside that file can't be reached. Add the marketplace by its repository instead." };
691+ }
692+ case "github":
693+ return { kind: "github", repo: source.repo, ref: source.ref ?? null, sha: source.sha ?? null, path: "" };
694+ case "url":
695+ case "git-subdir": {
696+ const path = source.source === "git-subdir" ? source.path : "";
697+ const gh = githubOfUrl(source.url) ?? (githubRepoProblem(source.url) ? null : { repo: source.url });
698+ if (gh) return { kind: "github", repo: gh.repo, ref: source.ref ?? null, sha: source.sha ?? null, path };
699+ const own = g1tOfUrl(source.url, host);
700+ if (own) return { kind: "g1t", repo: own.repo, ref: source.sha ?? source.ref ?? null, path };
701+ return { kind: "unsupported", why: "It is on a git host g1t doesn't read yet. g1t reads GitHub and repositories here." };
702+ }
703+ case "archive":
704+ return { kind: "archive", url: source.url, sha256: source.sha256 ?? null };
705+ case "npm":
706+ return { kind: "unsupported", why: "It is published on npm, which g1t doesn't install from yet. It still works in Claude Code." };
707+ case "command":
708+ return { kind: "unsupported", why: "It is produced by a command on the machine that installs it, which g1t can't run. It still works in Claude Code." };
709+ }
710+}
711+
712+/** The marketplaces every workspace starts with, in Claude Code's own names. */
713+export const BUILTIN_MARKETPLACES: { name: string; source: MarketplaceSource; about: string }[] = [
714+ { name: "claude-plugins-official", source: { source: "github", repo: "anthropics/claude-plugins-official" }, about: "Anthropic's reviewed plugins: tools, connectors and skills from Anthropic and the companies behind them." },
715+ { name: "anthropic-agent-skills", source: { source: "github", repo: "anthropics/skills" }, about: "Anthropic's skills: documents, spreadsheets, presentations, PDFs, design, testing and more." },
716+];
+53−20
7373 personal?: { description?: string; status?: ConnectorStatus; capabilities?: ConnectorCapability[] };
7474 /** More words search should find it by. */
7575 keywords?: string[];
76+ /**
77+ * The remote MCP server behind it, when the tool publishes one: g1t
78+ * connects to it (docs.g1t.sh/guides/customize/, "Connectors"), signs in
79+ * the way it asks (`oauth`: the browser; `header`: a key an owner
80+ * pastes; `none`), lists its tools, and gives them to agents.
81+ */
82+ mcp?: { url: string; auth: "oauth" | "header" | "none"; header?: string };
7683 };
7784
7885 /** A connector as one context (a workspace, or you) shows it. */
8895 keywords: string[];
8996 /** The other scope it can also be connected in. */
9097 alsoIn: ConnectorScope | null;
98+ /** The MCP server behind it, when there is one. */
99+ mcp: Connector["mcp"] | null;
91100 };
92101
93102 /** The categories, in the order the directory lists them. */
127136 };
128137 }
129138
139+/** Where an MCP-backed connector is connected: Customize's Connectors page, with the directory entry chosen. */
140+export const MCP_CONNECT_PATH = "/:workspace/-/customize/connectors/new?directory=";
141+
142+/**
143+ * A tool with a remote MCP server: available now, connected from Customize
144+ * (an owner signs in or pastes a key), its tools then given to agents.
145+ */
146+function mcp(
147+ id: string,
148+ name: string,
149+ category: ConnectorCategory,
150+ description: string,
151+ server: NonNullable<Connector["mcp"]>,
152+ capabilities: ConnectorCapability[] = ["Agents can read", "Agents can act"],
153+ keywords: string[] = [],
154+): Connector {
155+ return { id, name, category, description, scopes: W, status: "available", href: { workspace: `${MCP_CONNECT_PATH}${id}` }, capabilities, keywords: ["mcp", ...keywords], mcp: server };
156+}
157+
130158 /** A planned connector. */
131159 function soon(
132160 id: string,
157185 },
158186 keywords: ["git", "import", "mirror", "sign in"],
159187 },
188+ mcp("github-mcp", "GitHub (MCP)", "code", "Agents read and act on repositories, issues and pull requests on GitHub, through GitHub's MCP server.", { url: "https://api.githubcopilot.com/mcp/", auth: "oauth" }, ["Agents can read", "Agents can act"], ["issues", "pull requests"]),
160189 {
161190 id: "webhooks",
162191 name: "Webhooks",
198227 },
199228 keywords: ["tracker", "tickets"],
200229 },
230+ mcp("linear-mcp", "Linear (MCP)", "issues", "Agents search, create and update issues, projects and cycles in Linear, through Linear's MCP server.", { url: "https://mcp.linear.app/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["tracker", "issues"]),
201231 {
202232 id: "jira",
203233 name: "Jira",
210240 provider: "jira",
211241 keywords: ["atlassian", "tracker", "tickets"],
212242 },
213− soon("asana", "Asana", "issues", BOTH, "Tasks agents can read, import as issues, and close when the work merges.", ["Agents can read", "Writes back"], {
214− description: "Tasks assigned to you in Asana, in your g1t inbox.",
215− capabilities: ["Agents can read"],
216− }),
243+ mcp("asana", "Asana", "issues", "Agents read, create and update tasks and projects in Asana, through Asana's MCP server.", { url: "https://mcp.asana.com/v2/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["tasks", "projects"]),
217244 soon("shortcut", "Shortcut", "issues", W, "Stories agents read when work mentions them, updated when the work lands.", ["Agents can read", "Writes back"]),
218245 soon("clickup", "ClickUp", "issues", W, "Tasks agents can read and import, kept in step with their pull requests.", ["Agents can read", "Writes back"]),
219246 soon("trello", "Trello", "issues", W, "Cards become issues, and move along their board as the work does.", ["Opens issues", "Writes back"]),
241268 soon("outlook-mail", "Outlook Mail", "calendar", P, "Forward a thread to an agent, or turn an email into an issue, from your own inbox.", ["Opens issues", "Agents can read"]),
242269
243270 // Docs & knowledge ------------------------------------------------------
244− soon("notion", "Notion", "docs", BOTH, "Import pages into Docs, and let agents read the ones work refers to.", ["Agents can read"], {
245− description: "Reach your own Notion pages, to bring them into Docs.",
246− capabilities: ["Agents can read"],
247− }),
248− soon("confluence", "Confluence", "docs", W, "Import spaces into Docs, and let agents read pages work refers to.", ["Agents can read"]),
271+ mcp("notion", "Notion", "docs", "Agents search, read and write Notion pages and databases, through Notion's MCP server.", { url: "https://mcp.notion.com/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["pages", "wiki", "databases"]),
272+ mcp("atlassian", "Atlassian (Jira and Confluence)", "docs", "Agents search and update Jira issues and Confluence pages, through Atlassian's MCP server.", { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["jira", "confluence", "tickets", "wiki"]),
249273 soon("google-docs", "Google Docs", "docs", BOTH, "Agents read the specs and plans linked from issues and chat.", ["Agents can read"], {
250274 description: "Docs you share in chat open for agents as well as people.",
251275 capabilities: ["Agents can read"],
252276 }),
277+ mcp("zapier", "Zapier", "data", "Agents run the actions you expose from Zapier, across thousands of apps, through Zapier's MCP server.", { url: "https://mcp.zapier.com/api/mcp/mcp", auth: "header", header: "Authorization" }, ["Agents can act"], ["automation", "apps"]),
278+ mcp("webflow", "Webflow", "design", "Agents read and update sites, pages and CMS items in Webflow, through Webflow's MCP server.", { url: "https://mcp.webflow.com/sse", auth: "oauth" }, ["Agents can read", "Agents can act"], ["cms", "sites"]),
253279 soon("coda", "Coda", "docs", W, "Agents read the docs and tables linked from issues and chat.", ["Agents can read"]),
254280
255281 // Monitoring & incidents ------------------------------------------------
289315 provider: "webhook",
290316 keywords: ["incoming", "http", "alerts", "pagerduty", "grafana"],
291317 },
318+ mcp("sentry-mcp", "Sentry (MCP)", "monitoring", "Agents search issues, read stack traces and look up errors in Sentry, through Sentry's MCP server.", { url: "https://mcp.sentry.dev/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["errors", "issues"]),
292319 soon("pagerduty", "PagerDuty", "monitoring", BOTH, "Incidents open issues an agent can start on, and resolve when the fix lands.", ["Opens issues", "Writes back"], {
293320 description: "Sets your status to On call while you are on call.",
294321 capabilities: ["Sets your status"],
301328 soon("better-stack", "Better Stack", "monitoring", W, "Uptime incidents and log alerts open issues.", ["Opens issues"]),
302329
303330 // Cloud & deploy --------------------------------------------------------
304− soon("cloudflare", "Cloudflare", "cloud", W, "Deploy projects to your own Cloudflare account, and manage their domains.", ["Deploys"]),
331+ mcp("cloudflare", "Cloudflare", "cloud", "Agents read your Cloudflare account's Workers, storage and settings, and look things up in its docs, through Cloudflare's MCP servers.", { url: "https://bindings.mcp.cloudflare.com/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["workers", "r2", "d1", "dns"]),
332+ mcp("cloudflare-docs", "Cloudflare docs", "docs", "Agents search Cloudflare's documentation while they work.", { url: "https://docs.mcp.cloudflare.com/mcp", auth: "none" }, ["Agents can read"], ["documentation"]),
305333 soon("aws", "Amazon Web Services", "cloud", W, "Workflows and deployments reach AWS with short-lived credentials, no stored keys.", ["Deploys"]),
306334 soon("google-cloud", "Google Cloud", "cloud", W, "Workflows and deployments reach Google Cloud with short-lived credentials.", ["Deploys"]),
307335 soon("azure", "Microsoft Azure", "cloud", W, "Workflows and deployments reach Azure with short-lived credentials.", ["Deploys"]),
308− soon("vercel", "Vercel", "cloud", W, "Preview and production deployments as checks on pull requests.", ["Deploys", "Agents can read"]),
336+ mcp("vercel", "Vercel", "cloud", "Agents read your Vercel projects, deployments and logs, through Vercel's MCP server.", { url: "https://mcp.vercel.com", auth: "oauth" }, ["Agents can read", "Agents can act"], ["deployments", "previews"]),
309337 soon("netlify", "Netlify", "cloud", W, "Deploy previews as checks on pull requests.", ["Deploys", "Agents can read"]),
310338 soon("fly-io", "Fly.io", "cloud", W, "Deploy apps from workflows, and roll back from the deployment's page.", ["Deploys"]),
311339 soon("kubernetes", "Kubernetes", "cloud", W, "Roll out to your own clusters from workflows, with each rollout's status on g1t.", ["Deploys"]),
349377 },
350378 soon("amazon-bedrock", "Amazon Bedrock", "ai", W, "Models in your own AWS account, billed there.", ["Runs models"]),
351379 soon("vertex-ai", "Google Vertex AI", "ai", W, "Models in your own Google Cloud project, billed there.", ["Runs models"]),
352− soon("hugging-face", "Hugging Face", "ai", W, "Inference endpoints you run on Hugging Face, for any kind of work.", ["Runs models"]),
380+ mcp("hugging-face", "Hugging Face", "ai", "Agents search models, datasets, papers and Spaces on the Hub, through Hugging Face's MCP server.", { url: "https://huggingface.co/mcp", auth: "oauth" }, ["Agents can read"], ["models", "datasets", "hub"]),
381+ mcp("context7", "Context7", "docs", "Agents read current documentation for the libraries a codebase uses, through Context7's MCP server.", { url: "https://mcp.context7.com/mcp", auth: "none" }, ["Agents can read"], ["documentation", "libraries"]),
382+ mcp("microsoft-learn", "Microsoft Learn", "docs", "Agents search Microsoft's documentation for Azure, .NET and Windows, through Microsoft Learn's MCP server.", { url: "https://learn.microsoft.com/api/mcp", auth: "none" }, ["Agents can read"], ["azure", "dotnet", "documentation"]),
383+ mcp("deepwiki", "DeepWiki", "docs", "Agents read generated documentation for public repositories, through DeepWiki's MCP server.", { url: "https://mcp.deepwiki.com/mcp", auth: "none" }, ["Agents can read"], ["repositories", "documentation"]),
353384
354385 // Data & analytics ------------------------------------------------------
355386 soon("postgresql", "PostgreSQL", "data", W, "Agents read your schema, and run read-only queries while they investigate.", ["Agents can read"]),
356387 soon("snowflake", "Snowflake", "data", W, "Agents answer questions from your warehouse with read-only queries.", ["Agents can read"]),
357388 soon("bigquery", "BigQuery", "data", W, "Agents answer questions from your datasets with read-only queries.", ["Agents can read"]),
358− soon("posthog", "PostHog", "data", W, "Agents read product analytics and feature flags while they plan a change.", ["Agents can read"]),
389+ mcp("posthog", "PostHog", "data", "Agents read product analytics, feature flags and errors in PostHog, through PostHog's MCP server.", { url: "https://mcp.posthog.com/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["analytics", "feature flags"]),
390+ mcp("supabase", "Supabase", "data", "Agents read your Supabase projects, schemas and logs, and run queries, through Supabase's MCP server.", { url: "https://mcp.supabase.com/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["postgres", "database"]),
391+ mcp("neon", "Neon", "data", "Agents read and manage Neon Postgres projects and branches, through Neon's MCP server.", { url: "https://mcp.neon.tech/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["postgres", "database"]),
359392 soon("amplitude", "Amplitude", "data", W, "Agents read the charts that work refers to.", ["Agents can read"]),
360393 soon("segment", "Segment", "data", W, "Agents read your tracking plan, and check new events against it.", ["Agents can read"]),
361394
362395 // CRM & support ---------------------------------------------------------
363396 soon("zendesk", "Zendesk", "crm", W, "Turn a ticket into an issue, and tell the customer when the fix ships.", ["Opens issues", "Writes back"]),
364− soon("intercom", "Intercom", "crm", W, "Conversations become issues, and hear back when the fix ships.", ["Opens issues", "Writes back"]),
365− soon("hubspot", "HubSpot", "crm", W, "Agents read the account behind a request when work mentions it.", ["Agents can read"]),
397+ mcp("intercom", "Intercom", "crm", "Agents search conversations, contacts and companies in Intercom, through Intercom's MCP server.", { url: "https://mcp.intercom.com/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["support", "conversations", "customers"]),
398+ mcp("hubspot", "HubSpot", "crm", "Agents read contacts, companies and deals in HubSpot, through HubSpot's MCP server.", { url: "https://mcp.hubspot.com/anthropic", auth: "oauth" }, ["Agents can read", "Agents can act"], ["crm", "contacts", "deals"]),
366399 soon("salesforce", "Salesforce", "crm", W, "Agents read the account and case behind a request.", ["Agents can read"]),
367− soon("stripe", "Stripe", "crm", W, "Agents read customers and payments while they work on billing.", ["Agents can read"]),
400+ mcp("stripe", "Stripe", "crm", "Agents read customers, payments and subscriptions, and look things up in Stripe's docs, through Stripe's MCP server.", { url: "https://mcp.stripe.com", auth: "oauth" }, ["Agents can read", "Agents can act"], ["payments", "billing", "customers"]),
401+ mcp("paypal", "PayPal", "crm", "Agents read and manage orders, invoices and subscriptions, through PayPal's MCP server.", { url: "https://mcp.paypal.com/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["payments", "invoices"]),
368402
369403 // Design ----------------------------------------------------------------
370− soon("figma", "Figma", "design", BOTH, "Agents read the frames linked from an issue, and build to them.", ["Agents can read"], {
371− description: "Reach your own Figma files, to link and preview them in chat and issues.",
372− capabilities: ["Agents can read"],
373− }),
404+ mcp("figma", "Figma", "design", "Agents read designs, components and variables from your Figma files, and build to them, through Figma's MCP server.", { url: "https://mcp.figma.com/mcp", auth: "oauth" }, ["Agents can read"], ["design", "frames", "components"]),
405+ mcp("canva", "Canva", "design", "Agents search, create and export Canva designs, through Canva's MCP server.", { url: "https://mcp.canva.com/mcp", auth: "oauth" }, ["Agents can read", "Agents can act"], ["design", "slides"]),
374406 soon("miro", "Miro", "design", W, "Agents read the boards linked from issues and chat.", ["Agents can read"]),
375407 soon("storybook", "Storybook", "design", W, "Each pull request's stories as a check, with what changed.", ["Agents can read"]),
376408
422454 provider: connector.provider ?? null,
423455 keywords: connector.keywords ?? [],
424456 alsoIn: connector.scopes.includes(other) ? other : null,
457+ mcp: connector.mcp ?? null,
425458 };
426459 }
427460
+8−3
33
44 import { CUSTOMIZE, CUSTOMIZE_SECTIONS, customizePath, sectionOfListing } from "./customize.ts";
55
6−test("every Marketplace listing has a place in Customize", () => {
6+test("every listing has a place in Customize", () => {
77 assert.equal(sectionOfListing("integration"), "connectors");
8+ assert.equal(sectionOfListing("connector"), "connectors");
9+ assert.equal(sectionOfListing("skill"), "skills");
10+ assert.equal(sectionOfListing("plugin"), "plugins");
811 assert.equal(sectionOfListing("extension"), "plugins");
912 for (const section of CUSTOMIZE_SECTIONS) assert.equal(CUSTOMIZE[section].id, section);
1013 });
1114
1215 test("a section's site path names its workspace", () => {
13− assert.equal(customizePath("connectors", "acme"), "/acme/-/marketplace/integrations");
14− assert.equal(customizePath("skills", "a b"), "/a%20b/-/agents/skills");
16+ assert.equal(customizePath("connectors", "acme"), "/acme/-/customize/connectors");
17+ assert.equal(customizePath("skills", "a b"), "/a%20b/-/customize/skills");
18+ assert.equal(customizePath("", "acme"), "/acme/-/customize");
19+ assert.equal(customizePath("plugins", "acme", "pdf@anthropic-agent-skills"), "/acme/-/customize/plugins/pdf@anthropic-agent-skills");
1520 });
+297−25
11 /**
2− * Customize: one place for what shapes how a workspace's agents work, in
3− * three parts. It is the Marketplace's successor, and what the phone app
4− * shows today; the site moves to it in its own change.
2+ * Customize (docs.g1t.sh/guides/customize/): one place for what shapes
3+ * how a workspace's agents work, in three parts, each with what the
4+ * workspace has (**Yours**) and what it can add (**Discover**). It is the
5+ * Marketplace's successor on the site and the phone.
56 *
67 * - **Skills**: what agents know how to do, in the open SKILL.md format
7− * (./skill-library.ts): the foundational skills every agent has, and the
8− * workspace's library.
9− * - **Connectors**: what agents can reach: the tools a team already uses
10− * (./connectors.ts). Today's Marketplace integrations.
11− * - **Plugins**: what adds pages, cards, tools and agent roles to the
12− * workspace (./marketplace.ts's `ExtensionManifest`). Today's
13− * Marketplace extensions.
8+ * (./skill-library.ts, ./skill-format.ts): g1t's foundational skills,
9+ * the workspace's library, and every skill published in a marketplace.
10+ * - **Connectors**: what agents can reach: MCP servers (./claude-plugin.ts
11+ * `.mcp.json`, the directory in ./connectors.ts, and any address an
12+ * owner adds), and g1t's own integrations (GitHub, trackers, alerts,
13+ * model providers).
14+ * - **Plugins**: folders in the open plugin format (./claude-plugin.ts),
15+ * found in marketplaces, each bringing skills, agents and connectors to
16+ * g1t's agents and everything else to Claude Code on a runner.
1417 *
15− * A Marketplace listing (`integration:<id>`, `extension:<id>`) keeps its
16− * reference; {@link sectionOfListing} says which part shows it. No imports
17− * beyond types, so it runs anywhere: services, the site, the phone.
18+ * Everything installable keeps the names the open ecosystem uses:
19+ * `name@marketplace` for a plugin, `plugin:skill` for a skill inside one.
20+ * Wire shapes are snake_case. No imports beyond types, so it runs
21+ * anywhere: services, the site, the phone.
1822 */
1923
24+import type { FetchPlan, MarketplaceSource, McpServerConfig, PluginLayout, SubagentFile } from "./claude-plugin";
25+import type { McpTool } from "./abilities";
26+import type { ConnectorCategory } from "./connectors";
2027 import type { ListingKind } from "./marketplace";
28+import type { SkillScope } from "./skill-library";
2129
2230 export type CustomizeSection = "skills" | "connectors" | "plugins";
2331
2836 title: string;
2937 /** One line, for the section's row. */
3038 description: string;
31− /** Where the site manages it today, with `:workspace` to fill in. */
39+ /** Where the site shows it, with `:workspace` to fill in. */
3240 sitePath: string;
3341 };
3442
3644 skills: {
3745 id: "skills",
3846 title: "Skills",
39− description: "What agents know how to do: the skills every agent has, and your workspace's library.",
40− sitePath: "/:workspace/-/agents/skills",
47+ description: "What agents know how to do: the skills every agent has, your workspace's library, and public skills to add.",
48+ sitePath: "/:workspace/-/customize/skills",
4149 },
4250 connectors: {
4351 id: "connectors",
4452 title: "Connectors",
45− description: "The tools your team already uses, connected so agents can work with their data.",
46− sitePath: "/:workspace/-/marketplace/integrations",
53+ description: "What agents can reach: MCP servers and the tools your team already uses, connected once and given to agents.",
54+ sitePath: "/:workspace/-/customize/connectors",
4755 },
4856 plugins: {
4957 id: "plugins",
5058 title: "Plugins",
51− description: "Pages, cards, tools and agent roles for what your team does besides code.",
52− sitePath: "/:workspace/-/marketplace/extensions",
59+ description: "Skills, agents and connectors published together, from any marketplace in the open plugin format.",
60+ sitePath: "/:workspace/-/customize/plugins",
5361 },
5462 };
5563
56−/** The part of Customize that shows a Marketplace listing of this kind. */
64+/** The part of Customize that shows a listing of this kind. */
5765 export function sectionOfListing(kind: ListingKind): CustomizeSection {
58− return kind === "integration" ? "connectors" : "plugins";
66+ if (kind === "integration" || kind === "connector") return "connectors";
67+ if (kind === "skill") return "skills";
68+ return "plugins";
69+}
70+
71+/** Customize's pages, under `/<workspace>/-/customize`. */
72+export function customizePath(section: CustomizeSection | "" | "requests" | "marketplaces", workspace: string, rest = ""): string {
73+ const base = `/${encodeURIComponent(workspace)}/-/customize`;
74+ return `${base}${section ? `/${section}` : ""}${rest ? `/${rest}` : ""}`;
5975 }
6076
61−/** A section's site path with its workspace filled in. */
62−export function customizePath(section: CustomizeSection, workspace: string): string {
63− return CUSTOMIZE[section].sitePath.replace(/:workspace\b/g, encodeURIComponent(workspace));
77+// ── Marketplaces ─────────────────────────────────────────────────────────
78+
79+/** A marketplace a workspace reads plugins from, as its page lists it. */
80+export type Marketplace = {
81+ /** Its name, as its manifest says and as `name@marketplace` uses. */
82+ name: string;
83+ source: MarketplaceSource;
84+ /** Built in: every workspace has it, and it can't be removed. */
85+ builtin: boolean;
86+ about: string | null;
87+ owner: string | null;
88+ /** How many plugins it lists, and how many of those carry skills. */
89+ plugins: number;
90+ /** RFC 3339: when its manifest was last read, and why it couldn't be, if so. */
91+ fetched_at: string | null;
92+ problem: string | null;
93+ added_by: string | null;
94+ added_at: string | null;
95+ can_remove: boolean;
96+};
97+
98+/** Whether a plugin from Discover can be installed here, now. */
99+export type PluginAvailability =
100+ /** Installable now. */
101+ | { kind: "available" }
102+ /** The workspace has it (switched off, if `enabled` is false). */
103+ | { kind: "installed"; enabled: boolean }
104+ /** Listed but not something g1t can fetch (npm, a command, a git host g1t doesn't read), with why. It still works in Claude Code. */
105+ | { kind: "unavailable"; why: string }
106+ /** g1t's own, announced but not released. */
107+ | { kind: "soon" };
108+
109+/** What a plugin would bring, worked out from its layout, before or after installing. */
110+export type PluginComponents = {
111+ /** Skills by name (`plugin:name` to agents). */
112+ skills: { name: string; description: string | null; path: string }[];
113+ /** Subagents by name, as hireable starting points. */
114+ agents: { name: string; description: string | null; path: string }[];
115+ /** Commands: the older one-file form of a skill. */
116+ commands: { name: string; path: string }[];
117+ /** MCP servers it names: remote ones become connectors; stdio ones are for Claude Code only. */
118+ mcp_servers: { name: string; type: McpServerConfig["type"]; url: string | null; remote: boolean }[];
119+ /** What only Claude Code on a runner uses. */
120+ hooks: boolean;
121+ lsp: boolean;
122+ bin: boolean;
123+ output_styles: boolean;
124+};
125+
126+/** One plugin as Discover lists it, from a marketplace. */
127+export type PluginListing = {
128+ /** `name@marketplace`. */
129+ ref: string;
130+ name: string;
131+ marketplace: string;
132+ display_name: string | null;
133+ description: string;
134+ version: string | null;
135+ author: string | null;
136+ homepage: string | null;
137+ category: string | null;
138+ tags: string[];
139+ keywords: string[];
140+ /** Where g1t would read it from, when it can. */
141+ fetch: FetchPlan;
142+ availability: PluginAvailability;
143+ /** Skills the entry names outright (a skills-only plugin), so Discover can list them without fetching. */
144+ skills: string[];
145+};
146+
147+/** A plugin the workspace installed. */
148+export type PluginInstall = {
149+ id: string;
150+ /** `name@marketplace`. */
151+ ref: string;
152+ name: string;
153+ marketplace: string;
154+ display_name: string | null;
155+ description: string;
156+ version: string | null;
157+ /** The commit it was read at, when the source has one; the ref otherwise. */
158+ sha: string | null;
159+ fetch: FetchPlan;
160+ components: PluginComponents;
161+ /** What couldn't be read when it was installed. */
162+ problems: string[];
163+ enabled: boolean;
164+ installed_by: string;
165+ installed_at: string;
166+ /** A newer version in the marketplace, when the listing moved on. */
167+ update: { version: string | null; sha: string | null } | null;
168+};
169+
170+/** A plugin's page: the listing, the install if any, and what it holds, read from its source. */
171+export type PluginDetail = {
172+ listing: PluginListing;
173+ install: PluginInstall | null;
174+ components: PluginComponents | null;
175+ readme: string | null;
176+ /** The plugin's manifest, when it has one. */
177+ manifest: { name: string; version: string | null; license: string | null; repository: string | null } | null;
178+ /** The subagents in full, for the hire flow. */
179+ agents: SubagentFile[];
180+ problems: string[];
181+ /** The marketplace it is listed in, for the header. */
182+ source: { marketplace: string; owner: string | null };
183+};
184+
185+// ── Skills in Discover ───────────────────────────────────────────────────
186+
187+/** One skill as Discover lists it: a skill inside a marketplace's plugin. */
188+export type SkillListing = {
189+ /** `plugin:skill`. */
190+ ref: string;
191+ name: string;
192+ plugin: string;
193+ marketplace: string;
194+ /** The plugin's description until the skill's own is read. */
195+ description: string;
196+ category: string | null;
197+ author: string | null;
198+ /** Whether the workspace's library already has it from this plugin. */
199+ added: boolean;
200+ /** Whether the plugin it is in can be fetched here. */
201+ availability: PluginAvailability;
202+};
203+
204+// ── Connectors ───────────────────────────────────────────────────────────
205+
206+/** How a connector signs in to its server. */
207+export type ConnectorAuth = "none" | "header" | "oauth";
208+
209+/** Where a connector came from. */
210+export type ConnectorFrom =
211+ | { kind: "directory"; id: string }
212+ | { kind: "plugin"; ref: string; server: string }
213+ | { kind: "address" }
214+ /** An MCP server kept on one agent before connectors existed. */
215+ | { kind: "agent"; handle: string };
216+
217+/** Whether a connector can be used now. */
218+export type ConnectorState = "connected" | "needs_sign_in" | "needs_key" | "problem";
219+
220+/** A workspace's connector: an MCP server connected once, then given to agents. */
221+export type WorkspaceConnector = {
222+ /** `con_…`. */
223+ id: string;
224+ /** 2 to 32 lowercase letters, digits and hyphens; tools are offered as `<name>__<tool>`. */
225+ name: string;
226+ /** For the page: the directory's name, or the name again. */
227+ title: string;
228+ url: string;
229+ category: ConnectorCategory | null;
230+ about: string | null;
231+ auth: ConnectorAuth;
232+ state: ConnectorState;
233+ /** Why it is a problem, when it is. */
234+ problem: string | null;
235+ from: ConnectorFrom;
236+ tools: McpTool[];
237+ /** RFC 3339: when its tools were last listed. */
238+ checked_at: string | null;
239+ /** The header's name when `auth` is header (its value is never shown). */
240+ header_name: string | null;
241+ /** For OAuth: who it is signed in as, if the server said, and when the sign-in was done. */
242+ signed_in_as: string | null;
243+ signed_in_at: string | null;
244+ grants: ConnectorGrant[];
245+ added_by: string;
246+ added_at: string;
247+};
248+
249+/** Where a connector reaches: every agent, a team's, or one. */
250+export type ConnectorGrant = {
251+ id: string;
252+ scope: SkillScope;
253+ /** The agent's handle, the team's slug, or null for the workspace. */
254+ target: string | null;
255+ label: string;
256+ granted_by: string;
257+ granted_at: string;
258+};
259+
260+/** What an owner sends to add a connector. */
261+export type ConnectorInput = {
262+ /** From the directory (its id), a plugin's server (`ref` and `server`), or by address. */
263+ from: { kind: "directory"; id: string } | { kind: "plugin"; ref: string; server: string } | { kind: "address"; name: string; url: string };
264+ auth: ConnectorAuth;
265+ /** For `header`: the header's name and its value, kept sealed. */
266+ header?: { name: string; value: string } | null;
267+};
268+
269+/** Starting an OAuth sign-in for a connector: where the browser goes. */
270+export type ConnectorSignIn = { url: string };
271+
272+// ── Agents and what reaches them ─────────────────────────────────────────
273+
274+/** A connector as one agent's Abilities tab lists it. */
275+export type AgentConnector = {
276+ id: string;
277+ name: string;
278+ title: string;
279+ state: ConnectorState;
280+ /** How the agent has it. */
281+ via: SkillScope | "agent-only";
282+ via_label: string;
283+ tools: number;
284+};
285+
286+// ── The bundle a runner gets ─────────────────────────────────────────────
287+
288+/**
289+ * What a job's Claude Code receives from the workspace's Customize
290+ * (docs.g1t.sh/guides/self-hosted-runners/, "Plugins and skills on
291+ * runners"): plugins to fetch at their pinned commit, the library's skills
292+ * as files, and connectors with the headers that sign them in. Read with
293+ * the job's agent token, once per job.
294+ */
295+export type CustomizeBundle = {
296+ plugins: { ref: string; name: string; fetch: FetchPlan; version: string | null }[];
297+ /** Library skills, each a folder: SKILL.md and its files, as `path` and content. */
298+ skills: { name: string; plugin: string | null; files: { path: string; content: string; encoding: "utf8" | "base64" }[] }[];
299+ /** Connectors the job's agent has, with what to send; secrets included, so this never leaves the runner. */
300+ connectors: { name: string; url: string; headers: Record<string, string> }[];
301+};
302+
303+/** Something to hire from a plugin's `agents/` folder, as a template. */
304+export type PluginAgentTemplate = {
305+ /** `plugin:agent`. */
306+ ref: string;
307+ plugin: string;
308+ marketplace: string;
309+ name: string;
310+ description: string;
311+ /** The system prompt, as the agent's instructions. */
312+ prompt: string;
313+ model: string | null;
314+ skills: string[];
315+ tools: string[];
316+};
317+
318+/** The layout of a plugin turned into what it brings. */
319+export function componentsOf(layout: PluginLayout, servers: McpServerConfig[], skillDescriptions: Record<string, string | null> = {}, agentDescriptions: Record<string, string | null> = {}): PluginComponents {
320+ return {
321+ skills: layout.skills.map((s) => ({ name: s.name, description: skillDescriptions[s.path] ?? null, path: s.path })),
322+ agents: layout.agents.map((a) => ({ name: a.name, description: agentDescriptions[a.path] ?? null, path: a.path })),
323+ commands: layout.commands.map((c) => ({ name: c.name, path: c.path })),
324+ mcp_servers: servers.map((s) => ({ name: s.name, type: s.type, url: s.url, remote: s.type === "http" || s.type === "sse" })),
325+ hooks: layout.hooks.length > 0,
326+ lsp: layout.lsp,
327+ bin: layout.bin,
328+ output_styles: layout.outputStyles,
329+ };
64330 }
331+
332+/** The most marketplaces a workspace adds, and the most plugins it installs. */
333+export const MAX_MARKETPLACES = 20;
334+export const MAX_PLUGIN_INSTALLS = 100;
335+/** The most connectors a workspace keeps. */
336+export const MAX_CONNECTORS = 50;
+2−0
9494 client.restore(ws, viewer, f),
9595 client.delete(ws, viewer, f),
9696 client.trashed(ws, viewer),
97+ client.emptyTrash(ws, viewer),
98+ client.deleteSpace(ws, viewer, "spc_1"),
9799 client.favorite(ws, viewer, f, true),
98100 client.content(ws, viewer, f),
99101 client.edit(ws, viewer, f, { kind: "doc", target: { kind: "append" }, markdown: "x" }),
+1−1
11 const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
22
3−export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp" | "ins" | "skl" | "ska" | "abr" | "mcp";
3+export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp" | "ins" | "skl" | "ska" | "abr" | "mcp" | "mkt" | "plg" | "con" | "cgr";
44
55 let lastMs = 0;
66 let lastCounter = 0;
+1−0
1515 export * from "./compute";
1616 export * from "./connectors";
1717 export * from "./context";
18+export * from "./claude-plugin";
1819 export * from "./customize";
1920 export * from "./d1";
2021 export * from "./datasets";
+11−4
2828 * test it under Node.
2929 */
3030
31−/** What a listing adds to a workspace. */
32−export type ListingKind = "integration" | "extension";
31+/**
32+ * What a listing adds to a workspace: a g1t integration, a plugin
33+ * (`plugin:<name@marketplace>`), a skill from a marketplace
34+ * (`skill:<name@marketplace>/<skill>`), a connector from the directory
35+ * (`connector:<id>`), or an extension (g1t's own, announced). Requests
36+ * (./customize.ts) name any of them.
37+ */
38+export type ListingKind = "integration" | "extension" | "plugin" | "skill" | "connector";
3339
34−export const LISTING_KINDS: readonly ListingKind[] = ["integration", "extension"];
40+export const LISTING_KINDS: readonly ListingKind[] = ["integration", "extension", "plugin", "skill", "connector"];
3541
3642 /** Whether a listing can be added today, or is planned. */
3743 export type ListingStatus = "available" | "soon";
386392 /** A reference split into kind and id; null when it is not one. */
387393 export function parseListing(value: unknown): ListingRef | null {
388394 if (typeof value !== "string") return null;
389− const match = /^(integration|extension):([a-z0-9][a-z0-9_-]{0,63})$/.exec(value.trim());
395+ const match = /^(integration|extension|connector):([a-z0-9][a-z0-9_-]{0,63})$|^(plugin|skill):([A-Za-z0-9][A-Za-z0-9._@/-]{0,199})$/.exec(value.trim());
396+ if (match && match[3]) return { kind: match[3] as ListingKind, id: match[4]! };
390397 return match ? { kind: match[1] as ListingKind, id: match[2]! } : null;
391398 }
392399
+41−10
3333 export const SKILL_NAME_MAX = 64;
3434 /** The longest description ("when to use it"). */
3535 export const SKILL_DESCRIPTION_MAX = 1024;
36−/** Where a repository keeps the skills it mirrors: `.g1t/skills/<name>/SKILL.md`. */
37−export const SKILLS_REPO_DIR = ".g1t/skills";
36+/**
37+ * Where a repository keeps the skills it mirrors: `.claude/skills/<name>/SKILL.md`,
38+ * the folder Claude Code reads project skills from, so one repository
39+ * serves both. `.g1t/skills` is still read for libraries linked before.
40+ */
41+export const SKILLS_REPO_DIR = ".claude/skills";
42+export const SKILLS_REPO_DIRS: readonly string[] = [".claude/skills", ".g1t/skills"];
3843
3944 /**
4045 * Names the library can't use: g1t's foundational skills', so `use_skill`
296301 return null;
297302 }
298303
304+/** A plugin skill's name, as Claude Code takes it: letters, digits, dots, underscores and hyphens, at most 64. */
305+export function lenientSkillNameProblem(name: string): string | null {
306+ if (!name) return "A skill needs a name.";
307+ if (name.length > SKILL_NAME_MAX) return `A skill's name is at most ${SKILL_NAME_MAX} characters.`;
308+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) return "A skill's name is letters, digits, dots, underscores and hyphens.";
309+ return null;
310+}
311+
299312 /** Whether `path` is a plain relative path inside the folder. */
300313 export function skillPathProblem(path: string): string | null {
301314 if (!path || path.length > 255) return `${path || "A file"} isn't a path a skill can hold.`;
334347 * files and `SKILL_FOLDER_MAX_BYTES` together. `expectName` is the
335348 * folder's name when it must match (a repository's `.g1t/skills/<name>/`).
336349 */
337−export function checkSkillFolder(input: SkillFile[], options: { expectName?: string | null } = {}): SkillCheck {
350+export type SkillCheckOptions = {
351+ expectName?: string | null;
352+ /**
353+ * For a skill from a plugin: its name keeps its case and may be one of
354+ * g1t's foundational names (agents call it as `plugin:name`), `tools:`
355+ * names agents don't have are kept aside rather than refused, and the
356+ * folder may be larger (`limits`).
357+ */
358+ lenient?: boolean;
359+ limits?: { files: number; bytes: number };
360+};
361+
362+/** How large a plugin's skill may be: public skills carry references and schemas. */
363+export const PLUGIN_SKILL_LIMITS = { files: 1000, bytes: 4 * 1024 * 1024 };
364+
365+export function checkSkillFolder(input: SkillFile[], options: SkillCheckOptions = {}): SkillCheck {
338366 const bad = (message: string): SkillCheck => ({ ok: false, message });
339367 if (!Array.isArray(input) || !input.length) return bad("A skill is a folder with a SKILL.md in it.");
340− if (input.length > SKILL_FILES_MAX) return bad(`A skill holds at most ${SKILL_FILES_MAX} files; this one has ${input.length}.`);
368+ const limits = options.limits ?? { files: SKILL_FILES_MAX, bytes: SKILL_FOLDER_MAX_BYTES };
369+ if (input.length > limits.files) return bad(`A skill holds at most ${limits.files} files; this one has ${input.length}.`);
341370 const seen = new Set<string>();
342371 let skillMd: SkillFile | null = null;
343372 const files: SkillFile[] = [];
357386 skillMd = { ...file, path: "SKILL.md" };
358387 } else files.push(file);
359388 }
360− if (bytes > SKILL_FOLDER_MAX_BYTES) return bad(`A skill's folder is at most 1 MB; this one is ${skillSize(bytes)}.`);
389+ if (bytes > limits.bytes) return bad(`A skill's folder is at most ${limits.bytes === SKILL_FOLDER_MAX_BYTES ? "1 MB" : skillSize(limits.bytes)}; this one is ${skillSize(bytes)}.`);
361390 if (!skillMd) {
362391 const nested = files.find((f) => f.path.toLowerCase().endsWith("/skill.md"));
363392 return bad(nested ? `SKILL.md belongs at the top of the folder, not in ${nested.path.slice(0, nested.path.lastIndexOf("/"))}.` : "A skill's folder needs a SKILL.md at its top.");
370399 } catch (error) {
371400 return bad(`SKILL.md's front-matter can't be read: ${error instanceof Error ? error.message : String(error)}.`);
372401 }
373− const name = typeof front.name === "string" ? front.name.trim() : "";
374− const nameProblem = skillNameProblem(name);
402+ const name = typeof front.name === "string" ? front.name.trim() : options.lenient && options.expectName ? options.expectName : "";
403+ const nameProblem = options.lenient ? lenientSkillNameProblem(name) : skillNameProblem(name);
375404 if (nameProblem) return bad(name ? nameProblem : "SKILL.md's front-matter needs a name: the skill's name, like release-notes.");
376405 if (options.expectName && options.expectName !== name) return bad(`The folder is ${options.expectName}, but its SKILL.md is named ${name}. They must match.`);
377406 const description = typeof front.description === "string" ? front.description.replace(/\s+/g, " ").trim() : "";
379408 if (description.length > SKILL_DESCRIPTION_MAX) return bad(`A skill's description is at most ${SKILL_DESCRIPTION_MAX} characters.`);
380409 const listed = listOf(front.tools);
381410 if (!listed) return bad("tools: is a list of tool names, like [read_file, make_file].");
382− const tools = [...new Set(listed.map((t) => t.trim()))].filter(Boolean);
383− const unknown = tools.filter((tool) => !AGENT_TOOL_NAMES.includes(tool));
384− if (unknown.length) return bad(`tools: names ${unknown.join(", ")}, which ${unknown.length === 1 ? "isn't a tool" : "aren't tools"} agents have. Agents' tools are listed in the skills guide.`);
411+ const named = [...new Set(listed.map((t) => t.trim()))].filter(Boolean);
412+ const unknown = named.filter((tool) => !AGENT_TOOL_NAMES.includes(tool));
413+ const tools = options.lenient ? named.filter((tool) => AGENT_TOOL_NAMES.includes(tool)) : named;
414+ if (unknown.length && !options.lenient) return bad(`tools: names ${unknown.join(", ")}, which ${unknown.length === 1 ? "isn't a tool" : "aren't tools"} agents have. Agents' tools are listed in the skills guide.`);
385415 const flag = front.requires_computer;
386416 if (flag !== undefined && flag !== null && typeof flag !== "boolean") return bad("requires_computer: is true or false.");
387417 const body = split.body.trim();
389419 const scripts = files.filter((f) => f.path.startsWith("scripts/")).map((f) => f.path);
390420 const extra: Record<string, unknown> = {};
391421 for (const [key, value] of Object.entries(front)) if (!["name", "description", "tools", "requires_computer"].includes(key)) extra[key] = value;
422+ if (options.lenient && unknown.length) extra.tools = unknown;
392423 files.sort((a, b) => a.path.localeCompare(b.path));
393424 return {
394425 ok: true,
+10−2
5151 | { kind: "repository"; repo: string; path: string; ref: string; commit: string }
5252 /** Drafted by an agent from a finished session. */
5353 | { kind: "session"; session_id: string; agent: string; title: string }
54− /** From `.g1t/skills/<name>/` in the repository the library follows. */
55− | { kind: "mirror"; repo: string; path: string; commit: string };
54+ /** From `.claude/skills/<name>/` in the repository the library follows. */
55+ | { kind: "mirror"; repo: string; path: string; commit: string }
56+ /** From a plugin installed from a marketplace (./claude-plugin.ts), at the commit it was read at. */
57+ | { kind: "plugin"; plugin: string; marketplace: string; path: string; sha: string | null };
5658
5759 /** A draft is waiting for a person to review it; only published skills can be attached. */
5860 export type SkillStatus = "published" | "draft";
6062 export type LibrarySkill = {
6163 id: string;
6264 name: string;
65+ /** The plugin it came with (`name@marketplace`), or null for the workspace's own. */
66+ plugin: string | null;
67+ /** What agents and people call it: `plugin:name` for a plugin's skill, the name alone otherwise. */
68+ qualified_name: string;
6369 /** When to use it. */
6470 description: string;
6571 status: SkillStatus;
181187 /** A foundational skill's id, or a library skill's. */
182188 id: string;
183189 name: string;
190+ /** The plugin it came with, or null. */
191+ plugin: string | null;
184192 description: string;
185193 foundational: boolean;
186194 /** Whether it is on for this agent (owners turn skills off per agent). */
+55−11
1414 // Model tiers (`ModelTier`, `MODEL_TIERS`) are integrations.ts's, the
1515 // same ones runs are routed between.
1616 import type { ModelTier } from "./integrations";
17−import type { ExtensionInstall, InstallRequest, InstallRequestStatus, InstallRequests } from "./marketplace";
17+import type { InstallRequest, InstallRequestStatus, InstallRequests } from "./marketplace";
1818 import type { AgentAbilities, AgentAbilitiesChange, McpServer } from "./abilities";
19+import type { AgentConnector, ConnectorInput, ConnectorSignIn, CustomizeBundle, Marketplace, PluginAgentTemplate, PluginDetail, PluginInstall, PluginListing, SkillListing, WorkspaceConnector } from "./customize";
20+import type { SkillScope } from "./skill-library";
1921 import type { AgentLook } from "./agent-look";
2022 import type { AgentComputerCommand, AgentComputerStatus } from "./runner";
2123
10411043 /** An owner marks a request added (`done`) or turns it down (`declined`); whoever asked is told. */
10421044 resolveInstallRequest(workspace: string, viewer: User, id: string, status: Exclude<InstallRequestStatus, "open">): Promise<Result<InstallRequest>>;
10431045 /** The extensions installed in the workspace; any member sees them. */
1044− extensionInstalls(workspace: string, viewer: User): Promise<Result<ExtensionInstall[]>>;
1046+ // Customize (./customize.ts): marketplaces, plugins and connectors.
1047+ marketplaces(workspace: string, viewer: User): Promise<Result<Marketplace[]>>;
1048+ addMarketplace(workspace: string, viewer: User, address: string, name?: string | null): Promise<Result<Marketplace>>;
1049+ removeMarketplace(workspace: string, viewer: User, name: string): Promise<Result<null>>;
1050+ refreshMarketplace(workspace: string, viewer: User, name: string): Promise<Result<Marketplace>>;
1051+ discover(workspace: string, viewer: User): Promise<Result<{ plugins: PluginListing[]; skills: SkillListing[]; marketplaces: Marketplace[] }>>;
1052+ plugin(workspace: string, viewer: User, ref: string): Promise<Result<PluginDetail>>;
1053+ pluginInstalls(workspace: string, viewer: User): Promise<Result<PluginInstall[]>>;
1054+ installPlugin(workspace: string, viewer: User, ref: string): Promise<Result<PluginInstall>>;
1055+ setPluginEnabled(workspace: string, viewer: User, ref: string, enabled: boolean): Promise<Result<PluginInstall>>;
1056+ uninstallPlugin(workspace: string, viewer: User, ref: string): Promise<Result<null>>;
1057+ pluginAgents(workspace: string, viewer: User): Promise<Result<PluginAgentTemplate[]>>;
1058+ connectors(workspace: string, viewer: User): Promise<Result<WorkspaceConnector[]>>;
1059+ connector(workspace: string, viewer: User, id: string): Promise<Result<WorkspaceConnector>>;
1060+ addConnector(workspace: string, viewer: User, input: ConnectorInput): Promise<Result<WorkspaceConnector>>;
1061+ setConnectorKey(workspace: string, viewer: User, id: string, headerName: string | null, value: string): Promise<Result<WorkspaceConnector>>;
1062+ removeConnector(workspace: string, viewer: User, id: string): Promise<Result<null>>;
1063+ refreshConnector(workspace: string, viewer: User, id: string): Promise<Result<WorkspaceConnector>>;
1064+ /** Where the browser goes to sign the connector in; `redirectUri` is this g1t's page the server sends it back to. */
1065+ startConnectorSignIn(workspace: string, viewer: User, id: string, redirectUri: string): Promise<Result<ConnectorSignIn>>;
1066+ finishConnectorSignIn(workspace: string, viewer: User, state: string, code: string): Promise<Result<WorkspaceConnector>>;
1067+ signOutConnector(workspace: string, viewer: User, id: string): Promise<Result<WorkspaceConnector>>;
1068+ /** `target`: an agent's handle or a team's slug; null for every agent. */
1069+ grantConnector(workspace: string, viewer: User, id: string, scope: SkillScope, target: string | null): Promise<Result<WorkspaceConnector>>;
1070+ revokeConnector(workspace: string, viewer: User, id: string, grant: string): Promise<Result<WorkspaceConnector>>;
1071+ agentConnectors(workspace: string, handle: string, viewer: User): Promise<Result<AgentConnector[]>>;
1072+ /** What a job's Claude Code gets, read with the job's agent token; secrets included, so only the API hands it to a runner. */
1073+ customizeBundle(workspace: string, viewer: User): Promise<Result<CustomizeBundle>>;
10451074 /** Owners install a published extension at its current version; open requests for it are answered. */
1046− installExtension(workspace: string, viewer: User, extension: string): Promise<Result<ExtensionInstall>>;
10471075 /** Owners switch an install on or off: off is the kill switch. */
1048− setExtensionEnabled(workspace: string, viewer: User, listing: string, enabled: boolean): Promise<Result<ExtensionInstall>>;
10491076 /** Owners cap what an install spends a month; null leaves it to the workspace's limit. */
1050− setExtensionBudget(workspace: string, viewer: User, listing: string, monthlyMicros: number | null): Promise<Result<ExtensionInstall>>;
1051− uninstallExtension(workspace: string, viewer: User, listing: string): Promise<Result<null>>;
10521077 };
10531078
10541079 async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
11281153 installRequests: (workspace, viewer) => call("install_requests", { workspace, viewer }),
11291154 requestInstall: (workspace, viewer, listing, note) => call("request_install", { workspace, viewer, listing, note: note ?? null }),
11301155 resolveInstallRequest: (workspace, viewer, id, status) => call("resolve_install_request", { workspace, viewer, id, status }),
1131− extensionInstalls: (workspace, viewer) => call("extension_installs", { workspace, viewer }),
1132− installExtension: (workspace, viewer, extension) => call("install_extension", { workspace, viewer, extension }),
1133− setExtensionEnabled: (workspace, viewer, listing, enabled) => call("set_extension_enabled", { workspace, viewer, listing, enabled }),
1134− setExtensionBudget: (workspace, viewer, listing, monthlyMicros) => call("set_extension_budget", { workspace, viewer, listing, monthly_micros: monthlyMicros }),
1135− uninstallExtension: (workspace, viewer, listing) => call("uninstall_extension", { workspace, viewer, listing }),
1156+ marketplaces: (workspace, viewer) => call("marketplaces", { workspace, viewer }),
1157+ addMarketplace: (workspace, viewer, address, name) => call("add_marketplace", { workspace, viewer, address, name: name ?? null }),
1158+ removeMarketplace: (workspace, viewer, name) => call("remove_marketplace", { workspace, viewer, name }),
1159+ refreshMarketplace: (workspace, viewer, name) => call("refresh_marketplace", { workspace, viewer, name }),
1160+ discover: (workspace, viewer) => call("discover", { workspace, viewer }),
1161+ plugin: (workspace, viewer, ref) => call("plugin", { workspace, viewer, ref }),
1162+ pluginInstalls: (workspace, viewer) => call("plugin_installs", { workspace, viewer }),
1163+ installPlugin: (workspace, viewer, ref) => call("install_plugin", { workspace, viewer, ref }),
1164+ setPluginEnabled: (workspace, viewer, ref, enabled) => call("set_plugin_enabled", { workspace, viewer, ref, enabled }),
1165+ uninstallPlugin: (workspace, viewer, ref) => call("uninstall_plugin", { workspace, viewer, ref }),
1166+ pluginAgents: (workspace, viewer) => call("plugin_agents", { workspace, viewer }),
1167+ connectors: (workspace, viewer) => call("connectors", { workspace, viewer }),
1168+ connector: (workspace, viewer, id) => call("connector", { workspace, viewer, id }),
1169+ addConnector: (workspace, viewer, input) => call("add_connector", { workspace, viewer, input }),
1170+ setConnectorKey: (workspace, viewer, id, headerName, value) => call("set_connector_key", { workspace, viewer, id, header_name: headerName, value }),
1171+ removeConnector: (workspace, viewer, id) => call("remove_connector", { workspace, viewer, id }),
1172+ refreshConnector: (workspace, viewer, id) => call("refresh_connector", { workspace, viewer, id }),
1173+ startConnectorSignIn: (workspace, viewer, id, redirectUri) => call("start_connector_sign_in", { workspace, viewer, id, redirect_uri: redirectUri }),
1174+ finishConnectorSignIn: (workspace, viewer, state, code) => call("finish_connector_sign_in", { workspace, viewer, state, code }),
1175+ signOutConnector: (workspace, viewer, id) => call("sign_out_connector", { workspace, viewer, id }),
1176+ grantConnector: (workspace, viewer, id, scope, target) => call("grant_connector", { workspace, viewer, id, scope, target }),
1177+ revokeConnector: (workspace, viewer, id, grant) => call("revoke_connector", { workspace, viewer, id, grant }),
1178+ agentConnectors: (workspace, handle, viewer) => call("agent_connectors", { workspace, handle, viewer }),
1179+ customizeBundle: (workspace, viewer) => call("customize_bundle", { workspace, viewer }),
11361180 };
11371181 }
+109−0
1+-- Customize (docs.g1t.sh/guides/customize/): marketplaces a workspace
2+-- reads plugins from, the plugins it installed, and its connectors (MCP
3+-- servers connected once and given to agents). Formats are the open ones
4+-- Claude Code reads (@g1t/contracts claude-plugin.ts). Requests stay in
5+-- install_requests (0005), whose listings now name plugins, skills and
6+-- connectors too. extension_installs (0005) is no longer written.
7+
8+-- A marketplace: where it is (MarketplaceSource, JSON) and its manifest as
9+-- last read (marketplace.json, JSON), so Discover lists from the cache.
10+-- The built-in marketplaces (claude-plugins-official, anthropic-agent-skills)
11+-- have no row of their own per workspace: their manifests are cached
12+-- under workspace_id '' and read by every workspace.
13+CREATE TABLE marketplaces (
14+ id TEXT PRIMARY KEY,
15+ workspace_id TEXT NOT NULL,
16+ name TEXT NOT NULL,
17+ source TEXT NOT NULL,
18+ manifest TEXT,
19+ fetched_at TEXT,
20+ -- Why the manifest couldn't be read last time, if so.
21+ problem TEXT,
22+ added_by TEXT NOT NULL,
23+ added_at TEXT NOT NULL
24+);
25+CREATE UNIQUE INDEX marketplaces_name ON marketplaces (workspace_id, name);
26+
27+-- A plugin installed in a workspace: `name@marketplace`, where its files
28+-- were read from (FetchPlan, JSON) and at which commit, what it brought
29+-- (PluginComponents, JSON), and whether it is switched on. Uninstalling
30+-- keeps the row with when and by whom.
31+CREATE TABLE plugin_installs (
32+ id TEXT PRIMARY KEY,
33+ workspace_id TEXT NOT NULL,
34+ ref TEXT NOT NULL,
35+ name TEXT NOT NULL,
36+ marketplace TEXT NOT NULL,
37+ display_name TEXT,
38+ description TEXT NOT NULL DEFAULT '',
39+ version TEXT,
40+ sha TEXT,
41+ fetch TEXT NOT NULL,
42+ components TEXT NOT NULL DEFAULT '{}',
43+ -- The subagents in full (SubagentFile[], JSON), as hireable templates.
44+ agents TEXT NOT NULL DEFAULT '[]',
45+ problems TEXT NOT NULL DEFAULT '[]',
46+ enabled INTEGER NOT NULL DEFAULT 1,
47+ installed_by TEXT NOT NULL,
48+ installed_at TEXT NOT NULL,
49+ updated_at TEXT NOT NULL,
50+ uninstalled_by TEXT,
51+ uninstalled_at TEXT
52+);
53+CREATE UNIQUE INDEX plugin_installs_live ON plugin_installs (workspace_id, ref) WHERE uninstalled_at IS NULL;
54+
55+-- A library skill that came with a plugin keeps the plugin's ref, and its
56+-- name is unique within that plugin rather than the library: agents call
57+-- it as `plugin:name`.
58+ALTER TABLE skills ADD COLUMN plugin TEXT;
59+DROP INDEX skills_name;
60+CREATE UNIQUE INDEX skills_name ON skills (workspace_id, COALESCE(plugin, ''), name) WHERE archived_at IS NULL;
61+
62+-- A connector: an MCP server the workspace connected. Its secrets (a
63+-- header's value, OAuth tokens and the client registration) are sealed
64+-- with AES-GCM under CONNECTORS_KEY, bound to the connector's id, and
65+-- never returned. `from` says where it came from (ConnectorFrom, JSON);
66+-- `tools` is the server's tool list as last read (McpTool[], JSON).
67+CREATE TABLE connectors (
68+ id TEXT PRIMARY KEY,
69+ workspace_id TEXT NOT NULL,
70+ name TEXT NOT NULL,
71+ title TEXT NOT NULL,
72+ url TEXT NOT NULL,
73+ category TEXT,
74+ about TEXT,
75+ -- none, header or oauth.
76+ auth TEXT NOT NULL DEFAULT 'none',
77+ header_name TEXT,
78+ -- Sealed JSON: { header?: value, oauth?: { client_id, client_secret?, access_token, refresh_token?, expires_at?, token_endpoint, scopes? } }.
79+ secrets TEXT,
80+ -- What the server said about who signed in, when the sign-in was done.
81+ signed_in_as TEXT,
82+ signed_in_at TEXT,
83+ -- An OAuth sign-in in progress: the state, the PKCE verifier and the endpoints, sealed; cleared when it completes.
84+ pending TEXT,
85+ pending_at TEXT,
86+ from_json TEXT NOT NULL,
87+ tools TEXT NOT NULL DEFAULT '[]',
88+ checked_at TEXT,
89+ problem TEXT,
90+ added_by TEXT NOT NULL,
91+ added_at TEXT NOT NULL,
92+ updated_at TEXT NOT NULL,
93+ removed_at TEXT
94+);
95+CREATE UNIQUE INDEX connectors_name ON connectors (workspace_id, name) WHERE removed_at IS NULL;
96+
97+-- Where a connector reaches: every agent (scope workspace, target ''), a
98+-- team's agents (team, the team's slug), or one agent (agent, its id).
99+CREATE TABLE connector_grants (
100+ id TEXT PRIMARY KEY,
101+ workspace_id TEXT NOT NULL,
102+ connector_id TEXT NOT NULL,
103+ scope TEXT NOT NULL,
104+ target TEXT NOT NULL DEFAULT '',
105+ granted_by TEXT NOT NULL,
106+ granted_at TEXT NOT NULL
107+);
108+CREATE UNIQUE INDEX connector_grants_once ON connector_grants (connector_id, scope, target);
109+CREATE INDEX connector_grants_reach ON connector_grants (workspace_id, scope, target);
+55−3
Loading the diff…
+200−0
Loading the diff…
+667−0
Loading the diff…
+247−0
Loading the diff…
+474−0
Loading the diff…
+231−0
Loading the diff…
+0−88
Loading the diff…
+0−110
Loading the diff…
+5−68
Loading the diff…
+10−2
Loading the diff…
+34−4
Loading the diff…
+305−0
Loading the diff…
+16−12
Loading the diff…
+422−0
Loading the diff…
+6−4
Loading the diff…
+6−4
Loading the diff…
+47−15
Loading the diff…
+7−7
Loading the diff…
+6−2
Loading the diff…
+5−1
Loading the diff…
+6−0
Loading the diff…

Comments

Sign in to comment.