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 branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell. | 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 | |
| 603 | * (`skills/`, `commands/`, `agents/`) plus whatever the manifest names, | |
| 604 | * plus the marketplace entry's paths. An entry that is not strict names | |
| 605 | * its components outright instead of the standard folders, as the public | |
| 606 | * skills marketplace does (one repository, one plugin per group of | |
| 607 | * skills). `files` are paths inside the plugin. | |
| 608 | */ | |
| 609 | export function pluginLayout(files: string[], manifest: PluginManifest | null, entry: MarketplacePluginEntry | null): PluginLayout { | |
| 610 | const set = new Set(files.map((f) => f.replace(/^\.\//, ""))); | |
| 611 | const has = (path: string) => set.has(path); | |
| 612 | const under = (dir: string) => [...set].filter((f) => f.startsWith(`${dir}/`)); | |
| 613 | const skillDirs = new Set<string>(); | |
| 614 | const addSkillDir = (dir: string) => { | |
| 615 | const clean = dir === "." ? "" : dir.replace(/\/+$/, ""); | |
| 616 | if (has(clean ? `${clean}/SKILL.md` : "SKILL.md")) skillDirs.add(clean || "."); | |
| 617 | 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$/, "")); | |
| 618 | }; | |
| 619 | const standard = !(entry && !entry.strict); | |
| 620 | if (standard) addSkillDir("skills"); | |
| 621 | for (const dir of [...(manifest?.skills ?? []), ...(entry?.skills ?? [])]) addSkillDir(dir); | |
| 622 | const commandFiles = new Set<string>(); | |
| 623 | const addCommands = (path: string) => { | |
| 624 | if (/\.md$/i.test(path) && has(path)) commandFiles.add(path); | |
| 625 | else for (const f of under(path)) if (/\.md$/i.test(f)) commandFiles.add(f); | |
| 626 | }; | |
| 627 | if (standard) addCommands("commands"); | |
| 628 | for (const p of [...(manifest?.commands ?? []), ...(entry?.commands ?? [])]) addCommands(p); | |
| 629 | const agentFiles = new Set<string>(); | |
| 630 | const addAgents = (path: string) => { | |
| 631 | if (/\.md$/i.test(path) && has(path)) agentFiles.add(path); | |
| 632 | else for (const f of under(path)) if (/\.md$/i.test(f)) agentFiles.add(f); | |
| 633 | }; | |
| 634 | if (standard) addAgents("agents"); | |
| 635 | for (const p of [...(manifest?.agents ?? []), ...(entry?.agents ?? [])]) addAgents(p); | |
| 636 | const hooks = [...(has("hooks/hooks.json") ? ["hooks/hooks.json"] : []), ...(manifest?.hooks ?? []).filter((h) => h === "(inline)" || has(h))]; | |
| 637 | return { | |
| 638 | skills: [...skillDirs].sort().map((path) => ({ path, name: path === "." ? (manifest?.name ?? entry?.name ?? "skill") : path.replace(/^.*\//, "") })), | |
| 639 | commands: [...commandFiles].sort().map((path) => ({ path, name: mdName(path) })), | |
| 640 | agents: [...agentFiles].sort().map((path) => ({ path, name: mdName(path) })), | |
| 641 | hooks, | |
| 642 | mcp: has(".mcp.json") ? ".mcp.json" : typeof manifest?.mcpServers === "string" && has(manifest.mcpServers) ? manifest.mcpServers : null, | |
| 643 | lsp: has(".lsp.json") || !!manifest?.lspServers, | |
| 644 | bin: under("bin").length > 0, | |
| 645 | outputStyles: under("output-styles").length > 0, | |
| 646 | readme: ["README.md", "readme.md", "Readme.md"].find(has) ?? null, | |
| 647 | }; | |
| 648 | } | |
| 649 | ||
| 650 | // ── Where to fetch from ────────────────────────────────────────────────── | |
| 651 | ||
| 652 | /** | |
| 653 | * Where g1t reads a plugin's files from, worked out from its entry and the | |
| 654 | * marketplace it is in: GitHub (public, by tarball at a commit or ref), a | |
| 655 | * repository on this g1t, or a zip archive. Everything else is listed but | |
| 656 | * not installable here, and says why. | |
| 657 | */ | |
| 658 | export type FetchPlan = | |
| 659 | | { kind: "github"; repo: string; ref: string | null; sha: string | null; path: string } | |
| 660 | | { kind: "g1t"; repo: string; ref: string | null; path: string } | |
| 661 | | { kind: "archive"; url: string; sha256: string | null } | |
| 662 | | { kind: "unsupported"; why: string }; | |
| 663 | ||
| 664 | /** The GitHub repository a git URL names, when it is one on github.com. */ | |
| 665 | function githubOfUrl(url: string): { repo: string } | null { | |
| 666 | const found = githubRepoOf(url); | |
| 667 | return found && /github\.com/i.test(url) ? { repo: found.repo } : null; | |
| 668 | } | |
| 669 | ||
| 670 | /** A g1t repository a URL names, when the URL is on this g1t (`host`). */ | |
| 671 | function g1tOfUrl(url: string, host: string | null): { repo: string } | null { | |
| 672 | if (!host) return null; | |
| 673 | 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); | |
| 674 | return m ? { repo: m[1]! } : null; | |
| 675 | } | |
| 676 | ||
| 677 | const join = (...parts: (string | null | undefined)[]): string => parts.filter((p) => p && p !== ".").join("/").replace(/\/+/g, "/").replace(/^\/|\/$/g, ""); | |
| 678 | ||
| 679 | export function fetchPlan(source: PluginSource, marketplace: MarketplaceSource, options: { pluginRoot?: string | null; host?: string | null } = {}): FetchPlan { | |
| 680 | const host = options.host ?? null; | |
| 681 | switch (source.source) { | |
| 682 | case "path": { | |
| 683 | const path = join(options.pluginRoot && !source.path.startsWith("./") ? options.pluginRoot : null, source.path.replace(/^\.\//, "")); | |
| 684 | if (marketplace.source === "github") return { kind: "github", repo: marketplace.repo, ref: marketplace.ref ?? null, sha: null, path }; | |
| 685 | if (marketplace.source === "g1t") return { kind: "g1t", repo: marketplace.repo, ref: marketplace.ref ?? null, path }; | |
| 686 | if (marketplace.source === "git") { | |
| 687 | const gh = githubOfUrl(marketplace.url); | |
| 688 | if (gh) return { kind: "github", repo: gh.repo, ref: marketplace.ref ?? null, sha: null, path }; | |
| 689 | const own = g1tOfUrl(marketplace.url, host); | |
| 690 | if (own) return { kind: "g1t", repo: own.repo, ref: marketplace.ref ?? null, path }; | |
| 691 | return { kind: "unsupported", why: "Its marketplace is on a git host g1t doesn't read yet. g1t reads GitHub and repositories here." }; | |
| 692 | } | |
| 693 | 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." }; | |
| 694 | } | |
| 695 | case "github": | |
| 696 | return { kind: "github", repo: source.repo, ref: source.ref ?? null, sha: source.sha ?? null, path: "" }; | |
| 697 | case "url": | |
| 698 | case "git-subdir": { | |
| 699 | const path = source.source === "git-subdir" ? source.path : ""; | |
| 700 | const gh = githubOfUrl(source.url) ?? (githubRepoProblem(source.url) ? null : { repo: source.url }); | |
| 701 | if (gh) return { kind: "github", repo: gh.repo, ref: source.ref ?? null, sha: source.sha ?? null, path }; | |
| 702 | const own = g1tOfUrl(source.url, host); | |
| 703 | if (own) return { kind: "g1t", repo: own.repo, ref: source.sha ?? source.ref ?? null, path }; | |
| 704 | return { kind: "unsupported", why: "It is on a git host g1t doesn't read yet. g1t reads GitHub and repositories here." }; | |
| 705 | } | |
| 706 | case "archive": | |
| 707 | return { kind: "archive", url: source.url, sha256: source.sha256 ?? null }; | |
| 708 | case "npm": | |
| 709 | return { kind: "unsupported", why: "It is published on npm, which g1t doesn't install from yet. It still works in Claude Code." }; | |
| 710 | case "command": | |
| 711 | 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." }; | |
| 712 | } | |
| 713 | } | |
| 714 | ||
| 715 | /** The marketplaces every workspace starts with, in Claude Code's own names. */ | |
| 716 | export const BUILTIN_MARKETPLACES: { name: string; source: MarketplaceSource; about: string }[] = [ | |
| 717 | { 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." }, | |
| 718 | { name: "anthropic-agent-skills", source: { source: "github", repo: "anthropics/skills" }, about: "Anthropic's skills: documents, spreadsheets, presentations, PDFs, design, testing and more." }, | |
| 719 | ]; |
This file's history is long; its oldest lines are credited to the oldest commit read.