Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
724 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r91/**
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.2 * Deprecated: g1t no longer runs agents of its own. Kept only until
3 * apps/mobile drops its Customize screens, which import it; delete it
4 * afterwards.
5 */
6/**
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r97 * 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];

This file's history is long; its oldest lines are credited to the oldest commit read.