Skip to content
719 linesCodeBlameRaw
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
23export type Checked<T> = { ok: true; value: T } | { ok: false; message: string };
24
25const 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 `..`. */
30export 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 */
42export 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-`). */
74export 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. */
80export 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). */
88export function pluginRef(name: string, marketplace: string): string {
89 return `${name}@${marketplace}`;
90}
91
92/** `name@marketplace` split; null when it isn't one. */
93export 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`. */
104export 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`). */
111export 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 */
124export 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
131const str = (value: unknown): string | undefined => (typeof value === "string" && value.trim() ? value.trim() : undefined);
132const 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. */
135export 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. */
141export 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 */
191export 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 */
210export 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. */
231export 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
256export 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
268const 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
270function 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. */
280function paths(value: unknown): string[] {
281 return strList(value).map((p) => (p === "." ? "." : p.replace(/^\.\//, "").replace(/\/+$/, ""))).filter((p) => p && !p.startsWith("/") && !p.split("/").includes(".."));
282}
283
284const 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. */
287export 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. */
325export 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
361export 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
382const 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. */
385export 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
418export 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. */
421export 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
435const VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g;
436
437function 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
448const 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. */
455export 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. */
511export 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
527const 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. */
530export 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. */
538function 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 */
552export 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. */
581export 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
599const 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 */
609export 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 */
658export 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. */
665function 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`). */
671function 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
677const join = (...parts: (string | null | undefined)[]): string => parts.filter((p) => p && p !== ".").join("/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
678
679export 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. */
716export 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];