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.
41 files+5777−8010/41 viewed
| 590 | 590 | | **Home** | Where you're needed across the workspace, and what happened while you were away. See [Home](/guides/home/). | | |
| 591 | 591 | | **Chat** | Channels and messages. See [Chat](/guides/chat/). | | |
| 592 | 592 | | **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/). | | |
| 594 | 593 | | **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/). | | |
| 595 | 595 | | **Artifacts** | Documents, decks and pages. See [artifacts](/guides/artifacts/). | | |
| 596 | 596 | | **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/). | | |
| 597 | 597 | | **Workspace** | The workspace's settings: who has access, money, compute, and how each part of g1t is set up. See [Workspace mode](#workspace-mode). | |
| 235 | 235 | label: "Chat", | |
| 236 | 236 | badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 }, | |
| 237 | 237 | }, | |
| 238 | − | { key: "agents", label: "Agents" }, | |
| 239 | 238 | ...(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" }, | |
| 240 | 241 | { key: "artifacts", label: "Artifacts" }, | |
| 241 | 242 | ]; | |
| 242 | 243 | const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null); |
A large diff (2,615 lines) is not shown until you ask for it.
| 20 | 20 | // | |
| 21 | 21 | // Usage: node configs.mjs [outDir] | |
| 22 | 22 | // 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, | |
| 24 | 24 | // USERCONTENT_URL, | |
| 25 | 25 | // PACKAGES_TOKEN_SECRET, S3_ENDPOINT, S3_BUCKET, BACKUP_S3_BUCKET, DOCS_S3_BUCKET, S3_REGION, | |
| 26 | 26 | // S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_PUBLIC_ENDPOINT, and optionally | |
| ⋯ | |||
| 82 | 82 | g1t: "USERCONTENT_KEY", | |
| 83 | 83 | "g1t-actions": "ACTIONS_KEY", | |
| 84 | 84 | "g1t-integrations": "INTEGRATIONS_KEY", | |
| 85 | + | "g1t-agents": "CONNECTORS_KEY", | |
| 85 | 86 | "g1t-webhooks": "WEBHOOKS_KEY", | |
| 86 | 87 | }; | |
| 87 | 88 | ||
| 28 | 28 | if ! grep -q '^IDENTITY_KEY=' "$KEYS"; then | |
| 29 | 29 | echo "IDENTITY_KEY=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" >> "$KEYS" | |
| 30 | 30 | 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 | |
| 31 | 36 | # The site's key, which signs the short-lived addresses of private | |
| 32 | 37 | # repositories' files. | |
| 33 | 38 | if ! grep -q '^USERCONTENT_KEY=' "$KEYS"; then |
| 173 | 173 | "worker": "g1t-agents", | |
| 174 | 174 | "d1": { "database": "g1t-agents", "migrations": "migrations" }, | |
| 175 | 175 | "stage": "core", | |
| 176 | − | "secrets": [], | |
| 176 | + | "secrets": ["CONNECTORS_KEY"], | |
| 177 | 177 | "setup": [ | |
| 178 | 178 | "The D1 database, before the first deploy: npx wrangler d1 create g1t-agents, then put its id in services/agents/wrangler.jsonc" | |
| 179 | 179 | ], |
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "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" }, | |
| 8 | 8 | "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 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 | + | }); |
| 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 | + | ]; |
| 73 | 73 | personal?: { description?: string; status?: ConnectorStatus; capabilities?: ConnectorCapability[] }; | |
| 74 | 74 | /** More words search should find it by. */ | |
| 75 | 75 | 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 }; | |
| 76 | 83 | }; | |
| 77 | 84 | ||
| 78 | 85 | /** A connector as one context (a workspace, or you) shows it. */ | |
| ⋯ | |||
| 88 | 95 | keywords: string[]; | |
| 89 | 96 | /** The other scope it can also be connected in. */ | |
| 90 | 97 | alsoIn: ConnectorScope | null; | |
| 98 | + | /** The MCP server behind it, when there is one. */ | |
| 99 | + | mcp: Connector["mcp"] | null; | |
| 91 | 100 | }; | |
| 92 | 101 | ||
| 93 | 102 | /** The categories, in the order the directory lists them. */ | |
| ⋯ | |||
| 127 | 136 | }; | |
| 128 | 137 | } | |
| 129 | 138 | ||
| 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 | + | ||
| 130 | 158 | /** A planned connector. */ | |
| 131 | 159 | function soon( | |
| 132 | 160 | id: string, | |
| ⋯ | |||
| 157 | 185 | }, | |
| 158 | 186 | keywords: ["git", "import", "mirror", "sign in"], | |
| 159 | 187 | }, | |
| 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"]), | |
| 160 | 189 | { | |
| 161 | 190 | id: "webhooks", | |
| 162 | 191 | name: "Webhooks", | |
| ⋯ | |||
| 198 | 227 | }, | |
| 199 | 228 | keywords: ["tracker", "tickets"], | |
| 200 | 229 | }, | |
| 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"]), | |
| 201 | 231 | { | |
| 202 | 232 | id: "jira", | |
| 203 | 233 | name: "Jira", | |
| ⋯ | |||
| 210 | 240 | provider: "jira", | |
| 211 | 241 | keywords: ["atlassian", "tracker", "tickets"], | |
| 212 | 242 | }, | |
| 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"]), | |
| 217 | 244 | soon("shortcut", "Shortcut", "issues", W, "Stories agents read when work mentions them, updated when the work lands.", ["Agents can read", "Writes back"]), | |
| 218 | 245 | soon("clickup", "ClickUp", "issues", W, "Tasks agents can read and import, kept in step with their pull requests.", ["Agents can read", "Writes back"]), | |
| 219 | 246 | soon("trello", "Trello", "issues", W, "Cards become issues, and move along their board as the work does.", ["Opens issues", "Writes back"]), | |
| ⋯ | |||
| 241 | 268 | 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"]), | |
| 242 | 269 | ||
| 243 | 270 | // 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"]), | |
| 249 | 273 | soon("google-docs", "Google Docs", "docs", BOTH, "Agents read the specs and plans linked from issues and chat.", ["Agents can read"], { | |
| 250 | 274 | description: "Docs you share in chat open for agents as well as people.", | |
| 251 | 275 | capabilities: ["Agents can read"], | |
| 252 | 276 | }), | |
| 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"]), | |
| 253 | 279 | soon("coda", "Coda", "docs", W, "Agents read the docs and tables linked from issues and chat.", ["Agents can read"]), | |
| 254 | 280 | ||
| 255 | 281 | // Monitoring & incidents ------------------------------------------------ | |
| ⋯ | |||
| 289 | 315 | provider: "webhook", | |
| 290 | 316 | keywords: ["incoming", "http", "alerts", "pagerduty", "grafana"], | |
| 291 | 317 | }, | |
| 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"]), | |
| 292 | 319 | soon("pagerduty", "PagerDuty", "monitoring", BOTH, "Incidents open issues an agent can start on, and resolve when the fix lands.", ["Opens issues", "Writes back"], { | |
| 293 | 320 | description: "Sets your status to On call while you are on call.", | |
| 294 | 321 | capabilities: ["Sets your status"], | |
| ⋯ | |||
| 301 | 328 | soon("better-stack", "Better Stack", "monitoring", W, "Uptime incidents and log alerts open issues.", ["Opens issues"]), | |
| 302 | 329 | ||
| 303 | 330 | // 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"]), | |
| 305 | 333 | soon("aws", "Amazon Web Services", "cloud", W, "Workflows and deployments reach AWS with short-lived credentials, no stored keys.", ["Deploys"]), | |
| 306 | 334 | soon("google-cloud", "Google Cloud", "cloud", W, "Workflows and deployments reach Google Cloud with short-lived credentials.", ["Deploys"]), | |
| 307 | 335 | 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"]), | |
| 309 | 337 | soon("netlify", "Netlify", "cloud", W, "Deploy previews as checks on pull requests.", ["Deploys", "Agents can read"]), | |
| 310 | 338 | soon("fly-io", "Fly.io", "cloud", W, "Deploy apps from workflows, and roll back from the deployment's page.", ["Deploys"]), | |
| 311 | 339 | soon("kubernetes", "Kubernetes", "cloud", W, "Roll out to your own clusters from workflows, with each rollout's status on g1t.", ["Deploys"]), | |
| ⋯ | |||
| 349 | 377 | }, | |
| 350 | 378 | soon("amazon-bedrock", "Amazon Bedrock", "ai", W, "Models in your own AWS account, billed there.", ["Runs models"]), | |
| 351 | 379 | 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"]), | |
| 353 | 384 | ||
| 354 | 385 | // Data & analytics ------------------------------------------------------ | |
| 355 | 386 | soon("postgresql", "PostgreSQL", "data", W, "Agents read your schema, and run read-only queries while they investigate.", ["Agents can read"]), | |
| 356 | 387 | soon("snowflake", "Snowflake", "data", W, "Agents answer questions from your warehouse with read-only queries.", ["Agents can read"]), | |
| 357 | 388 | 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"]), | |
| 359 | 392 | soon("amplitude", "Amplitude", "data", W, "Agents read the charts that work refers to.", ["Agents can read"]), | |
| 360 | 393 | soon("segment", "Segment", "data", W, "Agents read your tracking plan, and check new events against it.", ["Agents can read"]), | |
| 361 | 394 | ||
| 362 | 395 | // CRM & support --------------------------------------------------------- | |
| 363 | 396 | 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"]), | |
| 366 | 399 | 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"]), | |
| 368 | 402 | ||
| 369 | 403 | // 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"]), | |
| 374 | 406 | soon("miro", "Miro", "design", W, "Agents read the boards linked from issues and chat.", ["Agents can read"]), | |
| 375 | 407 | soon("storybook", "Storybook", "design", W, "Each pull request's stories as a check, with what changed.", ["Agents can read"]), | |
| 376 | 408 | ||
| ⋯ | |||
| 422 | 454 | provider: connector.provider ?? null, | |
| 423 | 455 | keywords: connector.keywords ?? [], | |
| 424 | 456 | alsoIn: connector.scopes.includes(other) ? other : null, | |
| 457 | + | mcp: connector.mcp ?? null, | |
| 425 | 458 | }; | |
| 426 | 459 | } | |
| 427 | 460 | ||
| 3 | 3 | ||
| 4 | 4 | import { CUSTOMIZE, CUSTOMIZE_SECTIONS, customizePath, sectionOfListing } from "./customize.ts"; | |
| 5 | 5 | ||
| 6 | − | test("every Marketplace listing has a place in Customize", () => { | |
| 6 | + | test("every listing has a place in Customize", () => { | |
| 7 | 7 | assert.equal(sectionOfListing("integration"), "connectors"); | |
| 8 | + | assert.equal(sectionOfListing("connector"), "connectors"); | |
| 9 | + | assert.equal(sectionOfListing("skill"), "skills"); | |
| 10 | + | assert.equal(sectionOfListing("plugin"), "plugins"); | |
| 8 | 11 | assert.equal(sectionOfListing("extension"), "plugins"); | |
| 9 | 12 | for (const section of CUSTOMIZE_SECTIONS) assert.equal(CUSTOMIZE[section].id, section); | |
| 10 | 13 | }); | |
| 11 | 14 | ||
| 12 | 15 | 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"); | |
| 15 | 20 | }); |
| 1 | 1 | /** | |
| 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. | |
| 5 | 6 | * | |
| 6 | 7 | * - **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. | |
| 14 | 17 | * | |
| 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. | |
| 18 | 22 | */ | |
| 19 | 23 | ||
| 24 | + | import type { FetchPlan, MarketplaceSource, McpServerConfig, PluginLayout, SubagentFile } from "./claude-plugin"; | |
| 25 | + | import type { McpTool } from "./abilities"; | |
| 26 | + | import type { ConnectorCategory } from "./connectors"; | |
| 20 | 27 | import type { ListingKind } from "./marketplace"; | |
| 28 | + | import type { SkillScope } from "./skill-library"; | |
| 21 | 29 | ||
| 22 | 30 | export type CustomizeSection = "skills" | "connectors" | "plugins"; | |
| 23 | 31 | ||
| ⋯ | |||
| 28 | 36 | title: string; | |
| 29 | 37 | /** One line, for the section's row. */ | |
| 30 | 38 | 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. */ | |
| 32 | 40 | sitePath: string; | |
| 33 | 41 | }; | |
| 34 | 42 | ||
| ⋯ | |||
| 36 | 44 | skills: { | |
| 37 | 45 | id: "skills", | |
| 38 | 46 | 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", | |
| 41 | 49 | }, | |
| 42 | 50 | connectors: { | |
| 43 | 51 | id: "connectors", | |
| 44 | 52 | 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", | |
| 47 | 55 | }, | |
| 48 | 56 | plugins: { | |
| 49 | 57 | id: "plugins", | |
| 50 | 58 | 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", | |
| 53 | 61 | }, | |
| 54 | 62 | }; | |
| 55 | 63 | ||
| 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. */ | |
| 57 | 65 | 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}` : ""}`; | |
| 59 | 75 | } | |
| 60 | 76 | ||
| 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 | + | }; | |
| 64 | 330 | } | |
| 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; | |
| 94 | 94 | client.restore(ws, viewer, f), | |
| 95 | 95 | client.delete(ws, viewer, f), | |
| 96 | 96 | client.trashed(ws, viewer), | |
| 97 | + | client.emptyTrash(ws, viewer), | |
| 98 | + | client.deleteSpace(ws, viewer, "spc_1"), | |
| 97 | 99 | client.favorite(ws, viewer, f, true), | |
| 98 | 100 | client.content(ws, viewer, f), | |
| 99 | 101 | client.edit(ws, viewer, f, { kind: "doc", target: { kind: "append" }, markdown: "x" }), |
| 1 | 1 | const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; | |
| 2 | 2 | ||
| 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"; | |
| 4 | 4 | ||
| 5 | 5 | let lastMs = 0; | |
| 6 | 6 | let lastCounter = 0; |
| 15 | 15 | export * from "./compute"; | |
| 16 | 16 | export * from "./connectors"; | |
| 17 | 17 | export * from "./context"; | |
| 18 | + | export * from "./claude-plugin"; | |
| 18 | 19 | export * from "./customize"; | |
| 19 | 20 | export * from "./d1"; | |
| 20 | 21 | export * from "./datasets"; |
| 28 | 28 | * test it under Node. | |
| 29 | 29 | */ | |
| 30 | 30 | ||
| 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"; | |
| 33 | 39 | ||
| 34 | − | export const LISTING_KINDS: readonly ListingKind[] = ["integration", "extension"]; | |
| 40 | + | export const LISTING_KINDS: readonly ListingKind[] = ["integration", "extension", "plugin", "skill", "connector"]; | |
| 35 | 41 | ||
| 36 | 42 | /** Whether a listing can be added today, or is planned. */ | |
| 37 | 43 | export type ListingStatus = "available" | "soon"; | |
| ⋯ | |||
| 386 | 392 | /** A reference split into kind and id; null when it is not one. */ | |
| 387 | 393 | export function parseListing(value: unknown): ListingRef | null { | |
| 388 | 394 | 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]! }; | |
| 390 | 397 | return match ? { kind: match[1] as ListingKind, id: match[2]! } : null; | |
| 391 | 398 | } | |
| 392 | 399 | ||
| 33 | 33 | export const SKILL_NAME_MAX = 64; | |
| 34 | 34 | /** The longest description ("when to use it"). */ | |
| 35 | 35 | 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"]; | |
| 38 | 43 | ||
| 39 | 44 | /** | |
| 40 | 45 | * Names the library can't use: g1t's foundational skills', so `use_skill` | |
| ⋯ | |||
| 296 | 301 | return null; | |
| 297 | 302 | } | |
| 298 | 303 | ||
| 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 | + | ||
| 299 | 312 | /** Whether `path` is a plain relative path inside the folder. */ | |
| 300 | 313 | export function skillPathProblem(path: string): string | null { | |
| 301 | 314 | if (!path || path.length > 255) return `${path || "A file"} isn't a path a skill can hold.`; | |
| ⋯ | |||
| 334 | 347 | * files and `SKILL_FOLDER_MAX_BYTES` together. `expectName` is the | |
| 335 | 348 | * folder's name when it must match (a repository's `.g1t/skills/<name>/`). | |
| 336 | 349 | */ | |
| 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 { | |
| 338 | 366 | const bad = (message: string): SkillCheck => ({ ok: false, message }); | |
| 339 | 367 | 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}.`); | |
| 341 | 370 | const seen = new Set<string>(); | |
| 342 | 371 | let skillMd: SkillFile | null = null; | |
| 343 | 372 | const files: SkillFile[] = []; | |
| ⋯ | |||
| 357 | 386 | skillMd = { ...file, path: "SKILL.md" }; | |
| 358 | 387 | } else files.push(file); | |
| 359 | 388 | } | |
| 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)}.`); | |
| 361 | 390 | if (!skillMd) { | |
| 362 | 391 | const nested = files.find((f) => f.path.toLowerCase().endsWith("/skill.md")); | |
| 363 | 392 | 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."); | |
| ⋯ | |||
| 370 | 399 | } catch (error) { | |
| 371 | 400 | return bad(`SKILL.md's front-matter can't be read: ${error instanceof Error ? error.message : String(error)}.`); | |
| 372 | 401 | } | |
| 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); | |
| 375 | 404 | if (nameProblem) return bad(name ? nameProblem : "SKILL.md's front-matter needs a name: the skill's name, like release-notes."); | |
| 376 | 405 | if (options.expectName && options.expectName !== name) return bad(`The folder is ${options.expectName}, but its SKILL.md is named ${name}. They must match.`); | |
| 377 | 406 | const description = typeof front.description === "string" ? front.description.replace(/\s+/g, " ").trim() : ""; | |
| ⋯ | |||
| 379 | 408 | if (description.length > SKILL_DESCRIPTION_MAX) return bad(`A skill's description is at most ${SKILL_DESCRIPTION_MAX} characters.`); | |
| 380 | 409 | const listed = listOf(front.tools); | |
| 381 | 410 | 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.`); | |
| 385 | 415 | const flag = front.requires_computer; | |
| 386 | 416 | if (flag !== undefined && flag !== null && typeof flag !== "boolean") return bad("requires_computer: is true or false."); | |
| 387 | 417 | const body = split.body.trim(); | |
| ⋯ | |||
| 389 | 419 | const scripts = files.filter((f) => f.path.startsWith("scripts/")).map((f) => f.path); | |
| 390 | 420 | const extra: Record<string, unknown> = {}; | |
| 391 | 421 | 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; | |
| 392 | 423 | files.sort((a, b) => a.path.localeCompare(b.path)); | |
| 393 | 424 | return { | |
| 394 | 425 | ok: true, | |
| 51 | 51 | | { kind: "repository"; repo: string; path: string; ref: string; commit: string } | |
| 52 | 52 | /** Drafted by an agent from a finished session. */ | |
| 53 | 53 | | { 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 }; | |
| 56 | 58 | ||
| 57 | 59 | /** A draft is waiting for a person to review it; only published skills can be attached. */ | |
| 58 | 60 | export type SkillStatus = "published" | "draft"; | |
| ⋯ | |||
| 60 | 62 | export type LibrarySkill = { | |
| 61 | 63 | id: string; | |
| 62 | 64 | 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; | |
| 63 | 69 | /** When to use it. */ | |
| 64 | 70 | description: string; | |
| 65 | 71 | status: SkillStatus; | |
| ⋯ | |||
| 181 | 187 | /** A foundational skill's id, or a library skill's. */ | |
| 182 | 188 | id: string; | |
| 183 | 189 | name: string; | |
| 190 | + | /** The plugin it came with, or null. */ | |
| 191 | + | plugin: string | null; | |
| 184 | 192 | description: string; | |
| 185 | 193 | foundational: boolean; | |
| 186 | 194 | /** Whether it is on for this agent (owners turn skills off per agent). */ | |
| 14 | 14 | // Model tiers (`ModelTier`, `MODEL_TIERS`) are integrations.ts's, the | |
| 15 | 15 | // same ones runs are routed between. | |
| 16 | 16 | import type { ModelTier } from "./integrations"; | |
| 17 | − | import type { ExtensionInstall, InstallRequest, InstallRequestStatus, InstallRequests } from "./marketplace"; | |
| 17 | + | import type { InstallRequest, InstallRequestStatus, InstallRequests } from "./marketplace"; | |
| 18 | 18 | 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"; | |
| 19 | 21 | import type { AgentLook } from "./agent-look"; | |
| 20 | 22 | import type { AgentComputerCommand, AgentComputerStatus } from "./runner"; | |
| 21 | 23 | ||
| ⋯ | |||
| 1041 | 1043 | /** An owner marks a request added (`done`) or turns it down (`declined`); whoever asked is told. */ | |
| 1042 | 1044 | resolveInstallRequest(workspace: string, viewer: User, id: string, status: Exclude<InstallRequestStatus, "open">): Promise<Result<InstallRequest>>; | |
| 1043 | 1045 | /** 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>>; | |
| 1045 | 1074 | /** 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>>; | |
| 1047 | 1075 | /** 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>>; | |
| 1049 | 1076 | /** 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>>; | |
| 1052 | 1077 | }; | |
| 1053 | 1078 | ||
| 1054 | 1079 | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { | |
| ⋯ | |||
| 1128 | 1153 | installRequests: (workspace, viewer) => call("install_requests", { workspace, viewer }), | |
| 1129 | 1154 | requestInstall: (workspace, viewer, listing, note) => call("request_install", { workspace, viewer, listing, note: note ?? null }), | |
| 1130 | 1155 | 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 }), | |
| 1136 | 1180 | }; | |
| 1137 | 1181 | } | |
| 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); |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.