Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 1 | /** |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 2 | * Deprecated: g1t no longer runs agents of its own. Kept only until |
| 3 | * apps/mobile drops its Customize screens, which import it; delete it | |
| 4 | * afterwards. | |
| 5 | */ | |
| 6 | /** | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 7 | * The open plugin formats (docs.g1t.sh/guides/customize/, "The formats"): |
| 8 | * what Claude Code reads, read here the same way, so a marketplace, a | |
| 9 | * plugin, a skill, a subagent or an MCP server published anywhere installs | |
| 10 | * on g1t as it is. g1t adds no keys of its own and ignores what it doesn't | |
| 11 | * know, as Claude Code does. | |
| 12 | * | |
| 13 | * - A **marketplace** is a repository (or a URL) holding | |
| 14 | * `.claude-plugin/marketplace.json`: a name, an owner and the plugins it | |
| 15 | * lists, each with a `source` saying where the plugin's files are. | |
| 16 | * - A **plugin** is a folder with an optional `.claude-plugin/plugin.json` | |
| 17 | * and a known layout: `skills/<name>/SKILL.md`, `commands/*.md`, | |
| 18 | * `agents/*.md`, `hooks/hooks.json`, `.mcp.json`, `.lsp.json`, `bin/`. | |
| 19 | * - A **subagent** is `agents/<name>.md`: front-matter (name, description, | |
| 20 | * tools, model, skills…) and the system prompt as its body. | |
| 21 | * - `.mcp.json` names MCP servers: `{ mcpServers: { name: { type, url… } } }`. | |
| 22 | * | |
| 23 | * Pure and standalone (no imports), so services, the site, the phone and | |
| 24 | * Node's tests use it as it is. A subagent file's front-matter is split | |
| 25 | * and parsed with ./skill-format.ts's functions, then read here. | |
| 26 | */ | |
| 27 | ||
| 28 | export type Checked<T> = { ok: true; value: T } | { ok: false; message: string }; | |
| 29 | ||
| 30 | const bad = (message: string): { ok: false; message: string } => ({ ok: false, message }); | |
| 31 | ||
| 32 | // ── Names ──────────────────────────────────────────────────────────────── | |
| 33 | ||
| 34 | /** A marketplace's name: letters, digits, `.`, `_` and `-`, starting with a letter or digit, no `..`. */ | |
| 35 | export function marketplaceNameProblem(name: unknown): string | null { | |
| 36 | if (typeof name !== "string" || !name) return "A marketplace has a name."; | |
| 37 | if (name.length > 64) return "A marketplace's name is at most 64 characters."; | |
| 38 | 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."; | |
| 39 | return null; | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Names Claude Code keeps for its own marketplaces, which g1t keeps the | |
| 44 | * same way: a workspace can't add a marketplace under one of them, so | |
| 45 | * `plugin@claude-plugins-official` always means the real thing. | |
| 46 | */ | |
| 47 | export const RESERVED_MARKETPLACE_NAMES: readonly string[] = [ | |
| 48 | "claude-code-marketplace", | |
| 49 | "claude-code-plugins", | |
| 50 | "claude-plugins-official", | |
| 51 | "anthropic-marketplace", | |
| 52 | "anthropic-plugins", | |
| 53 | "agent-skills", | |
| 54 | "anthropic-agent-skills", | |
| 55 | "life-sciences", | |
| 56 | "knowledge-work-plugins", | |
| 57 | "claude-for-legal", | |
| 58 | "claude-for-financial-services", | |
| 59 | "financial-services-plugins", | |
| 60 | "first-party-plugins", | |
| 61 | "claude-tag-plugins", | |
| 62 | "claude-community", | |
| 63 | "claude-plugins-community", | |
| 64 | "healthcare", | |
| 65 | "inline", | |
| 66 | "builtin", | |
| 67 | "skills-dir", | |
| 68 | "synced", | |
| 69 | "npm", | |
| 70 | "pip", | |
| 71 | "uv", | |
| 72 | "cargo", | |
| 73 | "github", | |
| 74 | "gh", | |
| 75 | "g1t", | |
| 76 | ]; | |
| 77 | ||
| 78 | /** Whether a marketplace name is reserved (the list above, and anything starting `claudeai-`). */ | |
| 79 | export function isReservedMarketplaceName(name: string): boolean { | |
| 80 | const lower = name.toLowerCase(); | |
| 81 | return RESERVED_MARKETPLACE_NAMES.includes(lower) || lower.startsWith("claudeai-"); | |
| 82 | } | |
| 83 | ||
| 84 | /** A plugin's name: kebab-case, no spaces, `@`, `:` or path separators. */ | |
| 85 | export function pluginNameProblem(name: unknown): string | null { | |
| 86 | if (typeof name !== "string" || !name) return "A plugin has a name."; | |
| 87 | if (name.length > 64) return "A plugin's name is at most 64 characters."; | |
| 88 | 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."; | |
| 89 | return null; | |
| 90 | } | |
| 91 | ||
| 92 | /** `name@marketplace`: how a plugin is named everywhere (settings, installs, requests). */ | |
| 93 | export function pluginRef(name: string, marketplace: string): string { | |
| 94 | return `${name}@${marketplace}`; | |
| 95 | } | |
| 96 | ||
| 97 | /** `name@marketplace` split; null when it isn't one. */ | |
| 98 | export function parsePluginRef(value: unknown): { name: string; marketplace: string } | null { | |
| 99 | if (typeof value !== "string") return null; | |
| 100 | const at = value.lastIndexOf("@"); | |
| 101 | if (at <= 0 || at === value.length - 1) return null; | |
| 102 | const name = value.slice(0, at); | |
| 103 | const marketplace = value.slice(at + 1); | |
| 104 | if (pluginNameProblem(name) || marketplaceNameProblem(marketplace)) return null; | |
| 105 | return { name, marketplace }; | |
| 106 | } | |
| 107 | ||
| 108 | /** A plugin's component as it is named to agents and people: `plugin:skill`. */ | |
| 109 | export function namespaced(plugin: string, component: string): string { | |
| 110 | return `${plugin}:${component}`; | |
| 111 | } | |
| 112 | ||
| 113 | // ── Sources ────────────────────────────────────────────────────────────── | |
| 114 | ||
| 115 | /** Where a plugin's files are, as a marketplace entry says (Claude Code's `source`). */ | |
| 116 | export type PluginSource = | |
| 117 | | { source: "path"; path: string } | |
| 118 | | { source: "github"; repo: string; ref?: string; sha?: string } | |
| 119 | | { source: "url"; url: string; ref?: string; sha?: string } | |
| 120 | | { source: "git-subdir"; url: string; path: string; ref?: string; sha?: string } | |
| 121 | | { source: "npm"; package: string; version?: string; registry?: string } | |
| 122 | | { source: "archive"; url: string; sha256?: string } | |
| 123 | | { source: "command"; command: string; timeout?: number; mode?: "copy" | "link" }; | |
| 124 | ||
| 125 | /** | |
| 126 | * Where a marketplace is: a GitHub repository, a git URL, a direct link to | |
| 127 | * its `marketplace.json`, a repository on this g1t, or one g1t builds in. | |
| 128 | */ | |
| 129 | export type MarketplaceSource = | |
| 130 | | { source: "github"; repo: string; ref?: string } | |
| 131 | | { source: "git"; url: string; ref?: string } | |
| 132 | | { source: "url"; url: string } | |
| 133 | | { source: "g1t"; repo: string; ref?: string } | |
| 134 | | { source: "builtin"; id: string }; | |
| 135 | ||
| 136 | const str = (value: unknown): string | undefined => (typeof value === "string" && value.trim() ? value.trim() : undefined); | |
| 137 | const sha40 = (value: unknown): string | undefined => (typeof value === "string" && /^[0-9a-f]{40}$/i.test(value) ? value.toLowerCase() : undefined); | |
| 138 | ||
| 139 | /** `owner/repo` as GitHub names it; null otherwise. */ | |
| 140 | export function githubRepoProblem(repo: unknown): string | null { | |
| 141 | if (typeof repo !== "string") return "Name the repository as owner/repo."; | |
| 142 | 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."; | |
| 143 | } | |
| 144 | ||
| 145 | /** A plugin entry's `source`, read: a relative path, or an object naming where its files are. */ | |
| 146 | export function parsePluginSource(raw: unknown): Checked<PluginSource> { | |
| 147 | if (typeof raw === "string") { | |
| 148 | if (!raw.startsWith("./") || raw.includes("..")) return bad("A plugin's path starts with ./ and stays inside the marketplace."); | |
| 149 | return { ok: true, value: { source: "path", path: raw.replace(/\/+$/, "") } }; | |
| 150 | } | |
| 151 | if (!raw || typeof raw !== "object") return bad("A plugin's source is a path or an object."); | |
| 152 | const s = raw as Record<string, unknown>; | |
| 153 | switch (s.source) { | |
| 154 | case "github": { | |
| 155 | const problem = githubRepoProblem(s.repo); | |
| 156 | if (problem) return bad(problem); | |
| 157 | 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) } : {}) } }; | |
| 158 | } | |
| 159 | case "url": { | |
| 160 | const url = str(s.url); | |
| 161 | if (!url || !/^https?:\/\//.test(url)) return bad("A url source is an https address."); | |
| 162 | return { ok: true, value: { source: "url", url, ...(str(s.ref) ? { ref: str(s.ref) } : {}), ...(sha40(s.sha) ? { sha: sha40(s.sha) } : {}) } }; | |
| 163 | } | |
| 164 | case "git-subdir": { | |
| 165 | const url = str(s.url); | |
| 166 | const path = str(s.path); | |
| 167 | if (!url) return bad("A git-subdir source names the repository's url."); | |
| 168 | if (!path || path.startsWith("/") || path.includes("..")) return bad("A git-subdir source names a folder inside the repository."); | |
| 169 | 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) } : {}) } }; | |
| 170 | } | |
| 171 | case "npm": { | |
| 172 | const pkg = str(s.package); | |
| 173 | if (!pkg) return bad("An npm source names the package."); | |
| 174 | return { ok: true, value: { source: "npm", package: pkg, ...(str(s.version) ? { version: str(s.version) } : {}), ...(str(s.registry) ? { registry: str(s.registry) } : {}) } }; | |
| 175 | } | |
| 176 | case "archive": { | |
| 177 | const url = str(s.url); | |
| 178 | if (!url || !/^https:\/\//.test(url)) return bad("An archive source is an https address of a zip file."); | |
| 179 | return { ok: true, value: { source: "archive", url, ...(typeof s.sha256 === "string" && /^[0-9a-f]{64}$/i.test(s.sha256) ? { sha256: s.sha256.toLowerCase() } : {}) } }; | |
| 180 | } | |
| 181 | case "command": { | |
| 182 | const command = str(s.command); | |
| 183 | if (!command) return bad("A command source says the command."); | |
| 184 | return { ok: true, value: { source: "command", command, ...(typeof s.timeout === "number" ? { timeout: s.timeout } : {}), ...(s.mode === "link" ? { mode: "link" as const } : {}) } }; | |
| 185 | } | |
| 186 | default: | |
| 187 | return bad(`g1t doesn't know the plugin source "${String(s.source)}".`); | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | /** | |
| 192 | * A GitHub repository from the ways people write one: `owner/repo`, | |
| 193 | * `owner/repo#ref`, `owner/repo@ref`, or any github.com address (with or | |
| 194 | * without `.git`, with a `/tree/<ref>` path). Null when it isn't one. | |
| 195 | */ | |
| 196 | export function githubRepoOf(text: string): { repo: string; ref?: string; path?: string } | null { | |
| 197 | const value = text.trim(); | |
| 198 | const short = /^([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+?)(?:\.git)?(?:[#@]([^\s#@]+))?$/.exec(value); | |
| 199 | if (short && !value.includes("://") && !value.includes(":")) { | |
| 200 | return { repo: short[1]!, ...(short[2] ? { ref: short[2] } : {}) }; | |
| 201 | } | |
| 202 | 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); | |
| 203 | if (!web) return null; | |
| 204 | const ref = web[2] ?? web[4]; | |
| 205 | return { repo: web[1]!, ...(ref ? { ref } : {}), ...(web[3] ? { path: web[3].replace(/\/+$/, "") } : {}) }; | |
| 206 | } | |
| 207 | ||
| 208 | /** | |
| 209 | * A marketplace as someone types it into **Add a marketplace**, the forms | |
| 210 | * Claude Code's `plugin marketplace add` takes: `owner/repo` (GitHub), a | |
| 211 | * github.com or git address, a direct link to `marketplace.json`, or, on | |
| 212 | * g1t, a repository here as `workspace/name` when `here` says that name is | |
| 213 | * one of this g1t's. | |
| 214 | */ | |
| 215 | export function parseMarketplaceAddress(text: string, options: { here?: (repo: string) => boolean; host?: string } = {}): Checked<MarketplaceSource> { | |
| 216 | const value = text.trim(); | |
| 217 | if (!value) return bad("Say where the marketplace is: owner/repo on GitHub, a repository here, or a link."); | |
| 218 | if (/^https?:\/\/.*\/marketplace\.json(\?.*)?$/i.test(value)) return { ok: true, value: { source: "url", url: value } }; | |
| 219 | if (options.host) { | |
| 220 | 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); | |
| 221 | if (own) return { ok: true, value: { source: "g1t", repo: own[1]!, ...(own[2] ? { ref: own[2] } : {}) } }; | |
| 222 | } | |
| 223 | const github = githubRepoOf(value); | |
| 224 | if (github && !value.includes("://") && options.here?.(github.repo)) return { ok: true, value: { source: "g1t", repo: github.repo, ...(github.ref ? { ref: github.ref } : {}) } }; | |
| 225 | if (github) return { ok: true, value: { source: "github", repo: github.repo, ...(github.ref ? { ref: github.ref } : {}) } }; | |
| 226 | if (/^(https?:\/\/|git@|ssh:\/\/)/.test(value)) { | |
| 227 | const ref = /[#]([^\s#]+)$/.exec(value)?.[1]; | |
| 228 | return { ok: true, value: { source: "git", url: value.replace(/#[^\s#]+$/, ""), ...(ref ? { ref } : {}) } }; | |
| 229 | } | |
| 230 | 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."); | |
| 231 | } | |
| 232 | ||
| 233 | // ── marketplace.json ───────────────────────────────────────────────────── | |
| 234 | ||
| 235 | /** One plugin as a marketplace lists it. Any plugin.json field may appear here too. */ | |
| 236 | export type MarketplacePluginEntry = { | |
| 237 | name: string; | |
| 238 | source: PluginSource; | |
| 239 | description: string; | |
| 240 | version: string | null; | |
| 241 | author: { name: string; email?: string; url?: string } | null; | |
| 242 | homepage: string | null; | |
| 243 | repository: string | null; | |
| 244 | license: string | null; | |
| 245 | keywords: string[]; | |
| 246 | category: string | null; | |
| 247 | tags: string[]; | |
| 248 | /** Entry-level component paths (added to the manifest's when `strict`). `skills` may be `.`. */ | |
| 249 | skills: string[]; | |
| 250 | commands: string[]; | |
| 251 | agents: string[]; | |
| 252 | /** Entry-level MCP servers, only for plugins without a plugin.json. */ | |
| 253 | mcpServers: Record<string, unknown> | null; | |
| 254 | /** Default true: the entry's components are added to the manifest's. */ | |
| 255 | strict: boolean; | |
| 256 | displayName: string | null; | |
| 257 | /** Whatever else the entry carried, kept as it was. */ | |
| 258 | extra: Record<string, unknown>; | |
| 259 | }; | |
| 260 | ||
| 261 | export type MarketplaceManifest = { | |
| 262 | name: string; | |
| 263 | owner: { name: string; email?: string; url?: string }; | |
| 264 | description: string | null; | |
| 265 | version: string | null; | |
| 266 | /** Bare plugin names resolve under this folder. */ | |
| 267 | pluginRoot: string | null; | |
| 268 | plugins: MarketplacePluginEntry[]; | |
| 269 | /** Entries that couldn't be read, by index, with why. */ | |
| 270 | problems: string[]; | |
| 271 | }; | |
| 272 | ||
| 273 | 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()] : []); | |
| 274 | ||
| 275 | function person(value: unknown): { name: string; email?: string; url?: string } | null { | |
| 276 | if (typeof value === "string") return value.trim() ? { name: value.trim() } : null; | |
| 277 | if (!value || typeof value !== "object") return null; | |
| 278 | const p = value as Record<string, unknown>; | |
| 279 | const name = str(p.name); | |
| 280 | if (!name) return null; | |
| 281 | return { name, ...(str(p.email) ? { email: str(p.email) } : {}), ...(str(p.url) ? { url: str(p.url) } : {}) }; | |
| 282 | } | |
| 283 | ||
| 284 | /** Component paths as a manifest or entry gives them: strings or a list, `./` kept off, `.` meaning the root. */ | |
| 285 | function paths(value: unknown): string[] { | |
| 286 | return strList(value).map((p) => (p === "." ? "." : p.replace(/^\.\//, "").replace(/\/+$/, ""))).filter((p) => p && !p.startsWith("/") && !p.split("/").includes("..")); | |
| 287 | } | |
| 288 | ||
| 289 | const ENTRY_KEYS = ["name", "source", "description", "version", "author", "homepage", "repository", "license", "keywords", "category", "tags", "skills", "commands", "agents", "mcpServers", "strict", "displayName"]; | |
| 290 | ||
| 291 | /** A plugin entry read, or why it can't be. */ | |
| 292 | export function parseMarketplaceEntry(raw: unknown): Checked<MarketplacePluginEntry> { | |
| 293 | if (!raw || typeof raw !== "object") return bad("A plugin entry is an object."); | |
| 294 | const e = raw as Record<string, unknown>; | |
| 295 | const nameProblem = pluginNameProblem(e.name); | |
| 296 | if (nameProblem) return bad(nameProblem); | |
| 297 | if (e.source === undefined) { | |
| 298 | return bad(`${String(e.name)} has no source.`); | |
| 299 | } | |
| 300 | const source = parsePluginSource(e.source); | |
| 301 | if (!source.ok) return bad(`${String(e.name)}: ${source.message}`); | |
| 302 | const extra: Record<string, unknown> = {}; | |
| 303 | for (const [key, value] of Object.entries(e)) if (!ENTRY_KEYS.includes(key)) extra[key] = value; | |
| 304 | return { | |
| 305 | ok: true, | |
| 306 | value: { | |
| 307 | name: e.name as string, | |
| 308 | source: source.value, | |
| 309 | description: (str(e.description) ?? "").slice(0, 2000), | |
| 310 | version: str(e.version) ?? null, | |
| 311 | author: person(e.author), | |
| 312 | homepage: str(e.homepage) ?? null, | |
| 313 | repository: str(e.repository) ?? null, | |
| 314 | license: str(e.license) ?? null, | |
| 315 | keywords: strList(e.keywords).slice(0, 30), | |
| 316 | category: str(e.category)?.toLowerCase() ?? null, | |
| 317 | tags: strList(e.tags).slice(0, 30), | |
| 318 | skills: paths(e.skills), | |
| 319 | commands: paths(e.commands), | |
| 320 | agents: paths(e.agents), | |
| 321 | mcpServers: e.mcpServers && typeof e.mcpServers === "object" && !Array.isArray(e.mcpServers) ? (e.mcpServers as Record<string, unknown>) : null, | |
| 322 | strict: e.strict !== false, | |
| 323 | displayName: str(e.displayName) ?? null, | |
| 324 | extra, | |
| 325 | }, | |
| 326 | }; | |
| 327 | } | |
| 328 | ||
| 329 | /** `marketplace.json` read, its unreadable entries noted rather than refused, as Claude Code loads it. */ | |
| 330 | export function parseMarketplaceManifest(raw: unknown): Checked<MarketplaceManifest> { | |
| 331 | if (!raw || typeof raw !== "object" || Array.isArray(raw)) return bad("marketplace.json is an object with a name, an owner and plugins."); | |
| 332 | const m = raw as Record<string, unknown>; | |
| 333 | const nameProblem = marketplaceNameProblem(m.name); | |
| 334 | if (nameProblem) return bad(nameProblem); | |
| 335 | const owner = person(m.owner); | |
| 336 | if (!owner) return bad("A marketplace names its owner."); | |
| 337 | if (!Array.isArray(m.plugins)) return bad("A marketplace lists its plugins."); | |
| 338 | const metadata = m.metadata && typeof m.metadata === "object" ? (m.metadata as Record<string, unknown>) : {}; | |
| 339 | const plugins: MarketplacePluginEntry[] = []; | |
| 340 | const problems: string[] = []; | |
| 341 | const names = new Set<string>(); | |
| 342 | m.plugins.forEach((entry, i) => { | |
| 343 | const read = parseMarketplaceEntry(entry); | |
| 344 | if (!read.ok) return problems.push(`Plugin ${i + 1}: ${read.message}`); | |
| 345 | if (names.has(read.value.name)) return problems.push(`Plugin ${i + 1}: ${read.value.name} is listed twice.`); | |
| 346 | names.add(read.value.name); | |
| 347 | plugins.push(read.value); | |
| 348 | }); | |
| 349 | const pluginRoot = str(metadata.pluginRoot); | |
| 350 | return { | |
| 351 | ok: true, | |
| 352 | value: { | |
| 353 | name: m.name as string, | |
| 354 | owner, | |
| 355 | description: str(m.description) ?? str(metadata.description) ?? null, | |
| 356 | version: str(m.version) ?? str(metadata.version) ?? null, | |
| 357 | pluginRoot: pluginRoot ? pluginRoot.replace(/^\.\//, "").replace(/\/+$/, "") : null, | |
| 358 | plugins, | |
| 359 | problems, | |
| 360 | }, | |
| 361 | }; | |
| 362 | } | |
| 363 | ||
| 364 | // ── plugin.json ────────────────────────────────────────────────────────── | |
| 365 | ||
| 366 | export type PluginManifest = { | |
| 367 | name: string; | |
| 368 | displayName: string | null; | |
| 369 | version: string | null; | |
| 370 | description: string; | |
| 371 | author: { name: string; email?: string; url?: string } | null; | |
| 372 | homepage: string | null; | |
| 373 | repository: string | null; | |
| 374 | license: string | null; | |
| 375 | keywords: string[]; | |
| 376 | /** Component paths besides the standard folders, as given (`./` kept off; `.` the root). */ | |
| 377 | skills: string[]; | |
| 378 | commands: string[]; | |
| 379 | agents: string[]; | |
| 380 | hooks: string[]; | |
| 381 | /** MCP servers given in the manifest itself (the same shape as .mcp.json's `mcpServers`), or a path to a file. */ | |
| 382 | mcpServers: Record<string, unknown> | string | null; | |
| 383 | lspServers: boolean; | |
| 384 | extra: Record<string, unknown>; | |
| 385 | }; | |
| 386 | ||
| 387 | const MANIFEST_KEYS = ["$schema", "name", "displayName", "version", "description", "author", "homepage", "repository", "license", "keywords", "skills", "commands", "agents", "hooks", "mcpServers", "lspServers"]; | |
| 388 | ||
| 389 | /** `.claude-plugin/plugin.json` read; only the name is required. */ | |
| 390 | export function parsePluginManifest(raw: unknown): Checked<PluginManifest> { | |
| 391 | if (!raw || typeof raw !== "object" || Array.isArray(raw)) return bad("plugin.json is an object naming the plugin."); | |
| 392 | const p = raw as Record<string, unknown>; | |
| 393 | const nameProblem = pluginNameProblem(p.name); | |
| 394 | if (nameProblem) return bad(nameProblem); | |
| 395 | const extra: Record<string, unknown> = {}; | |
| 396 | for (const [key, value] of Object.entries(p)) if (!MANIFEST_KEYS.includes(key)) extra[key] = value; | |
| 397 | const hooks = typeof p.hooks === "string" ? paths(p.hooks) : p.hooks && typeof p.hooks === "object" ? ["(inline)"] : []; | |
| 398 | return { | |
| 399 | ok: true, | |
| 400 | value: { | |
| 401 | name: p.name as string, | |
| 402 | displayName: str(p.displayName) ?? null, | |
| 403 | version: str(p.version) ?? null, | |
| 404 | description: (str(p.description) ?? "").slice(0, 2000), | |
| 405 | author: person(p.author), | |
| 406 | homepage: str(p.homepage) ?? null, | |
| 407 | repository: str(p.repository) ?? null, | |
| 408 | license: str(p.license) ?? null, | |
| 409 | keywords: strList(p.keywords).slice(0, 30), | |
| 410 | skills: paths(p.skills), | |
| 411 | commands: paths(p.commands), | |
| 412 | agents: paths(p.agents), | |
| 413 | hooks, | |
| 414 | mcpServers: typeof p.mcpServers === "string" ? p.mcpServers.replace(/^\.\//, "") : p.mcpServers && typeof p.mcpServers === "object" ? (p.mcpServers as Record<string, unknown>) : null, | |
| 415 | lspServers: !!p.lspServers, | |
| 416 | extra, | |
| 417 | }, | |
| 418 | }; | |
| 419 | } | |
| 420 | ||
| 421 | // ── .mcp.json ──────────────────────────────────────────────────────────── | |
| 422 | ||
| 423 | export type McpTransport = "http" | "sse" | "stdio" | "ws" | "sdk"; | |
| 424 | ||
| 425 | /** 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. */ | |
| 426 | export type McpServerConfig = { | |
| 427 | name: string; | |
| 428 | type: McpTransport; | |
| 429 | url: string | null; | |
| 430 | headers: Record<string, string>; | |
| 431 | command: string | null; | |
| 432 | args: string[]; | |
| 433 | env: Record<string, string>; | |
| 434 | /** The `oauth` block, when the file carries one. */ | |
| 435 | oauth: Record<string, unknown> | null; | |
| 436 | /** `${VAR}` references the config makes, by variable name, for the setup screen. */ | |
| 437 | variables: string[]; | |
| 438 | }; | |
| 439 | ||
| 440 | const VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g; | |
| 441 | ||
| 442 | function variablesIn(...values: unknown[]): string[] { | |
| 443 | const out = new Set<string>(); | |
| 444 | const walk = (value: unknown) => { | |
| 445 | if (typeof value === "string") for (const m of value.matchAll(VARIABLE)) out.add(m[1]!); | |
| 446 | else if (Array.isArray(value)) value.forEach(walk); | |
| 447 | else if (value && typeof value === "object") Object.values(value as Record<string, unknown>).forEach(walk); | |
| 448 | }; | |
| 449 | values.forEach(walk); | |
| 450 | return [...out].sort(); | |
| 451 | } | |
| 452 | ||
| 453 | const strMap = (value: unknown): Record<string, string> => { | |
| 454 | const out: Record<string, string> = {}; | |
| 455 | 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; | |
| 456 | return out; | |
| 457 | }; | |
| 458 | ||
| 459 | /** The servers of an `.mcp.json` (or a manifest's `mcpServers`), those that can't be read noted. */ | |
| 460 | export function parseMcpServers(raw: unknown): { servers: McpServerConfig[]; problems: string[] } { | |
| 461 | const servers: McpServerConfig[] = []; | |
| 462 | const problems: string[] = []; | |
| 463 | const map = raw && typeof raw === "object" && !Array.isArray(raw) ? ((raw as Record<string, unknown>).mcpServers ?? raw) : null; | |
| 464 | if (!map || typeof map !== "object" || Array.isArray(map)) return { servers, problems: ["mcpServers is an object of servers by name."] }; | |
| 465 | for (const [name, value] of Object.entries(map as Record<string, unknown>)) { | |
| 466 | if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name)) { | |
| 467 | problems.push(`${name}: a server's name is letters, digits, dots, underscores and hyphens.`); | |
| 468 | continue; | |
| 469 | } | |
| 470 | if (!value || typeof value !== "object") { | |
| 471 | problems.push(`${name}: a server is an object.`); | |
| 472 | continue; | |
| 473 | } | |
| 474 | const s = value as Record<string, unknown>; | |
| 475 | const url = str(s.url); | |
| 476 | const command = str(s.command); | |
| 477 | let type: McpTransport; | |
| 478 | const given = typeof s.type === "string" ? s.type : null; | |
| 479 | if (given === "http" || given === "streamable-http") type = "http"; | |
| 480 | else if (given === "sse" || given === "ws" || given === "sdk" || given === "stdio") type = given; | |
| 481 | else if (given === null && command) type = "stdio"; | |
| 482 | else if (given === null && url) { | |
| 483 | // Claude Code treats a url without a type as stdio and skips it; g1t says so instead. | |
| 484 | problems.push(`${name}: a remote server says its type (http or sse).`); | |
| 485 | continue; | |
| 486 | } else { | |
| 487 | problems.push(`${name}: g1t doesn't know the transport "${String(given)}".`); | |
| 488 | continue; | |
| 489 | } | |
| 490 | if ((type === "http" || type === "sse" || type === "ws") && !url) { | |
| 491 | problems.push(`${name}: a ${type} server has a url.`); | |
| 492 | continue; | |
| 493 | } | |
| 494 | if (type === "stdio" && !command) { | |
| 495 | problems.push(`${name}: a stdio server has a command.`); | |
| 496 | continue; | |
| 497 | } | |
| 498 | servers.push({ | |
| 499 | name, | |
| 500 | type, | |
| 501 | url: url ?? null, | |
| 502 | headers: strMap(s.headers), | |
| 503 | command: command ?? null, | |
| 504 | args: strList(s.args), | |
| 505 | env: strMap(s.env), | |
| 506 | oauth: s.oauth && typeof s.oauth === "object" ? (s.oauth as Record<string, unknown>) : null, | |
| 507 | variables: variablesIn(s.url, s.headers, s.command, s.args, s.env), | |
| 508 | }); | |
| 509 | } | |
| 510 | return { servers, problems }; | |
| 511 | } | |
| 512 | ||
| 513 | // ── Subagents ──────────────────────────────────────────────────────────── | |
| 514 | ||
| 515 | /** `agents/<name>.md` read: the front-matter Claude Code reads, and the system prompt. */ | |
| 516 | export type SubagentFile = { | |
| 517 | name: string; | |
| 518 | description: string; | |
| 519 | /** The tools it may use, as named in the file (Claude Code's names, or MCP tool names); empty means every tool. */ | |
| 520 | tools: string[]; | |
| 521 | disallowedTools: string[]; | |
| 522 | model: string | null; | |
| 523 | /** Skills to preload, by name. */ | |
| 524 | skills: string[]; | |
| 525 | mcpServers: string[]; | |
| 526 | color: string | null; | |
| 527 | /** The system prompt: the body after the front-matter. */ | |
| 528 | prompt: string; | |
| 529 | extra: Record<string, unknown>; | |
| 530 | }; | |
| 531 | ||
| 532 | const AGENT_KEYS = ["name", "description", "tools", "disallowedTools", "model", "skills", "mcpServers", "color"]; | |
| 533 | ||
| 534 | /** A subagent's name: at most 256 characters, no colon, not starting with a hyphen. */ | |
| 535 | export function subagentNameProblem(name: unknown): string | null { | |
| 536 | if (typeof name !== "string" || !name.trim()) return "A subagent has a name."; | |
| 537 | if (name.length > 256) return "A subagent's name is at most 256 characters."; | |
| 538 | if (name.includes(":") || name.startsWith("-")) return "A subagent's name has no colon and doesn't start with a hyphen."; | |
| 539 | return null; | |
| 540 | } | |
| 541 | ||
| 542 | /** A tools list as Claude Code writes it: a comma-separated string or a list. */ | |
| 543 | function toolList(value: unknown): string[] { | |
| 544 | if (Array.isArray(value)) return strList(value); | |
| 545 | if (typeof value !== "string") return []; | |
| 546 | return value | |
| 547 | .split(/[,\s]+/) | |
| 548 | .map((t) => t.trim()) | |
| 549 | .filter(Boolean); | |
| 550 | } | |
| 551 | ||
| 552 | /** | |
| 553 | * The file read from its parsed front-matter and body (./skill-format.ts's | |
| 554 | * `splitFrontMatter` and `parseFrontMatter` give them), or why Claude Code | |
| 555 | * would skip it. | |
| 556 | */ | |
| 557 | export function subagentOf(front: Record<string, unknown>, body: string): Checked<SubagentFile> { | |
| 558 | const nameProblem = subagentNameProblem(front.name); | |
| 559 | if (nameProblem) return bad(nameProblem); | |
| 560 | const description = str(front.description); | |
| 561 | if (!description) return bad(`${String(front.name)} says nothing about when to use it (description).`); | |
| 562 | const prompt = body.trim(); | |
| 563 | if (!prompt) return bad(`${String(front.name)} has no instructions after the front-matter.`); | |
| 564 | const extra: Record<string, unknown> = {}; | |
| 565 | for (const [key, value] of Object.entries(front)) if (!AGENT_KEYS.includes(key)) extra[key] = value; | |
| 566 | return { | |
| 567 | ok: true, | |
| 568 | value: { | |
| 569 | name: (front.name as string).trim(), | |
| 570 | description: description.slice(0, 2000), | |
| 571 | tools: toolList(front.tools), | |
| 572 | disallowedTools: toolList(front.disallowedTools), | |
| 573 | model: str(front.model) ?? null, | |
| 574 | skills: strList(front.skills), | |
| 575 | mcpServers: strList(front.mcpServers), | |
| 576 | color: str(front.color) ?? null, | |
| 577 | prompt, | |
| 578 | extra, | |
| 579 | }, | |
| 580 | }; | |
| 581 | } | |
| 582 | ||
| 583 | // ── A plugin's layout ──────────────────────────────────────────────────── | |
| 584 | ||
| 585 | /** What a plugin's folder holds, found from its file list and manifests. Paths are inside the plugin. */ | |
| 586 | export type PluginLayout = { | |
| 587 | /** Folders holding a `SKILL.md`, by path (`skills/pdf`), with the skill's folder name. */ | |
| 588 | skills: { path: string; name: string }[]; | |
| 589 | /** `commands/*.md` files: the older form of a skill, one file each. */ | |
| 590 | commands: { path: string; name: string }[]; | |
| 591 | /** `agents/**.md` files. */ | |
| 592 | agents: { path: string; name: string }[]; | |
| 593 | /** `hooks/hooks.json` (or the manifest's), when present. */ | |
| 594 | hooks: string[]; | |
| 595 | /** `.mcp.json` when present (the manifest may hold servers inline too). */ | |
| 596 | mcp: string | null; | |
| 597 | lsp: boolean; | |
| 598 | /** `bin/`: executables on the PATH where Claude Code runs; never on g1t's own agents. */ | |
| 599 | bin: boolean; | |
| 600 | outputStyles: boolean; | |
| 601 | readme: string | null; | |
| 602 | }; | |
| 603 | ||
| 604 | const mdName = (path: string): string => path.replace(/^.*\//, "").replace(/\.md$/i, ""); | |
| 605 | ||
| 606 | /** | |
| 607 | * The plugin's components from its files: the standard folders | |
| 608 | * (`skills/`, `commands/`, `agents/`) plus whatever the manifest names, | |
| 609 | * plus the marketplace entry's paths. An entry that is not strict names | |
| 610 | * its components outright instead of the standard folders, as the public | |
| 611 | * skills marketplace does (one repository, one plugin per group of | |
| 612 | * skills). `files` are paths inside the plugin. | |
| 613 | */ | |
| 614 | export function pluginLayout(files: string[], manifest: PluginManifest | null, entry: MarketplacePluginEntry | null): PluginLayout { | |
| 615 | const set = new Set(files.map((f) => f.replace(/^\.\//, ""))); | |
| 616 | const has = (path: string) => set.has(path); | |
| 617 | const under = (dir: string) => [...set].filter((f) => f.startsWith(`${dir}/`)); | |
| 618 | const skillDirs = new Set<string>(); | |
| 619 | const addSkillDir = (dir: string) => { | |
| 620 | const clean = dir === "." ? "" : dir.replace(/\/+$/, ""); | |
| 621 | if (has(clean ? `${clean}/SKILL.md` : "SKILL.md")) skillDirs.add(clean || "."); | |
| 622 | 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$/, "")); | |
| 623 | }; | |
| 624 | const standard = !(entry && !entry.strict); | |
| 625 | if (standard) addSkillDir("skills"); | |
| 626 | for (const dir of [...(manifest?.skills ?? []), ...(entry?.skills ?? [])]) addSkillDir(dir); | |
| 627 | const commandFiles = new Set<string>(); | |
| 628 | const addCommands = (path: string) => { | |
| 629 | if (/\.md$/i.test(path) && has(path)) commandFiles.add(path); | |
| 630 | else for (const f of under(path)) if (/\.md$/i.test(f)) commandFiles.add(f); | |
| 631 | }; | |
| 632 | if (standard) addCommands("commands"); | |
| 633 | for (const p of [...(manifest?.commands ?? []), ...(entry?.commands ?? [])]) addCommands(p); | |
| 634 | const agentFiles = new Set<string>(); | |
| 635 | const addAgents = (path: string) => { | |
| 636 | if (/\.md$/i.test(path) && has(path)) agentFiles.add(path); | |
| 637 | else for (const f of under(path)) if (/\.md$/i.test(f)) agentFiles.add(f); | |
| 638 | }; | |
| 639 | if (standard) addAgents("agents"); | |
| 640 | for (const p of [...(manifest?.agents ?? []), ...(entry?.agents ?? [])]) addAgents(p); | |
| 641 | const hooks = [...(has("hooks/hooks.json") ? ["hooks/hooks.json"] : []), ...(manifest?.hooks ?? []).filter((h) => h === "(inline)" || has(h))]; | |
| 642 | return { | |
| 643 | skills: [...skillDirs].sort().map((path) => ({ path, name: path === "." ? (manifest?.name ?? entry?.name ?? "skill") : path.replace(/^.*\//, "") })), | |
| 644 | commands: [...commandFiles].sort().map((path) => ({ path, name: mdName(path) })), | |
| 645 | agents: [...agentFiles].sort().map((path) => ({ path, name: mdName(path) })), | |
| 646 | hooks, | |
| 647 | mcp: has(".mcp.json") ? ".mcp.json" : typeof manifest?.mcpServers === "string" && has(manifest.mcpServers) ? manifest.mcpServers : null, | |
| 648 | lsp: has(".lsp.json") || !!manifest?.lspServers, | |
| 649 | bin: under("bin").length > 0, | |
| 650 | outputStyles: under("output-styles").length > 0, | |
| 651 | readme: ["README.md", "readme.md", "Readme.md"].find(has) ?? null, | |
| 652 | }; | |
| 653 | } | |
| 654 | ||
| 655 | // ── Where to fetch from ────────────────────────────────────────────────── | |
| 656 | ||
| 657 | /** | |
| 658 | * Where g1t reads a plugin's files from, worked out from its entry and the | |
| 659 | * marketplace it is in: GitHub (public, by tarball at a commit or ref), a | |
| 660 | * repository on this g1t, or a zip archive. Everything else is listed but | |
| 661 | * not installable here, and says why. | |
| 662 | */ | |
| 663 | export type FetchPlan = | |
| 664 | | { kind: "github"; repo: string; ref: string | null; sha: string | null; path: string } | |
| 665 | | { kind: "g1t"; repo: string; ref: string | null; path: string } | |
| 666 | | { kind: "archive"; url: string; sha256: string | null } | |
| 667 | | { kind: "unsupported"; why: string }; | |
| 668 | ||
| 669 | /** The GitHub repository a git URL names, when it is one on github.com. */ | |
| 670 | function githubOfUrl(url: string): { repo: string } | null { | |
| 671 | const found = githubRepoOf(url); | |
| 672 | return found && /github\.com/i.test(url) ? { repo: found.repo } : null; | |
| 673 | } | |
| 674 | ||
| 675 | /** A g1t repository a URL names, when the URL is on this g1t (`host`). */ | |
| 676 | function g1tOfUrl(url: string, host: string | null): { repo: string } | null { | |
| 677 | if (!host) return null; | |
| 678 | 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); | |
| 679 | return m ? { repo: m[1]! } : null; | |
| 680 | } | |
| 681 | ||
| 682 | const join = (...parts: (string | null | undefined)[]): string => parts.filter((p) => p && p !== ".").join("/").replace(/\/+/g, "/").replace(/^\/|\/$/g, ""); | |
| 683 | ||
| 684 | export function fetchPlan(source: PluginSource, marketplace: MarketplaceSource, options: { pluginRoot?: string | null; host?: string | null } = {}): FetchPlan { | |
| 685 | const host = options.host ?? null; | |
| 686 | switch (source.source) { | |
| 687 | case "path": { | |
| 688 | const path = join(options.pluginRoot && !source.path.startsWith("./") ? options.pluginRoot : null, source.path.replace(/^\.\//, "")); | |
| 689 | if (marketplace.source === "github") return { kind: "github", repo: marketplace.repo, ref: marketplace.ref ?? null, sha: null, path }; | |
| 690 | if (marketplace.source === "g1t") return { kind: "g1t", repo: marketplace.repo, ref: marketplace.ref ?? null, path }; | |
| 691 | if (marketplace.source === "git") { | |
| 692 | const gh = githubOfUrl(marketplace.url); | |
| 693 | if (gh) return { kind: "github", repo: gh.repo, ref: marketplace.ref ?? null, sha: null, path }; | |
| 694 | const own = g1tOfUrl(marketplace.url, host); | |
| 695 | if (own) return { kind: "g1t", repo: own.repo, ref: marketplace.ref ?? null, path }; | |
| 696 | return { kind: "unsupported", why: "Its marketplace is on a git host g1t doesn't read yet. g1t reads GitHub and repositories here." }; | |
| 697 | } | |
| 698 | 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." }; | |
| 699 | } | |
| 700 | case "github": | |
| 701 | return { kind: "github", repo: source.repo, ref: source.ref ?? null, sha: source.sha ?? null, path: "" }; | |
| 702 | case "url": | |
| 703 | case "git-subdir": { | |
| 704 | const path = source.source === "git-subdir" ? source.path : ""; | |
| 705 | const gh = githubOfUrl(source.url) ?? (githubRepoProblem(source.url) ? null : { repo: source.url }); | |
| 706 | if (gh) return { kind: "github", repo: gh.repo, ref: source.ref ?? null, sha: source.sha ?? null, path }; | |
| 707 | const own = g1tOfUrl(source.url, host); | |
| 708 | if (own) return { kind: "g1t", repo: own.repo, ref: source.sha ?? source.ref ?? null, path }; | |
| 709 | return { kind: "unsupported", why: "It is on a git host g1t doesn't read yet. g1t reads GitHub and repositories here." }; | |
| 710 | } | |
| 711 | case "archive": | |
| 712 | return { kind: "archive", url: source.url, sha256: source.sha256 ?? null }; | |
| 713 | case "npm": | |
| 714 | return { kind: "unsupported", why: "It is published on npm, which g1t doesn't install from yet. It still works in Claude Code." }; | |
| 715 | case "command": | |
| 716 | 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." }; | |
| 717 | } | |
| 718 | } | |
| 719 | ||
| 720 | /** The marketplaces every workspace starts with, in Claude Code's own names. */ | |
| 721 | export const BUILTIN_MARKETPLACES: { name: string; source: MarketplaceSource; about: string }[] = [ | |
| 722 | { 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." }, | |
| 723 | { name: "anthropic-agent-skills", source: { source: "github", repo: "anthropics/skills" }, about: "Anthropic's skills: documents, spreadsheets, presentations, PDFs, design, testing and more." }, | |
| 724 | ]; |
This file's history is long; its oldest lines are credited to the oldest commit read.