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

Commit

The contracts for Customize, the marketplace, g1t's foundational skills and the Claude Code plugin format are gone: they described the agents g1t no longer runs of its own, and were kept only while the mobile app's Customize screens imported them, which it no longer does. Nothing else used them, so @g1t/contracts no longer exports them or offers their ./customize, ./skills, ./marketplace and ./claude-plugin subpaths, and the runner's zip module no longer says it unpacks plugins from Customize. The Rust contracts never mirrored them.

Checked:
npm run typecheck (all workspaces), npm test -w @g1t/contracts (48 passed) and cargo check -p g1t-runner.
syntaqxcommitted Parent1f5404eBrowse files
10 files+1−20950/10 viewed
+0−1
1818 mod process;
1919 mod report;
2020 mod uses;
21−/// Also unpacks the plugins a run gets from Customize (customize.rs).
2221 pub(crate) mod zip;
2322
2423 use std::collections::{BTreeMap, BTreeSet};
+1−1
44 "private": true,
55 "type": "module",
66 "license": "MIT",
7− "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./instances": "./src/instances.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./marketplace": "./src/marketplace.ts", "./chat": "./src/chat.ts", "./integrations": "./src/integrations.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./customize": "./src/customize.ts", "./claude-plugin": "./src/claude-plugin.ts", "./coding-agents": "./src/coding-agents.ts", "./skills": "./src/skills.ts", "./discovery": "./src/discovery.ts", "./telemetry": "./src/telemetry.ts" },
7+ "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./instances": "./src/instances.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./chat": "./src/chat.ts", "./integrations": "./src/integrations.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./coding-agents": "./src/coding-agents.ts", "./discovery": "./src/discovery.ts", "./telemetry": "./src/telemetry.ts" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+0−270
1−import assert from "node:assert/strict";
2−import { test } from "node:test";
3−
4−import {
5− BUILTIN_MARKETPLACES,
6− fetchPlan,
7− githubRepoOf,
8− isReservedMarketplaceName,
9− marketplaceNameProblem,
10− namespaced,
11− parseMarketplaceAddress,
12− parseMarketplaceManifest,
13− parseMcpServers,
14− parsePluginManifest,
15− parsePluginRef,
16− parsePluginSource,
17− pluginLayout,
18− pluginNameProblem,
19− pluginRef,
20− subagentOf,
21−} from "./claude-plugin.ts";
22−
23−// The two public marketplaces as they are published, in miniature.
24−const OFFICIAL = {
25− name: "claude-plugins-official",
26− owner: { name: "Anthropic", email: "support@anthropic.com" },
27− plugins: [
28− { name: "agent-sdk-dev", source: "./plugins/agent-sdk-dev", description: "Development kit", category: "development", author: { name: "Anthropic" } },
29− { name: "asana", source: "./external_plugins/asana", category: "productivity", description: "Asana" },
30− {
31− name: "airtable",
32− source: { source: "git-subdir", url: "https://github.com/Airtable/skills.git", path: "plugins/airtable", ref: "main", sha: "812ee67f1fd3d76fb45ff8df40afaa0448602ba8" },
33− category: "productivity",
34− description: "Airtable",
35− },
36− { name: "aikido", source: { source: "url", url: "https://github.com/AikidoSec/aikido-claude-plugin.git", sha: "02f018ad4da175a2a5ad6ca9451c3d309d105fab" } },
37− { name: "from-npm", source: { source: "npm", package: "@acme/plugin" } },
38− { name: "bad entry", source: "./x" },
39− { name: "no-source" },
40− ],
41−};
42−
43−const SKILLS = {
44− name: "anthropic-agent-skills",
45− owner: { name: "Keith Lazuka", email: "klazuka@anthropic.com" },
46− metadata: { description: "Anthropic example skills", version: "1.0.0" },
47− plugins: [
48− { name: "document-skills", description: "Documents", source: "./", strict: false, skills: ["./skills/xlsx", "./skills/docx", "./skills/pptx", "./skills/pdf"] },
49− { name: "claude-api", description: "Claude API", source: "./", strict: false, skills: ["./skills/claude-api"] },
50− ],
51−};
52−
53−test("a marketplace manifest reads as published, noting the entries it can't read", () => {
54− const read = parseMarketplaceManifest(OFFICIAL);
55− assert.ok(read.ok);
56− assert.equal(read.value.name, "claude-plugins-official");
57− assert.equal(read.value.owner.name, "Anthropic");
58− assert.deepEqual(
59− read.value.plugins.map((p) => [p.name, p.source.source, p.category]),
60− [
61− ["agent-sdk-dev", "path", "development"],
62− ["asana", "path", "productivity"],
63− ["airtable", "git-subdir", "productivity"],
64− ["aikido", "url", null],
65− ["from-npm", "npm", null],
66− ],
67− );
68− assert.deepEqual(read.value.problems, ["Plugin 6: A plugin's name is letters, digits, dots, underscores and hyphens, like pr-review-toolkit.", "Plugin 7: no-source has no source."]);
69− const skills = parseMarketplaceManifest(SKILLS);
70− assert.ok(skills.ok);
71− assert.equal(skills.value.description, "Anthropic example skills");
72− assert.equal(skills.value.version, "1.0.0");
73− assert.deepEqual(skills.value.plugins[0]!.skills, ["skills/xlsx", "skills/docx", "skills/pptx", "skills/pdf"]);
74− assert.equal(skills.value.plugins[0]!.strict, false);
75−});
76−
77−test("a manifest without a name, an owner or plugins is refused", () => {
78− assert.equal(parseMarketplaceManifest({ owner: { name: "x" }, plugins: [] }).ok, false);
79− assert.equal(parseMarketplaceManifest({ name: "x", plugins: [] }).ok, false);
80− assert.equal(parseMarketplaceManifest({ name: "x", owner: "Someone" }).ok, false);
81− assert.equal(parseMarketplaceManifest({ name: "../x", owner: { name: "x" }, plugins: [] }).ok, false);
82− assert.equal(parseMarketplaceManifest("[]").ok, false);
83−});
84−
85−test("plugin sources: every form Claude Code takes, and what each refuses", () => {
86− assert.deepEqual(parsePluginSource("./plugins/foo/"), { ok: true, value: { source: "path", path: "./plugins/foo" } });
87− assert.equal(parsePluginSource("plugins/foo").ok, false);
88− assert.equal(parsePluginSource("./plugins/../foo").ok, false);
89− assert.deepEqual(parsePluginSource({ source: "github", repo: "acme/tools", ref: "v1", sha: "ABCDEF0123456789abcdef0123456789abcdef01" }), {
90− ok: true,
91− value: { source: "github", repo: "acme/tools", ref: "v1", sha: "abcdef0123456789abcdef0123456789abcdef01" },
92− });
93− assert.equal(parsePluginSource({ source: "github", repo: "not a repo" }).ok, false);
94− assert.equal(parsePluginSource({ source: "url", url: "ftp://x" }).ok, false);
95− assert.equal(parsePluginSource({ source: "git-subdir", url: "https://github.com/a/b.git", path: "../x" }).ok, false);
96− assert.deepEqual(parsePluginSource({ source: "archive", url: "https://x.dev/p.zip", sha256: "ab".repeat(32) }), { ok: true, value: { source: "archive", url: "https://x.dev/p.zip", sha256: "ab".repeat(32) } });
97− assert.deepEqual(parsePluginSource({ source: "command", command: "make plugin", mode: "link" }), { ok: true, value: { source: "command", command: "make plugin", mode: "link" } });
98− assert.equal(parsePluginSource({ source: "pip", package: "x" }).ok, false);
99−});
100−
101−test("where to fetch a plugin from follows its source and its marketplace", () => {
102− const github = { source: "github", repo: "anthropics/claude-plugins-official" } as const;
103− assert.deepEqual(fetchPlan({ source: "path", path: "./plugins/agent-sdk-dev" }, github), { kind: "github", repo: "anthropics/claude-plugins-official", ref: null, sha: null, path: "plugins/agent-sdk-dev" });
104− assert.deepEqual(fetchPlan({ source: "path", path: "./" }, { source: "github", repo: "anthropics/skills", ref: "main" }), { kind: "github", repo: "anthropics/skills", ref: "main", sha: null, path: "" });
105− assert.deepEqual(fetchPlan({ source: "path", path: "./foo" }, { source: "g1t", repo: "flagon-io/plugins" }), { kind: "g1t", repo: "flagon-io/plugins", ref: null, path: "foo" });
106− assert.deepEqual(fetchPlan({ source: "git-subdir", url: "https://github.com/Airtable/skills.git", path: "plugins/airtable", ref: "main", sha: "8".repeat(40) }, github), {
107− kind: "github",
108− repo: "Airtable/skills",
109− ref: "main",
110− sha: "8".repeat(40),
111− path: "plugins/airtable",
112− });
113− assert.deepEqual(fetchPlan({ source: "url", url: "https://github.com/AikidoSec/aikido-claude-plugin.git", sha: "0".repeat(40) }, github), { kind: "github", repo: "AikidoSec/aikido-claude-plugin", ref: null, sha: "0".repeat(40), path: "" });
114− assert.deepEqual(fetchPlan({ source: "url", url: "https://g1t.sh/flagon-io/tools" }, github, { host: "g1t.sh" }), { kind: "g1t", repo: "flagon-io/tools", ref: null, path: "" });
115− assert.equal(fetchPlan({ source: "url", url: "https://gitlab.com/a/b.git" }, github).kind, "unsupported");
116− assert.equal(fetchPlan({ source: "npm", package: "x" }, github).kind, "unsupported");
117− assert.equal(fetchPlan({ source: "command", command: "x" }, github).kind, "unsupported");
118− assert.equal(fetchPlan({ source: "path", path: "./x" }, { source: "url", url: "https://x.dev/marketplace.json" }).kind, "unsupported");
119− assert.deepEqual(fetchPlan({ source: "path", path: "foo" }, github, { pluginRoot: "plugins" }), { kind: "github", repo: "anthropics/claude-plugins-official", ref: null, sha: null, path: "plugins/foo" });
120− assert.deepEqual(fetchPlan({ source: "archive", url: "https://x.dev/p.zip" }, github), { kind: "archive", url: "https://x.dev/p.zip", sha256: null });
121−});
122−
123−test("a marketplace address is read the ways people write one", () => {
124− assert.deepEqual(parseMarketplaceAddress("anthropics/skills"), { ok: true, value: { source: "github", repo: "anthropics/skills" } });
125− assert.deepEqual(parseMarketplaceAddress("acme/tools#v2"), { ok: true, value: { source: "github", repo: "acme/tools", ref: "v2" } });
126− assert.deepEqual(parseMarketplaceAddress("https://github.com/anthropics/claude-plugins-official"), { ok: true, value: { source: "github", repo: "anthropics/claude-plugins-official" } });
127− assert.deepEqual(parseMarketplaceAddress("https://github.com/acme/tools/tree/main"), { ok: true, value: { source: "github", repo: "acme/tools", ref: "main" } });
128− assert.deepEqual(parseMarketplaceAddress("https://x.dev/plugins/marketplace.json"), { ok: true, value: { source: "url", url: "https://x.dev/plugins/marketplace.json" } });
129− assert.deepEqual(parseMarketplaceAddress("https://gitlab.com/acme/tools.git#main"), { ok: true, value: { source: "git", url: "https://gitlab.com/acme/tools.git", ref: "main" } });
130− assert.deepEqual(parseMarketplaceAddress("flagon-io/plugins", { here: (repo) => repo === "flagon-io/plugins" }), { ok: true, value: { source: "g1t", repo: "flagon-io/plugins" } });
131− assert.deepEqual(parseMarketplaceAddress("https://g1t.sh/flagon-io/plugins", { host: "g1t.sh" }), { ok: true, value: { source: "g1t", repo: "flagon-io/plugins" } });
132− assert.equal(parseMarketplaceAddress("").ok, false);
133− assert.equal(parseMarketplaceAddress("just words").ok, false);
134− assert.deepEqual(githubRepoOf("git@github.com:acme/tools.git"), { repo: "acme/tools" });
135− assert.equal(githubRepoOf("https://example.com/acme/tools"), null);
136−});
137−
138−test("names: marketplaces, reserved ones, plugins and plugin@marketplace", () => {
139− assert.equal(marketplaceNameProblem("my-plugins"), null);
140− assert.ok(marketplaceNameProblem("-x"));
141− assert.ok(marketplaceNameProblem("a..b"));
142− assert.ok(isReservedMarketplaceName("claude-plugins-official"));
143− assert.ok(isReservedMarketplaceName("claudeai-foo"));
144− assert.ok(isReservedMarketplaceName("g1t"));
145− assert.equal(isReservedMarketplaceName("acme"), false);
146− assert.equal(pluginNameProblem("pr-review-toolkit"), null);
147− assert.ok(pluginNameProblem("a:b"));
148− assert.ok(pluginNameProblem("a/b"));
149− assert.equal(pluginRef("pdf", "anthropic-agent-skills"), "pdf@anthropic-agent-skills");
150− assert.deepEqual(parsePluginRef("pdf@anthropic-agent-skills"), { name: "pdf", marketplace: "anthropic-agent-skills" });
151− assert.equal(parsePluginRef("pdf"), null);
152− assert.equal(parsePluginRef("@x"), null);
153− assert.equal(namespaced("document-skills", "pdf"), "document-skills:pdf");
154−});
155−
156−test("plugin.json needs only a name; component paths and inline servers are kept", () => {
157− const read = parsePluginManifest({ name: "asana", description: "Asana", author: { name: "Asana" }, skills: ["./extra-skills"], mcpServers: { asana: { type: "http", url: "https://mcp.asana.com/v2/mcp" } }, hooks: "./hooks/hooks.json", other: 1 });
158− assert.ok(read.ok);
159− assert.deepEqual(read.value.skills, ["extra-skills"]);
160− assert.deepEqual(read.value.hooks, ["hooks/hooks.json"]);
161− assert.deepEqual(read.value.extra, { other: 1 });
162− assert.ok(read.value.mcpServers && typeof read.value.mcpServers === "object");
163− assert.equal(parsePluginManifest({ description: "no name" }).ok, false);
164− assert.equal(parsePluginManifest({ name: "claude:x" }).ok, false);
165−});
166−
167−test(".mcp.json: remote servers, stdio ones, and what is skipped", () => {
168− const { servers, problems } = parseMcpServers({
169− mcpServers: {
170− asana: { type: "http", url: "https://mcp.asana.com/v2/mcp", headers: { Authorization: "Bearer ${ASANA_TOKEN}" } },
171− legacy: { type: "sse", url: "https://x.dev/sse" },
172− local: { command: "npx", args: ["-y", "@acme/server", "${HOME}"], env: { KEY: "${ACME_KEY:-none}" } },
173− untyped: { url: "https://x.dev/mcp" },
174− broken: "nope",
175− "bad name!": { type: "http", url: "https://x.dev" },
176− },
177− });
178− assert.deepEqual(
179− servers.map((s) => [s.name, s.type, s.url ?? s.command, s.variables]),
180− [
181− ["asana", "http", "https://mcp.asana.com/v2/mcp", ["ASANA_TOKEN"]],
182− ["legacy", "sse", "https://x.dev/sse", []],
183− ["local", "stdio", "npx", ["ACME_KEY", "HOME"]],
184− ],
185− );
186− assert.equal(problems.length, 3);
187− assert.match(problems[0]!, /untyped: a remote server says its type/);
188− assert.equal(parseMcpServers([]).problems.length, 1);
189−});
190−
191−test("a subagent file: front-matter and the prompt, skipped the way Claude Code skips it", () => {
192− const read = subagentOf(
193− {
194− name: "code-reviewer",
195− description: "Reviews pull requests for risk. Use after a change is ready.",
196− tools: "Read, Grep, Bash",
197− model: "sonnet",
198− skills: ["pr-review"],
199− color: "green",
200− },
201− "\nYou are a careful reviewer. Read the diff first.\n",
202− );
203− assert.ok(read.ok);
204− assert.deepEqual(
205− { name: read.value.name, tools: read.value.tools, model: read.value.model, skills: read.value.skills, color: read.value.color },
206− { name: "code-reviewer", tools: ["Read", "Grep", "Bash"], model: "sonnet", skills: ["pr-review"], color: "green" },
207− );
208− assert.equal(read.value.prompt, "You are a careful reviewer. Read the diff first.");
209− assert.equal(subagentOf({ name: "x" }, "body").ok, false);
210− assert.equal(subagentOf({ name: "a:b", description: "d" }, "body").ok, false);
211− assert.equal(subagentOf({ name: "a", description: "d" }, "\n").ok, false);
212−});
213−
214−test("a plugin's layout is found from its files, its manifest and a strict entry", () => {
215− const files = [
216− ".claude-plugin/plugin.json",
217− "README.md",
218− "skills/pdf/SKILL.md",
219− "skills/pdf/scripts/fill.py",
220− "skills/docx/SKILL.md",
221− "commands/review.md",
222− "agents/reviewer.md",
223− "agents/security/audit.md",
224− "hooks/hooks.json",
225− ".mcp.json",
226− "bin/tool",
227− "extra/one/SKILL.md",
228− ];
229− const manifest = parsePluginManifest({ name: "kit", skills: ["./extra"] });
230− assert.ok(manifest.ok);
231− const layout = pluginLayout(files, manifest.value, null);
232− assert.deepEqual(layout.skills, [
233− { path: "extra/one", name: "one" },
234− { path: "skills/docx", name: "docx" },
235− { path: "skills/pdf", name: "pdf" },
236− ]);
237− assert.deepEqual(layout.commands, [{ path: "commands/review.md", name: "review" }]);
238− assert.deepEqual(layout.agents, [
239− { path: "agents/reviewer.md", name: "reviewer" },
240− { path: "agents/security/audit.md", name: "audit" },
241− ]);
242− assert.deepEqual(layout.hooks, ["hooks/hooks.json"]);
243− assert.equal(layout.mcp, ".mcp.json");
244− assert.equal(layout.bin, true);
245− assert.equal(layout.readme, "README.md");
246− // The skills marketplace: a plugin whose folder is the marketplace root, with its skills named by the entry.
247− const entry = parseMarketplaceManifest(SKILLS);
248− assert.ok(entry.ok);
249− const docs = pluginLayout(["skills/xlsx/SKILL.md", "skills/docx/SKILL.md", "skills/pdf/SKILL.md", "skills/pptx/SKILL.md", "skills/other/SKILL.md", "README.md"], null, entry.value.plugins[0]!);
250− // The entry is not strict: it names its skills, so a sibling folder under skills/ is not its.
251− assert.deepEqual(
252− docs.skills.map((s) => s.name),
253− ["docx", "pdf", "pptx", "xlsx"],
254− );
255− // An entry that names its skills gets exactly those, not every sibling folder under skills/.
256− assert.deepEqual(pluginLayout(["skills/xlsx/SKILL.md", "skills/claude-api/SKILL.md", "skills/other/SKILL.md"], null, entry.value.plugins[1]!).skills, [{ path: "skills/claude-api", name: "claude-api" }]);
257− // A plugin that is one skill at its root.
258− assert.deepEqual(pluginLayout(["SKILL.md", "reference.md"], null, { ...entry.value.plugins[1]!, skills: ["."] }).skills, [{ path: ".", name: "claude-api" }]);
259−});
260−
261−test("the built-in marketplaces are the public ones, under their own names", () => {
262− assert.deepEqual(
263− BUILTIN_MARKETPLACES.map((m) => [m.name, m.source]),
264− [
265− ["claude-plugins-official", { source: "github", repo: "anthropics/claude-plugins-official" }],
266− ["anthropic-agent-skills", { source: "github", repo: "anthropics/skills" }],
267− ],
268− );
269− for (const m of BUILTIN_MARKETPLACES) assert.ok(isReservedMarketplaceName(m.name));
270−});
+0−724
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−
28−export type Checked<T> = { ok: true; value: T } | { ok: false; message: string };
29−
30−const bad = (message: string): { ok: false; message: string } => ({ ok: false, message });
31−
32−// ── Names ────────────────────────────────────────────────────────────────
33−
34−/** A marketplace's name: letters, digits, `.`, `_` and `-`, starting with a letter or digit, no `..`. */
35−export function marketplaceNameProblem(name: unknown): string | null {
36− if (typeof name !== "string" || !name) return "A marketplace has a name.";
37− if (name.length > 64) return "A marketplace's name is at most 64 characters.";
38− if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) return "A marketplace's name is letters, digits, dots, underscores and hyphens, starting with a letter or digit.";
39− return null;
40−}
41−
42−/**
43− * Names Claude Code keeps for its own marketplaces, which g1t keeps the
44− * same way: a workspace can't add a marketplace under one of them, so
45− * `plugin@claude-plugins-official` always means the real thing.
46− */
47−export const RESERVED_MARKETPLACE_NAMES: readonly string[] = [
48− "claude-code-marketplace",
49− "claude-code-plugins",
50− "claude-plugins-official",
51− "anthropic-marketplace",
52− "anthropic-plugins",
53− "agent-skills",
54− "anthropic-agent-skills",
55− "life-sciences",
56− "knowledge-work-plugins",
57− "claude-for-legal",
58− "claude-for-financial-services",
59− "financial-services-plugins",
60− "first-party-plugins",
61− "claude-tag-plugins",
62− "claude-community",
63− "claude-plugins-community",
64− "healthcare",
65− "inline",
66− "builtin",
67− "skills-dir",
68− "synced",
69− "npm",
70− "pip",
71− "uv",
72− "cargo",
73− "github",
74− "gh",
75− "g1t",
76−];
77−
78−/** Whether a marketplace name is reserved (the list above, and anything starting `claudeai-`). */
79−export function isReservedMarketplaceName(name: string): boolean {
80− const lower = name.toLowerCase();
81− return RESERVED_MARKETPLACE_NAMES.includes(lower) || lower.startsWith("claudeai-");
82−}
83−
84−/** A plugin's name: kebab-case, no spaces, `@`, `:` or path separators. */
85−export function pluginNameProblem(name: unknown): string | null {
86− if (typeof name !== "string" || !name) return "A plugin has a name.";
87− if (name.length > 64) return "A plugin's name is at most 64 characters.";
88− if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) return "A plugin's name is letters, digits, dots, underscores and hyphens, like pr-review-toolkit.";
89− return null;
90−}
91−
92−/** `name@marketplace`: how a plugin is named everywhere (settings, installs, requests). */
93−export function pluginRef(name: string, marketplace: string): string {
94− return `${name}@${marketplace}`;
95−}
96−
97−/** `name@marketplace` split; null when it isn't one. */
98−export function parsePluginRef(value: unknown): { name: string; marketplace: string } | null {
99− if (typeof value !== "string") return null;
100− const at = value.lastIndexOf("@");
101− if (at <= 0 || at === value.length - 1) return null;
102− const name = value.slice(0, at);
103− const marketplace = value.slice(at + 1);
104− if (pluginNameProblem(name) || marketplaceNameProblem(marketplace)) return null;
105− return { name, marketplace };
106−}
107−
108−/** A plugin's component as it is named to agents and people: `plugin:skill`. */
109−export function namespaced(plugin: string, component: string): string {
110− return `${plugin}:${component}`;
111−}
112−
113−// ── Sources ──────────────────────────────────────────────────────────────
114−
115−/** Where a plugin's files are, as a marketplace entry says (Claude Code's `source`). */
116−export type PluginSource =
117− | { source: "path"; path: string }
118− | { source: "github"; repo: string; ref?: string; sha?: string }
119− | { source: "url"; url: string; ref?: string; sha?: string }
120− | { source: "git-subdir"; url: string; path: string; ref?: string; sha?: string }
121− | { source: "npm"; package: string; version?: string; registry?: string }
122− | { source: "archive"; url: string; sha256?: string }
123− | { source: "command"; command: string; timeout?: number; mode?: "copy" | "link" };
124−
125−/**
126− * Where a marketplace is: a GitHub repository, a git URL, a direct link to
127− * its `marketplace.json`, a repository on this g1t, or one g1t builds in.
128− */
129−export type MarketplaceSource =
130− | { source: "github"; repo: string; ref?: string }
131− | { source: "git"; url: string; ref?: string }
132− | { source: "url"; url: string }
133− | { source: "g1t"; repo: string; ref?: string }
134− | { source: "builtin"; id: string };
135−
136−const str = (value: unknown): string | undefined => (typeof value === "string" && value.trim() ? value.trim() : undefined);
137−const sha40 = (value: unknown): string | undefined => (typeof value === "string" && /^[0-9a-f]{40}$/i.test(value) ? value.toLowerCase() : undefined);
138−
139−/** `owner/repo` as GitHub names it; null otherwise. */
140−export function githubRepoProblem(repo: unknown): string | null {
141− if (typeof repo !== "string") return "Name the repository as owner/repo.";
142− return /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/.test(repo) && !repo.endsWith(".git") && !repo.includes("..") ? null : "Name the repository as owner/repo.";
143−}
144−
145−/** A plugin entry's `source`, read: a relative path, or an object naming where its files are. */
146−export function parsePluginSource(raw: unknown): Checked<PluginSource> {
147− if (typeof raw === "string") {
148− if (!raw.startsWith("./") || raw.includes("..")) return bad("A plugin's path starts with ./ and stays inside the marketplace.");
149− return { ok: true, value: { source: "path", path: raw.replace(/\/+$/, "") } };
150− }
151− if (!raw || typeof raw !== "object") return bad("A plugin's source is a path or an object.");
152− const s = raw as Record<string, unknown>;
153− switch (s.source) {
154− case "github": {
155− const problem = githubRepoProblem(s.repo);
156− if (problem) return bad(problem);
157− return { ok: true, value: { source: "github", repo: s.repo as string, ...(str(s.ref) ? { ref: str(s.ref) } : {}), ...(sha40(s.sha) ? { sha: sha40(s.sha) } : {}) } };
158− }
159− case "url": {
160− const url = str(s.url);
161− if (!url || !/^https?:\/\//.test(url)) return bad("A url source is an https address.");
162− return { ok: true, value: { source: "url", url, ...(str(s.ref) ? { ref: str(s.ref) } : {}), ...(sha40(s.sha) ? { sha: sha40(s.sha) } : {}) } };
163− }
164− case "git-subdir": {
165− const url = str(s.url);
166− const path = str(s.path);
167− if (!url) return bad("A git-subdir source names the repository's url.");
168− if (!path || path.startsWith("/") || path.includes("..")) return bad("A git-subdir source names a folder inside the repository.");
169− return { ok: true, value: { source: "git-subdir", url, path: path.replace(/^\.\//, "").replace(/\/+$/, ""), ...(str(s.ref) ? { ref: str(s.ref) } : {}), ...(sha40(s.sha) ? { sha: sha40(s.sha) } : {}) } };
170− }
171− case "npm": {
172− const pkg = str(s.package);
173− if (!pkg) return bad("An npm source names the package.");
174− return { ok: true, value: { source: "npm", package: pkg, ...(str(s.version) ? { version: str(s.version) } : {}), ...(str(s.registry) ? { registry: str(s.registry) } : {}) } };
175− }
176− case "archive": {
177− const url = str(s.url);
178− if (!url || !/^https:\/\//.test(url)) return bad("An archive source is an https address of a zip file.");
179− return { ok: true, value: { source: "archive", url, ...(typeof s.sha256 === "string" && /^[0-9a-f]{64}$/i.test(s.sha256) ? { sha256: s.sha256.toLowerCase() } : {}) } };
180− }
181− case "command": {
182− const command = str(s.command);
183− if (!command) return bad("A command source says the command.");
184− return { ok: true, value: { source: "command", command, ...(typeof s.timeout === "number" ? { timeout: s.timeout } : {}), ...(s.mode === "link" ? { mode: "link" as const } : {}) } };
185− }
186− default:
187− return bad(`g1t doesn't know the plugin source "${String(s.source)}".`);
188− }
189−}
190−
191−/**
192− * A GitHub repository from the ways people write one: `owner/repo`,
193− * `owner/repo#ref`, `owner/repo@ref`, or any github.com address (with or
194− * without `.git`, with a `/tree/<ref>` path). Null when it isn't one.
195− */
196−export function githubRepoOf(text: string): { repo: string; ref?: string; path?: string } | null {
197− const value = text.trim();
198− const short = /^([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+?)(?:\.git)?(?:[#@]([^\s#@]+))?$/.exec(value);
199− if (short && !value.includes("://") && !value.includes(":")) {
200− return { repo: short[1]!, ...(short[2] ? { ref: short[2] } : {}) };
201− }
202− const web = /^(?:https?:\/\/|git@)(?:www\.)?github\.com[/:]([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/(.*))?)?\/?(?:[#@]([^\s#@]+))?$/.exec(value);
203− if (!web) return null;
204− const ref = web[2] ?? web[4];
205− return { repo: web[1]!, ...(ref ? { ref } : {}), ...(web[3] ? { path: web[3].replace(/\/+$/, "") } : {}) };
206−}
207−
208−/**
209− * A marketplace as someone types it into **Add a marketplace**, the forms
210− * Claude Code's `plugin marketplace add` takes: `owner/repo` (GitHub), a
211− * github.com or git address, a direct link to `marketplace.json`, or, on
212− * g1t, a repository here as `workspace/name` when `here` says that name is
213− * one of this g1t's.
214− */
215−export function parseMarketplaceAddress(text: string, options: { here?: (repo: string) => boolean; host?: string } = {}): Checked<MarketplaceSource> {
216− const value = text.trim();
217− if (!value) return bad("Say where the marketplace is: owner/repo on GitHub, a repository here, or a link.");
218− if (/^https?:\/\/.*\/marketplace\.json(\?.*)?$/i.test(value)) return { ok: true, value: { source: "url", url: value } };
219− if (options.host) {
220− const own = new RegExp(`^https?://(?:www\\.)?${options.host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?/[A-Za-z0-9._-]+?)(?:\\.git)?/?(?:[#@]([^\\s#@]+))?$`, "i").exec(value);
221− if (own) return { ok: true, value: { source: "g1t", repo: own[1]!, ...(own[2] ? { ref: own[2] } : {}) } };
222− }
223− const github = githubRepoOf(value);
224− if (github && !value.includes("://") && options.here?.(github.repo)) return { ok: true, value: { source: "g1t", repo: github.repo, ...(github.ref ? { ref: github.ref } : {}) } };
225− if (github) return { ok: true, value: { source: "github", repo: github.repo, ...(github.ref ? { ref: github.ref } : {}) } };
226− if (/^(https?:\/\/|git@|ssh:\/\/)/.test(value)) {
227− const ref = /[#]([^\s#]+)$/.exec(value)?.[1];
228− return { ok: true, value: { source: "git", url: value.replace(/#[^\s#]+$/, ""), ...(ref ? { ref } : {}) } };
229− }
230− return bad("That isn't a place g1t can read a marketplace from. Give owner/repo on GitHub, a repository here, or a link to its marketplace.json.");
231−}
232−
233−// ── marketplace.json ─────────────────────────────────────────────────────
234−
235−/** One plugin as a marketplace lists it. Any plugin.json field may appear here too. */
236−export type MarketplacePluginEntry = {
237− name: string;
238− source: PluginSource;
239− description: string;
240− version: string | null;
241− author: { name: string; email?: string; url?: string } | null;
242− homepage: string | null;
243− repository: string | null;
244− license: string | null;
245− keywords: string[];
246− category: string | null;
247− tags: string[];
248− /** Entry-level component paths (added to the manifest's when `strict`). `skills` may be `.`. */
249− skills: string[];
250− commands: string[];
251− agents: string[];
252− /** Entry-level MCP servers, only for plugins without a plugin.json. */
253− mcpServers: Record<string, unknown> | null;
254− /** Default true: the entry's components are added to the manifest's. */
255− strict: boolean;
256− displayName: string | null;
257− /** Whatever else the entry carried, kept as it was. */
258− extra: Record<string, unknown>;
259−};
260−
261−export type MarketplaceManifest = {
262− name: string;
263− owner: { name: string; email?: string; url?: string };
264− description: string | null;
265− version: string | null;
266− /** Bare plugin names resolve under this folder. */
267− pluginRoot: string | null;
268− plugins: MarketplacePluginEntry[];
269− /** Entries that couldn't be read, by index, with why. */
270− problems: string[];
271−};
272−
273−const strList = (value: unknown): string[] => (Array.isArray(value) ? value.filter((v): v is string => typeof v === "string" && !!v.trim()).map((v) => v.trim()) : typeof value === "string" && value.trim() ? [value.trim()] : []);
274−
275−function person(value: unknown): { name: string; email?: string; url?: string } | null {
276− if (typeof value === "string") return value.trim() ? { name: value.trim() } : null;
277− if (!value || typeof value !== "object") return null;
278− const p = value as Record<string, unknown>;
279− const name = str(p.name);
280− if (!name) return null;
281− return { name, ...(str(p.email) ? { email: str(p.email) } : {}), ...(str(p.url) ? { url: str(p.url) } : {}) };
282−}
283−
284−/** Component paths as a manifest or entry gives them: strings or a list, `./` kept off, `.` meaning the root. */
285−function paths(value: unknown): string[] {
286− return strList(value).map((p) => (p === "." ? "." : p.replace(/^\.\//, "").replace(/\/+$/, ""))).filter((p) => p && !p.startsWith("/") && !p.split("/").includes(".."));
287−}
288−
289−const ENTRY_KEYS = ["name", "source", "description", "version", "author", "homepage", "repository", "license", "keywords", "category", "tags", "skills", "commands", "agents", "mcpServers", "strict", "displayName"];
290−
291−/** A plugin entry read, or why it can't be. */
292−export function parseMarketplaceEntry(raw: unknown): Checked<MarketplacePluginEntry> {
293− if (!raw || typeof raw !== "object") return bad("A plugin entry is an object.");
294− const e = raw as Record<string, unknown>;
295− const nameProblem = pluginNameProblem(e.name);
296− if (nameProblem) return bad(nameProblem);
297− if (e.source === undefined) {
298− return bad(`${String(e.name)} has no source.`);
299− }
300− const source = parsePluginSource(e.source);
301− if (!source.ok) return bad(`${String(e.name)}: ${source.message}`);
302− const extra: Record<string, unknown> = {};
303− for (const [key, value] of Object.entries(e)) if (!ENTRY_KEYS.includes(key)) extra[key] = value;
304− return {
305− ok: true,
306− value: {
307− name: e.name as string,
308− source: source.value,
309− description: (str(e.description) ?? "").slice(0, 2000),
310− version: str(e.version) ?? null,
311− author: person(e.author),
312− homepage: str(e.homepage) ?? null,
313− repository: str(e.repository) ?? null,
314− license: str(e.license) ?? null,
315− keywords: strList(e.keywords).slice(0, 30),
316− category: str(e.category)?.toLowerCase() ?? null,
317− tags: strList(e.tags).slice(0, 30),
318− skills: paths(e.skills),
319− commands: paths(e.commands),
320− agents: paths(e.agents),
321− mcpServers: e.mcpServers && typeof e.mcpServers === "object" && !Array.isArray(e.mcpServers) ? (e.mcpServers as Record<string, unknown>) : null,
322− strict: e.strict !== false,
323− displayName: str(e.displayName) ?? null,
324− extra,
325− },
326− };
327−}
328−
329−/** `marketplace.json` read, its unreadable entries noted rather than refused, as Claude Code loads it. */
330−export function parseMarketplaceManifest(raw: unknown): Checked<MarketplaceManifest> {
331− if (!raw || typeof raw !== "object" || Array.isArray(raw)) return bad("marketplace.json is an object with a name, an owner and plugins.");
332− const m = raw as Record<string, unknown>;
333− const nameProblem = marketplaceNameProblem(m.name);
334− if (nameProblem) return bad(nameProblem);
335− const owner = person(m.owner);
336− if (!owner) return bad("A marketplace names its owner.");
337− if (!Array.isArray(m.plugins)) return bad("A marketplace lists its plugins.");
338− const metadata = m.metadata && typeof m.metadata === "object" ? (m.metadata as Record<string, unknown>) : {};
339− const plugins: MarketplacePluginEntry[] = [];
340− const problems: string[] = [];
341− const names = new Set<string>();
342− m.plugins.forEach((entry, i) => {
343− const read = parseMarketplaceEntry(entry);
344− if (!read.ok) return problems.push(`Plugin ${i + 1}: ${read.message}`);
345− if (names.has(read.value.name)) return problems.push(`Plugin ${i + 1}: ${read.value.name} is listed twice.`);
346− names.add(read.value.name);
347− plugins.push(read.value);
348− });
349− const pluginRoot = str(metadata.pluginRoot);
350− return {
351− ok: true,
352− value: {
353− name: m.name as string,
354− owner,
355− description: str(m.description) ?? str(metadata.description) ?? null,
356− version: str(m.version) ?? str(metadata.version) ?? null,
357− pluginRoot: pluginRoot ? pluginRoot.replace(/^\.\//, "").replace(/\/+$/, "") : null,
358− plugins,
359− problems,
360− },
361− };
362−}
363−
364−// ── plugin.json ──────────────────────────────────────────────────────────
365−
366−export type PluginManifest = {
367− name: string;
368− displayName: string | null;
369− version: string | null;
370− description: string;
371− author: { name: string; email?: string; url?: string } | null;
372− homepage: string | null;
373− repository: string | null;
374− license: string | null;
375− keywords: string[];
376− /** Component paths besides the standard folders, as given (`./` kept off; `.` the root). */
377− skills: string[];
378− commands: string[];
379− agents: string[];
380− hooks: string[];
381− /** MCP servers given in the manifest itself (the same shape as .mcp.json's `mcpServers`), or a path to a file. */
382− mcpServers: Record<string, unknown> | string | null;
383− lspServers: boolean;
384− extra: Record<string, unknown>;
385−};
386−
387−const MANIFEST_KEYS = ["$schema", "name", "displayName", "version", "description", "author", "homepage", "repository", "license", "keywords", "skills", "commands", "agents", "hooks", "mcpServers", "lspServers"];
388−
389−/** `.claude-plugin/plugin.json` read; only the name is required. */
390−export function parsePluginManifest(raw: unknown): Checked<PluginManifest> {
391− if (!raw || typeof raw !== "object" || Array.isArray(raw)) return bad("plugin.json is an object naming the plugin.");
392− const p = raw as Record<string, unknown>;
393− const nameProblem = pluginNameProblem(p.name);
394− if (nameProblem) return bad(nameProblem);
395− const extra: Record<string, unknown> = {};
396− for (const [key, value] of Object.entries(p)) if (!MANIFEST_KEYS.includes(key)) extra[key] = value;
397− const hooks = typeof p.hooks === "string" ? paths(p.hooks) : p.hooks && typeof p.hooks === "object" ? ["(inline)"] : [];
398− return {
399− ok: true,
400− value: {
401− name: p.name as string,
402− displayName: str(p.displayName) ?? null,
403− version: str(p.version) ?? null,
404− description: (str(p.description) ?? "").slice(0, 2000),
405− author: person(p.author),
406− homepage: str(p.homepage) ?? null,
407− repository: str(p.repository) ?? null,
408− license: str(p.license) ?? null,
409− keywords: strList(p.keywords).slice(0, 30),
410− skills: paths(p.skills),
411− commands: paths(p.commands),
412− agents: paths(p.agents),
413− hooks,
414− mcpServers: typeof p.mcpServers === "string" ? p.mcpServers.replace(/^\.\//, "") : p.mcpServers && typeof p.mcpServers === "object" ? (p.mcpServers as Record<string, unknown>) : null,
415− lspServers: !!p.lspServers,
416− extra,
417− },
418− };
419−}
420−
421−// ── .mcp.json ────────────────────────────────────────────────────────────
422−
423−export type McpTransport = "http" | "sse" | "stdio" | "ws" | "sdk";
424−
425−/** One server as `.mcp.json` names it, read. Remote servers are what g1t's own agents can use; stdio ones run only where Claude Code runs. */
426−export type McpServerConfig = {
427− name: string;
428− type: McpTransport;
429− url: string | null;
430− headers: Record<string, string>;
431− command: string | null;
432− args: string[];
433− env: Record<string, string>;
434− /** The `oauth` block, when the file carries one. */
435− oauth: Record<string, unknown> | null;
436− /** `${VAR}` references the config makes, by variable name, for the setup screen. */
437− variables: string[];
438−};
439−
440−const VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g;
441−
442−function variablesIn(...values: unknown[]): string[] {
443− const out = new Set<string>();
444− const walk = (value: unknown) => {
445− if (typeof value === "string") for (const m of value.matchAll(VARIABLE)) out.add(m[1]!);
446− else if (Array.isArray(value)) value.forEach(walk);
447− else if (value && typeof value === "object") Object.values(value as Record<string, unknown>).forEach(walk);
448− };
449− values.forEach(walk);
450− return [...out].sort();
451−}
452−
453−const strMap = (value: unknown): Record<string, string> => {
454− const out: Record<string, string> = {};
455− if (value && typeof value === "object" && !Array.isArray(value)) for (const [k, v] of Object.entries(value as Record<string, unknown>)) if (typeof v === "string") out[k] = v;
456− return out;
457−};
458−
459−/** The servers of an `.mcp.json` (or a manifest's `mcpServers`), those that can't be read noted. */
460−export function parseMcpServers(raw: unknown): { servers: McpServerConfig[]; problems: string[] } {
461− const servers: McpServerConfig[] = [];
462− const problems: string[] = [];
463− const map = raw && typeof raw === "object" && !Array.isArray(raw) ? ((raw as Record<string, unknown>).mcpServers ?? raw) : null;
464− if (!map || typeof map !== "object" || Array.isArray(map)) return { servers, problems: ["mcpServers is an object of servers by name."] };
465− for (const [name, value] of Object.entries(map as Record<string, unknown>)) {
466− if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name)) {
467− problems.push(`${name}: a server's name is letters, digits, dots, underscores and hyphens.`);
468− continue;
469− }
470− if (!value || typeof value !== "object") {
471− problems.push(`${name}: a server is an object.`);
472− continue;
473− }
474− const s = value as Record<string, unknown>;
475− const url = str(s.url);
476− const command = str(s.command);
477− let type: McpTransport;
478− const given = typeof s.type === "string" ? s.type : null;
479− if (given === "http" || given === "streamable-http") type = "http";
480− else if (given === "sse" || given === "ws" || given === "sdk" || given === "stdio") type = given;
481− else if (given === null && command) type = "stdio";
482− else if (given === null && url) {
483− // Claude Code treats a url without a type as stdio and skips it; g1t says so instead.
484− problems.push(`${name}: a remote server says its type (http or sse).`);
485− continue;
486− } else {
487− problems.push(`${name}: g1t doesn't know the transport "${String(given)}".`);
488− continue;
489− }
490− if ((type === "http" || type === "sse" || type === "ws") && !url) {
491− problems.push(`${name}: a ${type} server has a url.`);
492− continue;
493− }
494− if (type === "stdio" && !command) {
495− problems.push(`${name}: a stdio server has a command.`);
496− continue;
497− }
498− servers.push({
499− name,
500− type,
501− url: url ?? null,
502− headers: strMap(s.headers),
503− command: command ?? null,
504− args: strList(s.args),
505− env: strMap(s.env),
506− oauth: s.oauth && typeof s.oauth === "object" ? (s.oauth as Record<string, unknown>) : null,
507− variables: variablesIn(s.url, s.headers, s.command, s.args, s.env),
508− });
509− }
510− return { servers, problems };
511−}
512−
513−// ── Subagents ────────────────────────────────────────────────────────────
514−
515−/** `agents/<name>.md` read: the front-matter Claude Code reads, and the system prompt. */
516−export type SubagentFile = {
517− name: string;
518− description: string;
519− /** The tools it may use, as named in the file (Claude Code's names, or MCP tool names); empty means every tool. */
520− tools: string[];
521− disallowedTools: string[];
522− model: string | null;
523− /** Skills to preload, by name. */
524− skills: string[];
525− mcpServers: string[];
526− color: string | null;
527− /** The system prompt: the body after the front-matter. */
528− prompt: string;
529− extra: Record<string, unknown>;
530−};
531−
532−const AGENT_KEYS = ["name", "description", "tools", "disallowedTools", "model", "skills", "mcpServers", "color"];
533−
534−/** A subagent's name: at most 256 characters, no colon, not starting with a hyphen. */
535−export function subagentNameProblem(name: unknown): string | null {
536− if (typeof name !== "string" || !name.trim()) return "A subagent has a name.";
537− if (name.length > 256) return "A subagent's name is at most 256 characters.";
538− if (name.includes(":") || name.startsWith("-")) return "A subagent's name has no colon and doesn't start with a hyphen.";
539− return null;
540−}
541−
542−/** A tools list as Claude Code writes it: a comma-separated string or a list. */
543−function toolList(value: unknown): string[] {
544− if (Array.isArray(value)) return strList(value);
545− if (typeof value !== "string") return [];
546− return value
547− .split(/[,\s]+/)
548− .map((t) => t.trim())
549− .filter(Boolean);
550−}
551−
552−/**
553− * The file read from its parsed front-matter and body (./skill-format.ts's
554− * `splitFrontMatter` and `parseFrontMatter` give them), or why Claude Code
555− * would skip it.
556− */
557−export function subagentOf(front: Record<string, unknown>, body: string): Checked<SubagentFile> {
558− const nameProblem = subagentNameProblem(front.name);
559− if (nameProblem) return bad(nameProblem);
560− const description = str(front.description);
561− if (!description) return bad(`${String(front.name)} says nothing about when to use it (description).`);
562− const prompt = body.trim();
563− if (!prompt) return bad(`${String(front.name)} has no instructions after the front-matter.`);
564− const extra: Record<string, unknown> = {};
565− for (const [key, value] of Object.entries(front)) if (!AGENT_KEYS.includes(key)) extra[key] = value;
566− return {
567− ok: true,
568− value: {
569− name: (front.name as string).trim(),
570− description: description.slice(0, 2000),
571− tools: toolList(front.tools),
572− disallowedTools: toolList(front.disallowedTools),
573− model: str(front.model) ?? null,
574− skills: strList(front.skills),
575− mcpServers: strList(front.mcpServers),
576− color: str(front.color) ?? null,
577− prompt,
578− extra,
579− },
580− };
581−}
582−
583−// ── A plugin's layout ────────────────────────────────────────────────────
584−
585−/** What a plugin's folder holds, found from its file list and manifests. Paths are inside the plugin. */
586−export type PluginLayout = {
587− /** Folders holding a `SKILL.md`, by path (`skills/pdf`), with the skill's folder name. */
588− skills: { path: string; name: string }[];
589− /** `commands/*.md` files: the older form of a skill, one file each. */
590− commands: { path: string; name: string }[];
591− /** `agents/**.md` files. */
592− agents: { path: string; name: string }[];
593− /** `hooks/hooks.json` (or the manifest's), when present. */
594− hooks: string[];
595− /** `.mcp.json` when present (the manifest may hold servers inline too). */
596− mcp: string | null;
597− lsp: boolean;
598− /** `bin/`: executables on the PATH where Claude Code runs; never on g1t's own agents. */
599− bin: boolean;
600− outputStyles: boolean;
601− readme: string | null;
602−};
603−
604−const mdName = (path: string): string => path.replace(/^.*\//, "").replace(/\.md$/i, "");
605−
606−/**
607− * The plugin's components from its files: the standard folders
608− * (`skills/`, `commands/`, `agents/`) plus whatever the manifest names,
609− * plus the marketplace entry's paths. An entry that is not strict names
610− * its components outright instead of the standard folders, as the public
611− * skills marketplace does (one repository, one plugin per group of
612− * skills). `files` are paths inside the plugin.
613− */
614−export function pluginLayout(files: string[], manifest: PluginManifest | null, entry: MarketplacePluginEntry | null): PluginLayout {
615− const set = new Set(files.map((f) => f.replace(/^\.\//, "")));
616− const has = (path: string) => set.has(path);
617− const under = (dir: string) => [...set].filter((f) => f.startsWith(`${dir}/`));
618− const skillDirs = new Set<string>();
619− const addSkillDir = (dir: string) => {
620− const clean = dir === "." ? "" : dir.replace(/\/+$/, "");
621− if (has(clean ? `${clean}/SKILL.md` : "SKILL.md")) skillDirs.add(clean || ".");
622− else for (const f of under(clean || ".")) if (/(^|\/)SKILL\.md$/.test(f) && f.slice(clean.length + (clean ? 1 : 0)).split("/").length === 2) skillDirs.add(f.replace(/\/SKILL\.md$/, ""));
623− };
624− const standard = !(entry && !entry.strict);
625− if (standard) addSkillDir("skills");
626− for (const dir of [...(manifest?.skills ?? []), ...(entry?.skills ?? [])]) addSkillDir(dir);
627− const commandFiles = new Set<string>();
628− const addCommands = (path: string) => {
629− if (/\.md$/i.test(path) && has(path)) commandFiles.add(path);
630− else for (const f of under(path)) if (/\.md$/i.test(f)) commandFiles.add(f);
631− };
632− if (standard) addCommands("commands");
633− for (const p of [...(manifest?.commands ?? []), ...(entry?.commands ?? [])]) addCommands(p);
634− const agentFiles = new Set<string>();
635− const addAgents = (path: string) => {
636− if (/\.md$/i.test(path) && has(path)) agentFiles.add(path);
637− else for (const f of under(path)) if (/\.md$/i.test(f)) agentFiles.add(f);
638− };
639− if (standard) addAgents("agents");
640− for (const p of [...(manifest?.agents ?? []), ...(entry?.agents ?? [])]) addAgents(p);
641− const hooks = [...(has("hooks/hooks.json") ? ["hooks/hooks.json"] : []), ...(manifest?.hooks ?? []).filter((h) => h === "(inline)" || has(h))];
642− return {
643− skills: [...skillDirs].sort().map((path) => ({ path, name: path === "." ? (manifest?.name ?? entry?.name ?? "skill") : path.replace(/^.*\//, "") })),
644− commands: [...commandFiles].sort().map((path) => ({ path, name: mdName(path) })),
645− agents: [...agentFiles].sort().map((path) => ({ path, name: mdName(path) })),
646− hooks,
647− mcp: has(".mcp.json") ? ".mcp.json" : typeof manifest?.mcpServers === "string" && has(manifest.mcpServers) ? manifest.mcpServers : null,
648− lsp: has(".lsp.json") || !!manifest?.lspServers,
649− bin: under("bin").length > 0,
650− outputStyles: under("output-styles").length > 0,
651− readme: ["README.md", "readme.md", "Readme.md"].find(has) ?? null,
652− };
653−}
654−
655−// ── Where to fetch from ──────────────────────────────────────────────────
656−
657−/**
658− * Where g1t reads a plugin's files from, worked out from its entry and the
659− * marketplace it is in: GitHub (public, by tarball at a commit or ref), a
660− * repository on this g1t, or a zip archive. Everything else is listed but
661− * not installable here, and says why.
662− */
663−export type FetchPlan =
664− | { kind: "github"; repo: string; ref: string | null; sha: string | null; path: string }
665− | { kind: "g1t"; repo: string; ref: string | null; path: string }
666− | { kind: "archive"; url: string; sha256: string | null }
667− | { kind: "unsupported"; why: string };
668−
669−/** The GitHub repository a git URL names, when it is one on github.com. */
670−function githubOfUrl(url: string): { repo: string } | null {
671− const found = githubRepoOf(url);
672− return found && /github\.com/i.test(url) ? { repo: found.repo } : null;
673−}
674−
675−/** A g1t repository a URL names, when the URL is on this g1t (`host`). */
676−function g1tOfUrl(url: string, host: string | null): { repo: string } | null {
677− if (!host) return null;
678− const m = new RegExp(`^https?://(?:www\\.)?${host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?/[A-Za-z0-9._-]+?)(?:\\.git)?/?$`, "i").exec(url);
679− return m ? { repo: m[1]! } : null;
680−}
681−
682−const join = (...parts: (string | null | undefined)[]): string => parts.filter((p) => p && p !== ".").join("/").replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
683−
684−export function fetchPlan(source: PluginSource, marketplace: MarketplaceSource, options: { pluginRoot?: string | null; host?: string | null } = {}): FetchPlan {
685− const host = options.host ?? null;
686− switch (source.source) {
687− case "path": {
688− const path = join(options.pluginRoot && !source.path.startsWith("./") ? options.pluginRoot : null, source.path.replace(/^\.\//, ""));
689− if (marketplace.source === "github") return { kind: "github", repo: marketplace.repo, ref: marketplace.ref ?? null, sha: null, path };
690− if (marketplace.source === "g1t") return { kind: "g1t", repo: marketplace.repo, ref: marketplace.ref ?? null, path };
691− if (marketplace.source === "git") {
692− const gh = githubOfUrl(marketplace.url);
693− if (gh) return { kind: "github", repo: gh.repo, ref: marketplace.ref ?? null, sha: null, path };
694− const own = g1tOfUrl(marketplace.url, host);
695− if (own) return { kind: "g1t", repo: own.repo, ref: marketplace.ref ?? null, path };
696− return { kind: "unsupported", why: "Its marketplace is on a git host g1t doesn't read yet. g1t reads GitHub and repositories here." };
697− }
698− return { kind: "unsupported", why: "Its marketplace was added by a link to its marketplace.json, so a plugin kept beside that file can't be reached. Add the marketplace by its repository instead." };
699− }
700− case "github":
701− return { kind: "github", repo: source.repo, ref: source.ref ?? null, sha: source.sha ?? null, path: "" };
702− case "url":
703− case "git-subdir": {
704− const path = source.source === "git-subdir" ? source.path : "";
705− const gh = githubOfUrl(source.url) ?? (githubRepoProblem(source.url) ? null : { repo: source.url });
706− if (gh) return { kind: "github", repo: gh.repo, ref: source.ref ?? null, sha: source.sha ?? null, path };
707− const own = g1tOfUrl(source.url, host);
708− if (own) return { kind: "g1t", repo: own.repo, ref: source.sha ?? source.ref ?? null, path };
709− return { kind: "unsupported", why: "It is on a git host g1t doesn't read yet. g1t reads GitHub and repositories here." };
710− }
711− case "archive":
712− return { kind: "archive", url: source.url, sha256: source.sha256 ?? null };
713− case "npm":
714− return { kind: "unsupported", why: "It is published on npm, which g1t doesn't install from yet. It still works in Claude Code." };
715− case "command":
716− return { kind: "unsupported", why: "It is produced by a command on the machine that installs it, which g1t can't run. It still works in Claude Code." };
717− }
718−}
719−
720−/** The marketplaces every workspace starts with, in Claude Code's own names. */
721−export const BUILTIN_MARKETPLACES: { name: string; source: MarketplaceSource; about: string }[] = [
722− { name: "claude-plugins-official", source: { source: "github", repo: "anthropics/claude-plugins-official" }, about: "Anthropic's reviewed plugins: tools, connectors and skills from Anthropic and the companies behind them." },
723− { name: "anthropic-agent-skills", source: { source: "github", repo: "anthropics/skills" }, about: "Anthropic's skills: documents, spreadsheets, presentations, PDFs, design, testing and more." },
724−];
+0−20
1−import assert from "node:assert/strict";
2−import { test } from "node:test";
3−
4−import { CUSTOMIZE, CUSTOMIZE_SECTIONS, customizePath, sectionOfListing } from "./customize.ts";
5−
6−test("every listing has a place in Customize", () => {
7− assert.equal(sectionOfListing("integration"), "connectors");
8− assert.equal(sectionOfListing("connector"), "connectors");
9− assert.equal(sectionOfListing("skill"), "skills");
10− assert.equal(sectionOfListing("plugin"), "plugins");
11− assert.equal(sectionOfListing("extension"), "plugins");
12− for (const section of CUSTOMIZE_SECTIONS) assert.equal(CUSTOMIZE[section].id, section);
13−});
14−
15−test("a section's site path names its workspace", () => {
16− assert.equal(customizePath("connectors", "acme"), "/acme/-/customize/connectors");
17− assert.equal(customizePath("skills", "a b"), "/a%20b/-/customize/skills");
18− assert.equal(customizePath("", "acme"), "/acme/-/customize");
19− assert.equal(customizePath("plugins", "acme", "pdf@anthropic-agent-skills"), "/acme/-/customize/plugins/pdf@anthropic-agent-skills");
20−});
+0−352
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− * Customize (docs.g1t.sh/guides/customize/): one place for what shapes
8− * how a workspace's agents work, in three parts, each with what the
9− * workspace has (**Yours**) and what it can add (**Discover**). It is the
10− * Marketplace's successor on the site and the phone.
11− *
12− * - **Skills**: what agents know how to do, in the open SKILL.md format
13− * (./skill-library.ts, ./skill-format.ts): g1t's foundational skills,
14− * the workspace's library, and every skill published in a marketplace.
15− * - **Connectors**: what agents can reach: MCP servers (./claude-plugin.ts
16− * `.mcp.json`, the directory in ./connectors.ts, and any address an
17− * owner adds), and g1t's own integrations (GitHub, trackers, alerts,
18− * model providers).
19− * - **Plugins**: folders in the open plugin format (./claude-plugin.ts),
20− * found in marketplaces, each bringing skills, agents and connectors to
21− * g1t's agents and everything else to Claude Code on a runner.
22− *
23− * Everything installable keeps the names the open ecosystem uses:
24− * `name@marketplace` for a plugin, `plugin:skill` for a skill inside one.
25− * Wire shapes are snake_case. No imports beyond types, so it runs
26− * anywhere: services, the site, the phone.
27− */
28−
29−import type { FetchPlan, MarketplaceSource, McpServerConfig, PluginLayout, SubagentFile } from "./claude-plugin";
30−import type { ConnectorCategory } from "./connectors";
31−import type { ListingKind } from "./marketplace";
32−
33−/** A tool an MCP server offers, as it describes it. */
34−export type McpTool = {
35− name: string;
36− description: string;
37− /** `read` when the server says the tool only reads; `write` otherwise, which is also what an unknown tool is. */
38− kind: "read" | "write";
39− /** Its arguments, as the server describes them (JSON Schema). */
40− input_schema: Record<string, unknown>;
41−};
42−
43−/** What a skill was attached to. */
44−export type SkillScope = "agent" | "team" | "workspace";
45−
46−export type CustomizeSection = "skills" | "connectors" | "plugins";
47−
48−export const CUSTOMIZE_SECTIONS: readonly CustomizeSection[] = ["skills", "connectors", "plugins"];
49−
50−export type CustomizeSectionInfo = {
51− id: CustomizeSection;
52− title: string;
53− /** One line, for the section's row. */
54− description: string;
55− /** Where the site shows it, with `:workspace` to fill in. */
56− sitePath: string;
57−};
58−
59−export const CUSTOMIZE: Record<CustomizeSection, CustomizeSectionInfo> = {
60− skills: {
61− id: "skills",
62− title: "Skills",
63− description: "What agents know how to do: the skills every agent has, your workspace's library, and public skills to add.",
64− sitePath: "/:workspace/-/customize/skills",
65− },
66− connectors: {
67− id: "connectors",
68− title: "Connectors",
69− description: "What agents can reach: MCP servers and the tools your team already uses, connected once and given to agents.",
70− sitePath: "/:workspace/-/customize/connectors",
71− },
72− plugins: {
73− id: "plugins",
74− title: "Plugins",
75− description: "Skills, agents and connectors published together, from any marketplace in the open plugin format.",
76− sitePath: "/:workspace/-/customize/plugins",
77− },
78−};
79−
80−/** The part of Customize that shows a listing of this kind. */
81−export function sectionOfListing(kind: ListingKind): CustomizeSection {
82− if (kind === "integration" || kind === "connector") return "connectors";
83− if (kind === "skill") return "skills";
84− return "plugins";
85−}
86−
87−/** Customize's pages, under `/<workspace>/-/customize`. */
88−export function customizePath(section: CustomizeSection | "" | "requests" | "marketplaces", workspace: string, rest = ""): string {
89− const base = `/${encodeURIComponent(workspace)}/-/customize`;
90− return `${base}${section ? `/${section}` : ""}${rest ? `/${rest}` : ""}`;
91−}
92−
93−// ── Marketplaces ─────────────────────────────────────────────────────────
94−
95−/** A marketplace a workspace reads plugins from, as its page lists it. */
96−export type Marketplace = {
97− /** Its name, as its manifest says and as `name@marketplace` uses. */
98− name: string;
99− source: MarketplaceSource;
100− /** Built in: every workspace has it, and it can't be removed. */
101− builtin: boolean;
102− about: string | null;
103− owner: string | null;
104− /** How many plugins it lists, and how many of those carry skills. */
105− plugins: number;
106− /** RFC 3339: when its manifest was last read, and why it couldn't be, if so. */
107− fetched_at: string | null;
108− problem: string | null;
109− added_by: string | null;
110− added_at: string | null;
111− can_remove: boolean;
112−};
113−
114−/** Whether a plugin from Discover can be installed here, now. */
115−export type PluginAvailability =
116− /** Installable now. */
117− | { kind: "available" }
118− /** The workspace has it (switched off, if `enabled` is false). */
119− | { kind: "installed"; enabled: boolean }
120− /** Listed but not something g1t can fetch (npm, a command, a git host g1t doesn't read), with why. It still works in Claude Code. */
121− | { kind: "unavailable"; why: string }
122− /** g1t's own, announced but not released. */
123− | { kind: "soon" };
124−
125−/** What a plugin would bring, worked out from its layout, before or after installing. */
126−export type PluginComponents = {
127− /** Skills by name (`plugin:name` to agents). */
128− skills: { name: string; description: string | null; path: string }[];
129− /** Subagents by name, as hireable starting points. */
130− agents: { name: string; description: string | null; path: string }[];
131− /** Commands: the older one-file form of a skill. */
132− commands: { name: string; path: string }[];
133− /** MCP servers it names: remote ones become connectors; stdio ones are for Claude Code only. */
134− mcp_servers: { name: string; type: McpServerConfig["type"]; url: string | null; remote: boolean }[];
135− /** What only Claude Code on a runner uses. */
136− hooks: boolean;
137− lsp: boolean;
138− bin: boolean;
139− output_styles: boolean;
140−};
141−
142−/** One plugin as Discover lists it, from a marketplace. */
143−export type PluginListing = {
144− /** `name@marketplace`. */
145− ref: string;
146− name: string;
147− marketplace: string;
148− display_name: string | null;
149− description: string;
150− version: string | null;
151− author: string | null;
152− homepage: string | null;
153− category: string | null;
154− tags: string[];
155− keywords: string[];
156− /** Where g1t would read it from, when it can. */
157− fetch: FetchPlan;
158− availability: PluginAvailability;
159− /** Skills the entry names outright (a skills-only plugin), so Discover can list them without fetching. */
160− skills: string[];
161−};
162−
163−/** A plugin the workspace installed. */
164−export type PluginInstall = {
165− id: string;
166− /** `name@marketplace`. */
167− ref: string;
168− name: string;
169− marketplace: string;
170− display_name: string | null;
171− description: string;
172− version: string | null;
173− /** The commit it was read at, when the source has one; the ref otherwise. */
174− sha: string | null;
175− fetch: FetchPlan;
176− components: PluginComponents;
177− /** What couldn't be read when it was installed. */
178− problems: string[];
179− enabled: boolean;
180− installed_by: string;
181− installed_at: string;
182− /** A newer version in the marketplace, when the listing moved on. */
183− update: { version: string | null; sha: string | null } | null;
184−};
185−
186−/** A plugin's page: the listing, the install if any, and what it holds, read from its source. */
187−export type PluginDetail = {
188− listing: PluginListing;
189− install: PluginInstall | null;
190− components: PluginComponents | null;
191− readme: string | null;
192− /** The plugin's manifest, when it has one. */
193− manifest: { name: string; version: string | null; license: string | null; repository: string | null } | null;
194− /** The subagents in full, for the hire flow. */
195− agents: SubagentFile[];
196− problems: string[];
197− /** The marketplace it is listed in, for the header. */
198− source: { marketplace: string; owner: string | null };
199−};
200−
201−// ── Skills in Discover ───────────────────────────────────────────────────
202−
203−/** One skill as Discover lists it: a skill inside a marketplace's plugin. */
204−export type SkillListing = {
205− /** `plugin:skill`. */
206− ref: string;
207− name: string;
208− plugin: string;
209− marketplace: string;
210− /** The plugin's description until the skill's own is read. */
211− description: string;
212− category: string | null;
213− author: string | null;
214− /** Whether the workspace's library already has it from this plugin. */
215− added: boolean;
216− /** Whether the plugin it is in can be fetched here. */
217− availability: PluginAvailability;
218−};
219−
220−// ── Connectors ───────────────────────────────────────────────────────────
221−
222−/** How a connector signs in to its server. */
223−export type ConnectorAuth = "none" | "header" | "oauth";
224−
225−/** Where a connector came from. */
226−export type ConnectorFrom =
227− | { kind: "directory"; id: string }
228− | { kind: "plugin"; ref: string; server: string }
229− | { kind: "address" }
230− /** An MCP server kept on one agent before connectors existed. */
231− | { kind: "agent"; handle: string };
232−
233−/** Whether a connector can be used now. */
234−export type ConnectorState = "connected" | "needs_sign_in" | "needs_key" | "problem";
235−
236−/** A workspace's connector: an MCP server connected once, then given to agents. */
237−export type WorkspaceConnector = {
238− /** `con_…`. */
239− id: string;
240− /** 2 to 32 lowercase letters, digits and hyphens; tools are offered as `<name>__<tool>`. */
241− name: string;
242− /** For the page: the directory's name, or the name again. */
243− title: string;
244− url: string;
245− category: ConnectorCategory | null;
246− about: string | null;
247− auth: ConnectorAuth;
248− state: ConnectorState;
249− /** Why it is a problem, when it is. */
250− problem: string | null;
251− from: ConnectorFrom;
252− tools: McpTool[];
253− /** RFC 3339: when its tools were last listed. */
254− checked_at: string | null;
255− /** The header's name when `auth` is header (its value is never shown). */
256− header_name: string | null;
257− /** For OAuth: who it is signed in as, if the server said, and when the sign-in was done. */
258− signed_in_as: string | null;
259− signed_in_at: string | null;
260− grants: ConnectorGrant[];
261− added_by: string;
262− added_at: string;
263−};
264−
265−/** Where a connector reaches: every agent, a team's, or one. */
266−export type ConnectorGrant = {
267− id: string;
268− scope: SkillScope;
269− /** The agent's handle, the team's slug, or null for the workspace. */
270− target: string | null;
271− label: string;
272− granted_by: string;
273− granted_at: string;
274−};
275−
276−/** What an owner sends to add a connector. */
277−export type ConnectorInput = {
278− /** From the directory (its id), a plugin's server (`ref` and `server`), or by address. */
279− from: { kind: "directory"; id: string } | { kind: "plugin"; ref: string; server: string } | { kind: "address"; name: string; url: string };
280− auth: ConnectorAuth;
281− /** For `header`: the header's name and its value, kept sealed. */
282− header?: { name: string; value: string } | null;
283−};
284−
285−/** Starting an OAuth sign-in for a connector: where the browser goes. */
286−export type ConnectorSignIn = { url: string };
287−
288−// ── Agents and what reaches them ─────────────────────────────────────────
289−
290−/** A connector as one agent's Abilities tab lists it. */
291−export type AgentConnector = {
292− id: string;
293− name: string;
294− title: string;
295− state: ConnectorState;
296− /** How the agent has it. */
297− via: SkillScope | "agent-only";
298− via_label: string;
299− tools: number;
300−};
301−
302−// ── The bundle a runner gets ─────────────────────────────────────────────
303−
304−/**
305− * What a job's Claude Code receives from the workspace's Customize
306− * (docs.g1t.sh/guides/self-hosted-runners/, "What a run gets from
307− * Customize"): plugins to fetch at their pinned commit, the library's skills
308− * as files, and connectors with the headers that sign them in. Read with
309− * the job's agent token, once per job.
310− */
311−export type CustomizeBundle = {
312− plugins: { ref: string; name: string; fetch: FetchPlan; version: string | null }[];
313− /** Library skills, each a folder: SKILL.md and its files, as `path` and content. */
314− skills: { name: string; plugin: string | null; files: { path: string; content: string; encoding: "utf8" | "base64" }[] }[];
315− /** Connectors the job's agent has, with what to send; secrets included, so this never leaves the runner. */
316− connectors: { name: string; url: string; headers: Record<string, string> }[];
317−};
318−
319−/** Something to hire from a plugin's `agents/` folder, as a template. */
320−export type PluginAgentTemplate = {
321− /** `plugin:agent`. */
322− ref: string;
323− plugin: string;
324− marketplace: string;
325− name: string;
326− description: string;
327− /** The system prompt, as the agent's instructions. */
328− prompt: string;
329− model: string | null;
330− skills: string[];
331− tools: string[];
332−};
333−
334−/** The layout of a plugin turned into what it brings. */
335−export function componentsOf(layout: PluginLayout, servers: McpServerConfig[], skillDescriptions: Record<string, string | null> = {}, agentDescriptions: Record<string, string | null> = {}): PluginComponents {
336− return {
337− skills: layout.skills.map((s) => ({ name: s.name, description: skillDescriptions[s.path] ?? null, path: s.path })),
338− agents: layout.agents.map((a) => ({ name: a.name, description: agentDescriptions[a.path] ?? null, path: a.path })),
339− commands: layout.commands.map((c) => ({ name: c.name, path: c.path })),
340− mcp_servers: servers.map((s) => ({ name: s.name, type: s.type, url: s.url, remote: s.type === "http" || s.type === "sse" })),
341− hooks: layout.hooks.length > 0,
342− lsp: layout.lsp,
343− bin: layout.bin,
344− output_styles: layout.outputStyles,
345− };
346−}
347−
348−/** The most marketplaces a workspace adds, and the most plugins it installs. */
349−export const MAX_MARKETPLACES = 20;
350−export const MAX_PLUGIN_INSTALLS = 100;
351−/** The most connectors a workspace keeps. */
352−export const MAX_CONNECTORS = 50;
+0−4
1313 export * from "./codeowners";
1414 export * from "./compute";
1515 export * from "./connectors";
16−export * from "./claude-plugin";
1716 export * from "./coding-agents";
18−export * from "./customize";
1917 export * from "./d1";
2018 export * from "./datasets";
2119 export * from "./discovery";
3432 export * from "./advisories";
3533 export * from "./inbox";
3634 export * from "./instances";
37−export * from "./marketplace";
3835 export * from "./ids";
3936 export * from "./integrations";
4037 export * from "./members";
5552 export * from "./search";
5653 export * from "./security";
5754 export * from "./security-suite";
58−export * from "./skills";
5955 export * from "./status";
6056 export * from "./teams";
6157 export * from "./people";
+0−73
1−import assert from "node:assert/strict";
2−import { test } from "node:test";
3−
4−import { FIRST_PARTY_EXTENSIONS, type ExtensionManifest, checkManifest, dataDisclosure, extensionFrameUrl, parseListing, versionOfTag } from "./marketplace.ts";
5−import { isScope } from "./scopes.ts";
6−
7−const published: ExtensionManifest = {
8− ...FIRST_PARTY_EXTENSIONS[0]!,
9− id: "standup",
10− status: "available",
11− source: { repo: "flagon-io/standup", tag: "v1.2.0" },
12− version: "1.2.0",
13−};
14−
15−test("g1t's own extensions are listed as coming, with manifests publishing would accept", () => {
16− const ids = new Set<string>();
17− for (const extension of FIRST_PARTY_EXTENSIONS) {
18− assert.ok(!ids.has(extension.id), extension.id);
19− ids.add(extension.id);
20− assert.equal(extension.status, "soon", `${extension.id} isn't published yet`);
21− assert.equal(extension.source, null);
22− assert.equal(extension.pricing, null);
23− assert.equal(extension.publisher.tier, "official");
24− const checked = checkManifest(extension, isScope);
25− assert.ok(checked.ok, `${extension.id}: ${checked.ok ? "" : checked.message}`);
26− }
27−});
28−
29−test("publishing checks a manifest strictly", () => {
30− assert.ok(checkManifest(published, isScope).ok);
31− const refused = (changes: Partial<ExtensionManifest> | Record<string, unknown>, why: RegExp) => {
32− const checked = checkManifest({ ...published, ...changes }, isScope);
33− assert.equal(checked.ok, false, JSON.stringify(changes));
34− if (!checked.ok) assert.match(checked.message, why);
35− };
36− refused({ id: "Bad Id" }, /id/);
37− refused({ scopes: ["everything:admin"] }, /scope/);
38− refused({ source: null }, /source/);
39− refused({ domains: ["not a host"] }, /domains/);
40− refused({ runtime: "connected", domains: [] }, /declares the domains/);
41− refused({ publisher: { name: "anyone", tier: "community" } }, /Community extensions run on their publisher's servers/);
42− refused({ pricing: { monthly: 5 } }, /free/);
43− refused({ permissions: [] }, /permissions/);
44− refused({ ui: { entry: "index.html" } }, /ui.entry/);
45− refused({ bridges: " " }, /bridges/);
46− refused({ adds: { pages: [], agent_roles: [], tools: [], notifications: [] } }, /cards/);
47− // A community extension that runs on its own servers, declaring them, is fine.
48− assert.ok(checkManifest({ ...published, runtime: "connected", publisher: { name: "anyone", tier: "community" }, domains: ["api.example.com"] }, isScope).ok);
49−});
50−
51−test("the install screen says where data goes", () => {
52− assert.equal(dataDisclosure({ domains: [] }), "Its data stays in g1t.");
53− assert.equal(dataDisclosure({ domains: ["api.acme.dev", "eu.acme.dev"] }), "Data leaves g1t to api.acme.dev, eu.acme.dev.");
54− assert.equal(dataDisclosure({ domains: [], bridges: "the CRM you connect" }), "Data leaves g1t to the CRM you connect.");
55−});
56−
57−test("an extension's page loads from the user-content origin, by version", () => {
58− assert.equal(extensionFrameUrl(published, "https://g1tusercontent.com/"), "https://g1tusercontent.com/x/standup/1.2.0/index.html");
59− assert.equal(extensionFrameUrl({ ...published, ui: null }, "https://g1tusercontent.com"), null);
60− assert.equal(extensionFrameUrl(FIRST_PARTY_EXTENSIONS[0]!, "https://g1tusercontent.com"), null, "nothing published, nothing to load");
61−});
62−
63−test("a tag publishes a version", () => {
64− assert.equal(versionOfTag("v1.4.0"), "1.4.0");
65− assert.equal(versionOfTag("2.0.0-beta.1"), "2.0.0-beta.1");
66− assert.equal(versionOfTag("latest"), null);
67−});
68−
69−test("listings are referenced by kind and id", () => {
70− assert.deepEqual(parseListing("extension:on-call"), { kind: "extension", id: "on-call" });
71− assert.equal(parseListing("app:on-call"), null);
72− assert.equal(parseListing("agent:engineering"), null, "agents start from templates in Agents, not the Marketplace");
73−});
+0−410
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 Marketplace: what adds functionality to a workspace, the installs it
8− * has, and members' requests to add things. Extensions add pages, data and
9− * cards; integrations connect what a team already uses so agents can work
10− * with it. Agents are not listed here: Agents mode has templates to start
11− * one from. Anyone in a workspace browses the Marketplace; only owners add
12− * things. A member asks instead, and the request reaches every owner as a
13− * notification.
14− *
15− * Every listing has a reference, `<kind>:<id>`:
16− *
17− * - `integration:<connector>`: a connector from the catalog
18− * (./connectors.ts); the integrations service keeps the connection;
19− * - `extension:<id>`: an extension, described by its manifest
20− * (`ExtensionManifest`): who publishes it, where its source is, the
21− * scopes it asks for, the domains its data goes to, the page it shows
22− * inside g1t, and its pricing. An install (`ExtensionInstall`) pins a
23− * version and can be switched off at once.
24− *
25− * Extensions are shared the way workflow actions are: a public
26− * repository with a manifest, where tagging a release publishes a
27− * version, and an install keeps the version it was installed at until an
28− * owner takes an update. Nothing is paid yet, but listings carry a
29− * pricing field and installs a plan, so paid listings are billing work.
30− *
31− * The agents service keeps installs (`extension_installs`) and requests
32− * (`install_requests`). Wire shapes are snake_case. No imports, so services
33− * test it under Node.
34− */
35−
36−/**
37− * What a listing adds to a workspace: a g1t integration, a plugin
38− * (`plugin:<name@marketplace>`), a skill from a marketplace
39− * (`skill:<name@marketplace>/<skill>`), a connector from the directory
40− * (`connector:<id>`), or an extension (g1t's own, announced). Requests
41− * (./customize.ts) name any of them.
42− */
43−export type ListingKind = "integration" | "extension" | "plugin" | "skill" | "connector";
44−
45−export const LISTING_KINDS: readonly ListingKind[] = ["integration", "extension", "plugin", "skill", "connector"];
46−
47−/** Whether a listing can be added today, or is planned. */
48−export type ListingStatus = "available" | "soon";
49−
50−/**
51− * Where an extension's server code runs: on g1t (official, verified and
52− * internal publishers, and community code once it has an isolated
53− * sandbox), or on the publisher's own servers, reached over HTTPS.
54− */
55−export type ExtensionRuntime = "hosted" | "connected";
56−
57−/** The workspace areas an extension can add to. */
58−export type ExtensionAdds = {
59− /** Its pages, in its sidebar, in order. */
60− pages: string[];
61− /** Agent roles it brings: templates, configured like any other agent. */
62− agent_roles: string[];
63− /** Cards it shows in chat and elsewhere, in words. */
64− cards: string[];
65− /** Tools agents can call. */
66− tools: string[];
67− /** Notification kinds it sends. */
68− notifications: string[];
69−};
70−
71−/**
72− * An extension, as its manifest describes it. A published one comes from
73− * `.g1t/extension.json` in its source repository at a tag; first-party
74− * listings not yet published (`status: "soon"`) have no source yet.
75− */
76−export type ExtensionManifest = {
77− /** Lowercase letters, digits and hyphens: `support`. */
78− id: string;
79− name: string;
80− /** One line, for cards. */
81− tagline: string;
82− /** A paragraph, for its page. */
83− description: string;
84− category: string;
85− publisher: { name: string; tier: ListingTier };
86− status: ListingStatus;
87− /** Its public repository on g1t and the tag a version was published from; null until it is published. */
88− source: { repo: string; tag: string } | null;
89− /** The version a tag published (`1.4.0`); null until it is published. */
90− version: string | null;
91− runtime: ExtensionRuntime;
92− /** The g1t scopes its token asks for (./scopes.ts), shown in plain words before install. */
93− scopes: string[];
94− /** What it can do, in plain words, as the install screen lists it. */
95− permissions: string[];
96− /**
97− * Every host outside g1t its data goes to. The install screen says
98− * "Data leaves g1t to …" for each; calls anywhere else are refused.
99− * Empty: its data stays in g1t.
100− */
101− domains: string[];
102− /**
103− * The system outside g1t it bridges, in words (`the CRM you connect`),
104− * when its data goes wherever the workspace points it rather than to
105− * fixed domains. The install screen says so. Absent or null: none.
106− */
107− bridges?: string | null;
108− /**
109− * Its page inside g1t: a path served from the user-content domain
110− * (g1tusercontent.com, or a self-hosted instance's own), loaded in a
111− * sandboxed frame that reaches g1t only through the bridge. Null: no page.
112− */
113− ui: { entry: string } | null;
114− adds: ExtensionAdds;
115− /** Empty while every listing is free; a later price list goes here. */
116− pricing: null;
117−};
118−
119−/** An extension installed in a workspace. */
120−export type ExtensionInstall = {
121− id: string;
122− /** `extension:<id>`. */
123− listing: string;
124− /** The version installed; updates are offered, never forced. */
125− version: string;
126− /** The plan it is on: `free` until listings have prices. */
127− plan: string;
128− installed_by: string;
129− /** RFC 3339. */
130− installed_at: string;
131− /** Off: the kill switch. Its token stops working and its page doesn't load. */
132− enabled: boolean;
133− /** Who last turned it off, and when; null while it is on. */
134− disabled_by: string | null;
135− disabled_at: string | null;
136− /** Its monthly spend cap, in micro-dollars; null: the workspace's limit applies. */
137− budget_monthly_micros: number | null;
138−};
139−
140−/** The plan an install records while nothing is paid. */
141−export const FREE_PLAN = "free";
142−
143−/** A manifest's checks, as publishing applies them; `knownScope` says which scopes exist (./scopes.ts `isScope`). */
144−export function checkManifest(raw: unknown, knownScope: (scope: string) => boolean): { ok: true; value: ExtensionManifest } | { ok: false; message: string } {
145− const m = raw as Partial<ExtensionManifest> | null;
146− const bad = (message: string) => ({ ok: false as const, message });
147− if (!m || typeof m !== "object") return bad("The manifest isn't a JSON object.");
148− if (typeof m.id !== "string" || !/^[a-z][a-z0-9-]{1,31}$/.test(m.id)) return bad("`id` is 2 to 32 lowercase letters, digits and hyphens, starting with a letter.");
149− for (const field of ["name", "tagline", "description", "category"] as const) {
150− if (typeof m[field] !== "string" || !m[field]!.trim()) return bad(`\`${field}\` is required.`);
151− }
152− if (!m.publisher || typeof m.publisher.name !== "string" || !LISTING_TIERS.includes(m.publisher.tier as ListingTier)) return bad("`publisher` needs a name and a tier.");
153− if (m.status !== "available" && m.status !== "soon") return bad("`status` is available or soon.");
154− if (m.runtime !== "hosted" && m.runtime !== "connected") return bad("`runtime` is hosted or connected.");
155− if (m.runtime === "hosted" && m.publisher.tier === "community") return bad("Community extensions run on their publisher's servers (`connected`) until hosted code has an isolated sandbox.");
156− if (m.status === "available" && (!m.source || !m.version)) return bad("A published extension names its source repository, tag and version.");
157− if (m.source && (typeof m.source.repo !== "string" || !/^[a-z0-9-]+\/[A-Za-z0-9._-]+$/.test(m.source.repo) || typeof m.source.tag !== "string" || !m.source.tag)) return bad("`source` is a repository (`owner/name`) and a tag.");
158− if (!Array.isArray(m.scopes) || m.scopes.some((scope) => typeof scope !== "string" || !knownScope(scope))) return bad("Every scope must be one g1t has.");
159− if (!Array.isArray(m.permissions) || m.permissions.length === 0) return bad("List what it can do in `permissions`, in plain words.");
160− if (!Array.isArray(m.domains) || m.domains.some((domain) => typeof domain !== "string" || !isHost(domain))) return bad("`domains` are host names, such as api.example.com.");
161− if (m.runtime === "connected" && m.domains.length === 0) return bad("A connected extension declares the domains it runs on.");
162− if (m.bridges != null && (typeof m.bridges !== "string" || !m.bridges.trim())) return bad("`bridges` names the system it connects to, in words.");
163− if (m.ui !== null && (typeof m.ui !== "object" || typeof m.ui?.entry !== "string" || !m.ui.entry.startsWith("/"))) return bad("`ui.entry` is a path, such as /index.html.");
164− if (!m.adds || !["pages", "cards", "agent_roles", "tools", "notifications"].every((k) => Array.isArray((m.adds as Record<string, unknown>)[k]))) return bad("`adds` lists pages, cards, agent_roles, tools and notifications.");
165− if (m.pricing !== null) return bad("Listings are free for now: `pricing` is null.");
166− return { ok: true, value: m as ExtensionManifest };
167−}
168−
169−function isHost(value: string): boolean {
170− return /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(value);
171−}
172−
173−/** What the install screen says about where an extension's data goes. */
174−export function dataDisclosure(manifest: Pick<ExtensionManifest, "domains" | "bridges">): string {
175− const to = [...manifest.domains, ...(manifest.bridges ? [manifest.bridges] : [])];
176− if (to.length === 0) return "Its data stays in g1t.";
177− return `Data leaves g1t to ${to.join(", ")}.`;
178−}
179−
180−/** Where an extension's page loads from: its entry on the user-content origin, under its id and version. */
181−export function extensionFrameUrl(manifest: Pick<ExtensionManifest, "id" | "version" | "ui">, usercontentOrigin: string): string | null {
182− if (!manifest.ui || !manifest.version) return null;
183− return `${usercontentOrigin.replace(/\/+$/, "")}/x/${manifest.id}/${manifest.version}${manifest.ui.entry}`;
184−}
185−
186−/** A version as a tag names it: `v1.4.0` and `1.4.0` are both `1.4.0`; null when it isn't one. */
187−export function versionOfTag(tag: string): string | null {
188− const match = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(tag.trim());
189− return match ? match[1]! : null;
190−}
191−
192−/** What every first-party listing shares before its first release. */
193−const UNRELEASED = {
194− publisher: { name: "g1t", tier: "official" as const },
195− status: "soon" as const,
196− source: null,
197− version: null,
198− runtime: "hosted" as const,
199− ui: { entry: "/index.html" },
200− pricing: null,
201−};
202−
203−/**
204− * g1t's own extensions. None is published yet: each is listed so people
205− * see what is coming, and can't be installed until its first release. The
206− * last three are connected systems: each bridges a system a team already
207− * runs, so agents work across it and g1t together.
208− */
209−export const FIRST_PARTY_EXTENSIONS: ExtensionManifest[] = [
210− {
211− ...UNRELEASED,
212− id: "mail",
213− name: "Mail",
214− tagline: "Email on your own domain, with shared inboxes agents work in.",
215− description:
216− "Email that g1t runs on your domain. Shared inboxes such as support@ and sales@ work like channels: agents sort and draft, and sending needs approval or a rule you set. Spam and phishing filtering is included, at what it costs to run. Gmail and Outlook keep working alongside it.",
217− category: "Communication",
218− scopes: ["notifications:write", "artifacts:read"],
219− permissions: ["Host email for your domain", "Let agents read shared inboxes and draft replies", "Send only with approval or a rule you set"],
220− domains: [],
221− adds: { pages: ["Inbox", "Shared inboxes", "Sent", "Rules for agents"], cards: ["Email threads in chat"], agent_roles: [], tools: ["mail", "thread"], notifications: ["Drafts to approve"] },
222− },
223− {
224− ...UNRELEASED,
225− id: "support",
226− name: "Support",
227− tagline: "Customer conversations an agent answers from your docs, escalating the rest.",
228− description:
229− "Conversations, escalations, macros and a knowledge base. A support agent answers what your docs cover, turns bug reports into issues in Code, and hands anything else to a person. Email to customers waits for approval.",
230− category: "Customers",
231− scopes: ["issues:write", "artifacts:read", "notifications:write"],
232− permissions: ["Read and reply to support email, with your approval", "Store conversations in workspace data", "Open issues from bug reports"],
233− domains: [],
234− adds: {
235− pages: ["Conversations", "Escalations", "Macros", "Knowledge"],
236− cards: ["Customer cards in chat", "Bug reports as issues in Code"],
237− agent_roles: ["Support Specialist"],
238− tools: ["conversation", "macro"],
239− notifications: ["Escalations"],
240− },
241− },
242− {
243− ...UNRELEASED,
244− id: "crm",
245− name: "CRM",
246− tagline: "Accounts, deals and a pipeline, with agents that keep it current.",
247− description:
248− "Accounts and contacts, deals by stage and a weekly forecast, kept as workspace data. Agents log calls and email, draft follow-ups for a person to send, and flag deals that have gone quiet.",
249− category: "Customers",
250− scopes: ["artifacts:write", "notifications:write"],
251− permissions: ["Store accounts, contacts and deals in workspace data", "Read email threads with customers in shared inboxes", "Draft follow-ups that a person sends"],
252− domains: [],
253− adds: { pages: ["Pipeline", "Deals", "Accounts", "Forecast"], cards: ["Account cards in chat"], agent_roles: ["Sales Ops"], tools: ["deal", "account"], notifications: ["Deals gone quiet"] },
254− },
255− {
256− ...UNRELEASED,
257− id: "recruiting",
258− name: "Recruiting",
259− tagline: "Openings, candidates and interview loops. Agents screen, people decide.",
260− description:
261− "A pipeline board, scorecards and scheduling. A recruiting agent screens applicants against your scorecard and books interview loops; offers and decisions stay with people.",
262− category: "People",
263− scopes: ["artifacts:write", "notifications:write"],
264− permissions: ["Store candidates and openings in workspace data", "Read and write interview events on connected calendars", "Email candidates from a shared address, with your approval"],
265− domains: [],
266− adds: {
267− pages: ["Pipeline", "Openings", "Candidates", "Interviews", "Offers"],
268− cards: ["Candidate cards in chat"],
269− agent_roles: ["Recruiter"],
270− tools: ["candidate", "opening"],
271− notifications: ["Offers to sign"],
272− },
273− },
274− {
275− ...UNRELEASED,
276− id: "on-call",
277− name: "On-call",
278− tagline: "Rotations, pages and who is on call now.",
279− description: "Rotations and schedules, pages through notifications, and an incident channel an operations agent keeps a timeline in.",
280− category: "Engineering",
281− scopes: ["notifications:write", "issues:read"],
282− permissions: ["Store rotations in workspace data", "Page people through their notifications", "Read issues an incident links to"],
283− domains: [],
284− adds: { pages: ["Rotations", "Pages", "Incidents"], cards: ["Who's on call, in chat"], agent_roles: [], tools: ["rotation", "page"], notifications: ["Pages", "Your shift"] },
285− },
286− {
287− ...UNRELEASED,
288− id: "helpdesk-bridge",
289− name: "Helpdesk bridge",
290− tagline: "Work tickets from the helpdesk you already run, next to your code and chat.",
291− description:
292− "Connects the helpdesk your team already uses. Its tickets show in g1t with the customer and the conversation; agents draft replies there for a person to send, and a bug report becomes an issue in Code that stays linked to its ticket.",
293− category: "Connected systems",
294− scopes: ["issues:write", "notifications:write"],
295− permissions: ["Read tickets and customers in the helpdesk you connect", "Draft replies there, sent by a person or a rule you set", "Open issues in Code linked to a ticket"],
296− domains: [],
297− bridges: "the helpdesk you connect",
298− adds: { pages: ["Tickets", "Linked issues"], cards: ["Ticket cards in chat"], agent_roles: [], tools: ["ticket"], notifications: ["Tickets assigned to you"] },
299− },
300− {
301− ...UNRELEASED,
302− id: "crm-bridge",
303− name: "CRM bridge",
304− tagline: "The accounts and deals in the CRM you already use, in g1t and in chat.",
305− description:
306− "Connects the CRM your team already uses. Accounts and deals show in g1t and in chat, and agents log calls, update stages and draft follow-ups there, each change in the audit log.",
307− category: "Connected systems",
308− scopes: ["notifications:write"],
309− permissions: ["Read accounts, contacts and deals in the CRM you connect", "Log activity and update deal stages there", "Draft follow-ups that a person sends"],
310− domains: [],
311− bridges: "the CRM you connect",
312− adds: { pages: ["Accounts", "Deals"], cards: ["Account cards in chat"], agent_roles: [], tools: ["account", "deal"], notifications: ["Deals gone quiet"] },
313− },
314− {
315− ...UNRELEASED,
316− id: "erp-bridge",
317− name: "ERP bridge",
318− tagline: "Orders, invoices and stock from your ERP, for agents that answer and reconcile.",
319− description:
320− "Connects the ERP your business runs on. Agents look up orders, invoices and stock to answer questions in chat and support, and reconcile what doesn't match. Anything that changes money or stock waits for a person's approval.",
321− category: "Connected systems",
322− scopes: ["notifications:write"],
323− permissions: ["Read orders, invoices and stock in the ERP you connect", "Propose changes there, each approved by a person"],
324− domains: [],
325− bridges: "the ERP you connect",
326− adds: { pages: ["Orders", "Invoices", "Approvals"], cards: ["Order cards in chat"], agent_roles: [], tools: ["order", "invoice"], notifications: ["Changes to approve"] },
327− },
328−];
329−
330−/** A first-party extension by id. */
331−export function extensionById(id: string): ExtensionManifest | undefined {
332− return FIRST_PARTY_EXTENSIONS.find((extension) => extension.id === id);
333−}
334−
335−/**
336− * Who stands behind a listing: g1t itself, a reviewed publisher, anyone,
337− * or the workspace's own people.
338− */
339−export type ListingTier = "official" | "verified" | "community" | "internal";
340−
341−export const LISTING_TIERS: readonly ListingTier[] = ["official", "verified", "community", "internal"];
342−
343−/**
344− * Who stands behind every integration: the connector catalog
345− * (./connectors.ts) is g1t's own, so each connector is Official.
346− */
347−export const CONNECTOR_PUBLISHER: { name: string; tier: ListingTier } = { name: "g1t", tier: "official" };
348−
349−/** A listing's reference, split: `integration:sentry` is `{ kind: "integration", id: "sentry" }`. */
350−export type ListingRef = { kind: ListingKind; id: string };
351−
352−/** Where a request stands: waiting on an owner, added, or turned down. */
353−export type InstallRequestStatus = "open" | "done" | "declined";
354−
355−export const INSTALL_REQUEST_STATUSES: readonly InstallRequestStatus[] = ["open", "done", "declined"];
356−
357−/** A member's request that the workspace add a listing. */
358−export type InstallRequest = {
359− id: string;
360− /** `<kind>:<id>`. */
361− listing: string;
362− kind: ListingKind;
363− /** The listing's name when it was asked for, such as `Sentry` or `Support`. */
364− name: string;
365− /** Why they want it, in their words, or null. */
366− note: string | null;
367− /** Who asked, by username. */
368− requested_by: string;
369− /** RFC 3339. */
370− requested_at: string;
371− status: InstallRequestStatus;
372− /** The owner who added it or turned it down, by username. */
373− resolved_by: string | null;
374− /** RFC 3339. */
375− resolved_at: string | null;
376−};
377−
378−/**
379− * Requests as one person sees them: an owner, every request in the
380− * workspace (`can_resolve`); anyone else, their own. Open ones first, then
381− * the latest; answered ones for 30 days.
382− */
383−export type InstallRequests = { requests: InstallRequest[]; can_resolve: boolean };
384−
385−/** The longest note a request carries. */
386−export const MAX_REQUEST_NOTE = 280;
387−/** The most requests one person keeps open in a workspace. */
388−export const MAX_OPEN_REQUESTS = 20;
389−/** How long an answered request stays listed. */
390−export const ANSWERED_REQUESTS_DAYS = 30;
391−
392−/** A listing's reference: `integration:sentry`. */
393−export function listingRef(kind: ListingKind, id: string): string {
394− return `${kind}:${id}`;
395−}
396−
397−/** A reference split into kind and id; null when it is not one. */
398−export function parseListing(value: unknown): ListingRef | null {
399− if (typeof value !== "string") return null;
400− const match = /^(integration|extension|connector):([a-z0-9][a-z0-9_-]{0,63})$|^(plugin|skill):([A-Za-z0-9][A-Za-z0-9._@/-]{0,199})$/.exec(value.trim());
401− if (match && match[3]) return { kind: match[3] as ListingKind, id: match[4]! };
402− return match ? { kind: match[1] as ListingKind, id: match[2]! } : null;
403−}
404−
405−/** A note as kept: trimmed, at most `MAX_REQUEST_NOTE` characters, null when empty. */
406−export function cleanRequestNote(value: unknown): string | null {
407− if (typeof value !== "string") return null;
408− const note = value.replace(/\s+/g, " ").trim().slice(0, MAX_REQUEST_NOTE);
409− return note || null;
410−}
+0−240
Loading the diff…

Comments

Sign in to comment.