Skip to content
724 linesCodeBlameRaw
1/**
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/**
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
28export type Checked<T> = { ok: true; value: T } | { ok: false; message: string };
29
30const 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 `..`. */
35export 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 */
47export 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-`). */
79export 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. */
85export 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). */
93export function pluginRef(name: string, marketplace: string): string {
94 return `${name}@${marketplace}`;
95}
96
97/** `name@marketplace` split; null when it isn't one. */
98export 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`. */
109export 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`). */
116export 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 */
129export 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
136const str = (value: unknown): string | undefined => (typeof value === "string" && value.trim() ? value.trim() : undefined);
137const 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. */
140export 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. */
146export 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 */
196export 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 */
215export 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. */
236export 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
261export 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
273const 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
275function 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. */
285function paths(value: unknown): string[] {
286 return strList(value).map((p) => (p === "." ? "." : p.replace(/^\.\//, "").replace(/\/+$/, ""))).filter((p) => p && !p.startsWith("/") && !p.split("/").includes(".."));
287}
288
289const 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. */
292export 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. */
330export 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
366export 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
387const 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. */
390export 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
423export 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. */
426export 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
440const VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g;
441
442function 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
453const 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. */
460export 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. */
516export 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
532const 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. */
535export 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. */
543function 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 */
557export 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. */
586export 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
604const 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 */
614export 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 */
663export 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. */
670function 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`). */
676function 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
682const join = (...parts: (string | null | undefined)[]): string => parts.filter((p) => p && p !== ".").join("/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
683
684export 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. */
721export 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];