diff --git a/README.md b/README.md index 961422c..c09bce6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ | Plugin | Claude | Codex | Gemini | OpenCode | AgentSkills | | --- | :---: | :---: | :---: | :---: | :---: | -| [`mlx-agent`](https://github.com/cavi-ai/mlx-agent) — discover, verify, and wire local MLX models | ✓ | ✓ | ✓ | ✓ | ✓ | +| [`mlx-agent`](https://github.com/cavi-ai/mlx-agent) — discover, verify, and wire local MLX models | ✓ | ✓ | — | ✓ | ✓ | | [`obsidian-agent`](https://github.com/cavi-ai/obsidian-agent) — portable vault workflows over the official Obsidian CLI | ✓ | ✓ | ✓ | ✓ | ✓ | The machine-readable source of truth is [`catalog.json`](catalog.json). Claude and Codex consume native marketplace projections. Gemini and OpenCode use the discovery records under `providers/`, which link to each source repository's tested installer; those files do not claim a native marketplace protocol. Portable AgentSkills packages remain available from each plugin repository. @@ -37,16 +37,13 @@ These package projections are distribution artifacts, not independent workflow s ## Gemini CLI -Gemini discovery metadata is in [`providers/gemini/catalog.json`](providers/gemini/catalog.json). Each entry records the source repository's native installation command. For example: +Gemini discovery metadata is in [`providers/gemini/catalog.json`](providers/gemini/catalog.json). Each entry records the source repository's native installation command: ```sh -git clone https://github.com/cavi-ai/mlx-agent.git -gemini extensions install ./mlx-agent/providers/gemini - gemini extensions install https://github.com/cavi-ai/obsidian-agent ``` -Both commands use Gemini's native extension loader. The discovery catalog itself does not install anything. +The command uses Gemini's native extension loader. The discovery catalog itself does not install anything. `mlx-agent` does not ship a Gemini extension. ## OpenCode @@ -75,4 +72,4 @@ node --test node scripts/validate-catalog.mjs ``` -Validation rejects identity drift, unknown or missing hosts, duplicate entries, non-plugin products, and projections that disagree with the canonical repository or package path. +Validation rejects identity drift, unknown hosts, packages for undeclared hosts, duplicate entries, non-plugin products, and projections that disagree with the canonical host list, repository, or package path. diff --git a/catalog.json b/catalog.json index 55833a0..2bde1d6 100644 --- a/catalog.json +++ b/catalog.json @@ -7,11 +7,10 @@ "repository": "cavi-ai/mlx-agent", "license": "MIT", "summary": "Discover, verify, and wire local MLX-optimized models on Apple Silicon.", - "hosts": ["claude", "codex", "gemini", "opencode", "agentskills"], + "hosts": ["claude", "codex", "opencode", "agentskills"], "packages": { "claude": { "path": "." }, "codex": { "path": "packages/codex/mlx-agent" }, - "gemini": { "path": "providers/gemini" }, "opencode": { "path": "providers/opencode" }, "agentskills": { "path": "providers/agentskills" } } diff --git a/catalog.schema.json b/catalog.schema.json index 361e15b..f4b8d05 100644 --- a/catalog.schema.json +++ b/catalog.schema.json @@ -32,11 +32,10 @@ "repository": { "type": "string", "pattern": "^cavi-ai/[a-z0-9-]+$" }, "license": { "type": "string", "minLength": 1 }, "summary": { "type": "string", "minLength": 1 }, - "hosts": { "type": "array", "minItems": 5, "maxItems": 5, "uniqueItems": true, "items": { "$ref": "#/$defs/host" } }, + "hosts": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/host" } }, "packages": { "type": "object", "additionalProperties": false, - "required": ["claude", "codex", "gemini", "opencode", "agentskills"], "properties": { "claude": { "$ref": "#/$defs/package" }, "codex": { "$ref": "#/$defs/package" }, diff --git a/providers/gemini/catalog.json b/providers/gemini/catalog.json index 02c9a8b..bfc70e4 100644 --- a/providers/gemini/catalog.json +++ b/providers/gemini/catalog.json @@ -3,12 +3,6 @@ "host": "gemini", "protocol": "discovery", "plugins": [ - { - "name": "mlx-agent", - "repository": "cavi-ai/mlx-agent", - "package": "providers/gemini", - "install": { "command": "git clone https://github.com/cavi-ai/mlx-agent.git && gemini extensions install ./mlx-agent/providers/gemini" } - }, { "name": "obsidian-agent", "repository": "cavi-ai/obsidian-agent", diff --git a/scripts/validate-catalog.mjs b/scripts/validate-catalog.mjs index d5747d4..6e47900 100644 --- a/scripts/validate-catalog.mjs +++ b/scripts/validate-catalog.mjs @@ -19,7 +19,6 @@ const MLX_EXCLUDED = [".mlx-agent-generated-files.json", "skills/*/scripts/mlx-a const MLX_EXCLUSION_REASON = "Codex skills use the packaged CLI entrypoint; MCP transport artifacts are not required or distributed by this catalog."; const DISCOVERY_COMMANDS = { gemini: { - "mlx-agent": "git clone https://github.com/cavi-ai/mlx-agent.git && gemini extensions install ./mlx-agent/providers/gemini", "obsidian-agent": "gemini extensions install https://github.com/cavi-ai/obsidian-agent" }, opencode: { @@ -106,7 +105,7 @@ function validateCanonicalSchema(schema, catalog, errors) { if (Object.hasOwn(plugin, "hosts")) { if (!Array.isArray(plugin.hosts)) violation(`${location}.hosts`, "must be an array"); else { - if (plugin.hosts.length !== 5) violation(`${location}.hosts`, "must contain exactly 5 items"); + if (plugin.hosts.length < 1) violation(`${location}.hosts`, "must contain at least 1 item"); if (new Set(plugin.hosts.map((host) => JSON.stringify(host))).size !== plugin.hosts.length) violation(`${location}.hosts`, "must contain unique items"); plugin.hosts.forEach((host, hostIndex) => { if (!HOSTS.includes(host)) violation(`${location}.hosts[${hostIndex}]`, "must be a supported host"); }); } @@ -114,7 +113,9 @@ function validateCanonicalSchema(schema, catalog, errors) { if (!Object.hasOwn(plugin, "packages")) continue; if (!plugin.packages || typeof plugin.packages !== "object" || Array.isArray(plugin.packages)) { violation(`${location}.packages`, "must be an object"); continue; } for (const key of unknownKeys(plugin.packages, HOSTS)) errors.push(`catalog plugin ${plugin?.name} unknown package host: ${key}`); - for (const host of HOSTS) { + const declared = Array.isArray(plugin.hosts) ? [...new Set(plugin.hosts.filter((host) => HOSTS.includes(host)))] : []; + for (const key of Object.keys(plugin.packages)) if (HOSTS.includes(key) && !declared.includes(key)) errors.push(`catalog plugin ${plugin?.name} package for undeclared host: ${key}`); + for (const host of declared) { if (!Object.hasOwn(plugin.packages, host)) { violation(`${location}.packages.${host}`, "is required"); continue; } const pkg = plugin.packages[host]; if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) { violation(`${location}.packages.${host}`, "must be an object"); continue; } @@ -262,7 +263,6 @@ export async function validateCatalog(root = process.cwd()) { seenHosts.add(host); } for (const host of plugin.hosts) if (!HOSTS.includes(host)) errors.push(`${prefix} unknown host: ${host}`); - for (const host of HOSTS) if (!seenHosts.has(host)) errors.push(`${prefix} required host missing: ${host}`); for (const host of new Set(plugin.hosts.filter((item) => HOSTS.includes(item)))) { if (!plugin.packages?.[host]?.path) errors.push(`${prefix} package path missing for host: ${host}`); } diff --git a/scripts/validate-catalog.test.mjs b/scripts/validate-catalog.test.mjs index 2822db9..53a9168 100644 --- a/scripts/validate-catalog.test.mjs +++ b/scripts/validate-catalog.test.mjs @@ -13,15 +13,16 @@ const inventories = { "obsidian-agent": ["build-retrospective", "connection-finder", "consistent-tagging", "daily-rollup", "dedup-merge", "frontmatter-normalizer", "manifest-content", "manifest-core", "manifest-feature", "manifest-infra", "manifest-pm", "manifest-research", "manifest-risk", "manifest-vault", "meeting-cleanup", "moc-builder", "note-splitter", "outline-to-draft", "plan-to-spec", "source-digest", "summarize-and-link", "task-harvester", "tracker-driver", "vault-grounding", "vault-synthesis", "wikilink-weaver"] }; const sourceCommits = { "mlx-agent": "923627988cf98e984fa36e0b940f86e7263e2958", "obsidian-agent": "7efecb058fce8185056cbe7868d6de1af11879a9" }; +const pluginHosts = { "mlx-agent": ["claude", "codex", "opencode", "agentskills"] }; -function plugin(name, repository = `cavi-ai/${name}`, supportedHosts = hosts) { +function plugin(name, repository = `cavi-ai/${name}`, supportedHosts = pluginHosts[name] ?? hosts) { return { name, repository, license: "MIT", summary: `${name} summary`, hosts: supportedHosts, - packages: Object.fromEntries(hosts.map((host) => [host, { path: host === "claude" ? "." : host === "codex" ? `packages/codex/${name}` : host === "gemini" ? (name === "mlx-agent" ? "providers/gemini" : ".") : host === "opencode" ? "providers/opencode" : "providers/agentskills" }])) + packages: Object.fromEntries([...new Set(supportedHosts)].filter((host) => hosts.includes(host)).map((host) => [host, { path: host === "claude" ? "." : host === "codex" ? `packages/codex/${name}` : host === "gemini" ? "." : host === "opencode" ? "providers/opencode" : "providers/agentskills" }])) }; } @@ -79,7 +80,7 @@ const entry = (name, host) => ({ name, repository: `cavi-ai/${name}`, package: canonical.plugins.find((item) => item.name === name).packages[host].path, - ...(host === "gemini" ? { install: { command: name === "mlx-agent" ? "git clone https://github.com/cavi-ai/mlx-agent.git && gemini extensions install ./mlx-agent/providers/gemini" : "gemini extensions install https://github.com/cavi-ai/obsidian-agent" } } : {}), + ...(host === "gemini" ? { install: { command: "gemini extensions install https://github.com/cavi-ai/obsidian-agent" } } : {}), ...(host === "opencode" ? { install: { command: name === "mlx-agent" ? "git clone https://github.com/cavi-ai/mlx-agent.git && python3 mlx-agent/scripts/mlx-agent install opencode --scope user --dry-run --json" : "git clone https://github.com/cavi-ai/obsidian-agent.git && node obsidian-agent/scripts/install.mjs --host opencode --scope user --dry-run" } } : {}) }); @@ -88,12 +89,25 @@ test("accepts exactly the two canonical plugins and truthful host projections", catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], - gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], + gemini: [entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); assert.deepEqual(await validateCatalog(root), []); }); +test("rejects a discovery projection for a host the plugin does not declare", async () => { + const root = await fixture({ + catalog: canonical, + claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], + codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], + gemini: [{ name: "mlx-agent", repository: "cavi-ai/mlx-agent", package: "providers/gemini", install: { command: "git clone https://github.com/cavi-ai/mlx-agent.git && gemini extensions install ./mlx-agent/providers/gemini" } }, entry("obsidian-agent", "gemini")], + opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] + }); + const errors = await validateCatalog(root); + assert(errors.some((error) => error.includes("gemini projects unsupported plugin host: mlx-agent"))); + assert(errors.some((error) => error.includes("gemini install command mismatch: mlx-agent"))); +}); + test("rejects duplicates, unknown hosts, missing repositories, and legacy identities", async () => { const bad = { name: "claude-plugins", @@ -123,7 +137,7 @@ test("rejects absent projection entries and unsupported or mismatched projection catalog: limited, claude: [entry("mlx-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex"), { name: "ghost", repository: "cavi-ai/ghost", package: ".codex-plugin" }], - gemini: [entry("mlx-agent", "gemini")], + gemini: [], opencode: [entry("mlx-agent", "opencode")] }); const errors = await validateCatalog(root); @@ -137,7 +151,7 @@ test("rejects repository identity and package path mismatches", async () => { catalog: canonical, claude: [{ ...entry("mlx-agent", "claude"), repository: "other/mlx-agent" }, entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], - gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], + gemini: [entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), { ...entry("obsidian-agent", "opencode"), package: "wrong" }] }); const errors = await validateCatalog(root); @@ -145,21 +159,22 @@ test("rejects repository identity and package path mismatches", async () => { assert(errors.some((error) => error.includes("opencode package mismatch: obsidian-agent"))); }); -test("requires the complete host matrix without duplicate host or projection entries", async () => { +test("rejects duplicate hosts, packages for undeclared hosts, and duplicate projection entries", async () => { const incomplete = { name: MARKETPLACE, - plugins: [plugin("mlx-agent", "cavi-ai/mlx-agent", ["claude", "claude", "codex", "gemini", "opencode"]), plugin("obsidian-agent")] + plugins: [plugin("mlx-agent", "cavi-ai/mlx-agent", ["claude", "claude", "codex", "opencode"]), plugin("obsidian-agent")] }; + incomplete.plugins[0].packages.gemini = { path: "providers/gemini" }; const root = await fixture({ catalog: incomplete, claude: [entry("mlx-agent", "claude"), entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], - gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], + gemini: [entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); const errors = await validateCatalog(root); assert(errors.some((error) => error.includes("duplicate host: claude"))); - assert(errors.some((error) => error.includes("required host missing: agentskills"))); + assert(errors.some((error) => error.includes("catalog plugin mlx-agent package for undeclared host: gemini"))); assert(errors.some((error) => error.includes("claude duplicate projection: mlx-agent"))); }); @@ -177,12 +192,12 @@ test("requires explicit install commands in discovery projections", async () => catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], - gemini: [{ ...entry("mlx-agent", "gemini"), install: undefined }, entry("obsidian-agent", "gemini")], - opencode: [entry("mlx-agent", "opencode"), { ...entry("obsidian-agent", "opencode"), install: {} }] + gemini: [{ ...entry("obsidian-agent", "gemini"), install: undefined }], + opencode: [{ ...entry("mlx-agent", "opencode"), install: {} }, entry("obsidian-agent", "opencode")] }); const errors = await validateCatalog(root); - assert(errors.some((error) => error.includes("gemini install command missing: mlx-agent"))); - assert(errors.some((error) => error.includes("opencode install command missing: obsidian-agent"))); + assert(errors.some((error) => error.includes("gemini install command missing: obsidian-agent"))); + assert(errors.some((error) => error.includes("opencode install command missing: mlx-agent"))); }); test("the published Codex projection uses resolvable marketplace-local packages", async () => { @@ -204,7 +219,7 @@ test("rejects drift inside a pinned Codex package", async () => { catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), { name: "obsidian-agent", source: { source: "local", path: "./packages/codex/obsidian-agent" } }], - gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], + gemini: [entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); await mkdir(path.join(root, "packages/codex"), { recursive: true }); @@ -215,7 +230,7 @@ test("rejects drift inside a pinned Codex package", async () => { }); test("rejects vendored drift even when package-local provenance is recomputed", async () => { - const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); + const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); const packageRoot = path.join(root, "packages/codex/obsidian-agent"); await writeFile(path.join(packageRoot, "skills/vault-grounding/SKILL.md"), "---\nname: vault-grounding\ndescription: attacker content\n---\n"); const provenancePath = path.join(packageRoot, "provenance.json"); @@ -227,7 +242,7 @@ test("rejects vendored drift even when package-local provenance is recomputed", }); test("rejects self-selected integrity roots and missing authoritative skills", async () => { - const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); + const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); const packageRoot = path.join(root, "packages/codex/obsidian-agent"); await rm(path.join(packageRoot, "skills/wikilink-weaver"), { recursive: true }); const provenancePath = path.join(packageRoot, "provenance.json"); @@ -241,7 +256,7 @@ test("rejects self-selected integrity roots and missing authoritative skills", a }); test("rejects live and dangling symlinks in Codex package trees", async () => { - const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); + const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); const outside = path.join(root, "outside.md"); await writeFile(outside, "outside"); await symlink(outside, path.join(root, "packages/codex/obsidian-agent/skills/vault-grounding/live.md")); @@ -251,7 +266,7 @@ test("rejects live and dangling symlinks in Codex package trees", async () => { }); test("rejects package-root and included-root symlinks", async () => { - const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); + const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); const obsidianPackage = path.join(root, "packages/codex/obsidian-agent"); const outsidePackage = path.join(root, "outside-obsidian-package"); await cp(obsidianPackage, outsidePackage, { recursive: true }); @@ -269,7 +284,7 @@ test("rejects package-root and included-root symlinks", async () => { test("applies canonical schema and rejects malformed native projections", async () => { const badCatalog = { ...canonical, unexpected: true }; - const root = await fixture({ catalog: badCatalog, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); + const root = await fixture({ catalog: badCatalog, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); const claude = JSON.parse(await readFile(path.join(root, ".claude-plugin/marketplace.json"), "utf8")); claude.plugins[0] = { name: "mlx-agent", repository: "cavi-ai/mlx-agent", package: "." }; await writeFile(path.join(root, ".claude-plugin/marketplace.json"), JSON.stringify(claude)); @@ -292,7 +307,8 @@ test("enforces fixed schema object, array, required, enum, and additional-proper [(catalog) => { delete catalog.plugins[0].license; }, "catalog schema violation: plugins[0].license is required"], [(catalog) => { catalog.plugins[0].name = "other"; }, "catalog schema violation: plugins[0].name must be a canonical plugin name"], [(catalog) => { catalog.plugins[0].hosts = "claude"; }, "catalog schema violation: plugins[0].hosts must be an array"], - [(catalog) => { catalog.plugins[0].hosts[4] = "claude"; }, "catalog schema violation: plugins[0].hosts must contain unique items"], + [(catalog) => { catalog.plugins[0].hosts = []; }, "catalog schema violation: plugins[0].hosts must contain at least 1 item"], + [(catalog) => { catalog.plugins[0].hosts.push("claude"); }, "catalog schema violation: plugins[0].hosts must contain unique items"], [(catalog) => { catalog.plugins[0].packages = []; }, "catalog schema violation: plugins[0].packages must be an object"], [(catalog) => { delete catalog.plugins[0].packages.codex; }, "catalog schema violation: plugins[0].packages.codex is required"], [(catalog) => { catalog.plugins[0].packages.codex = "local"; }, "catalog schema violation: plugins[0].packages.codex must be an object"], @@ -338,7 +354,7 @@ test("requires a closed and fully typed trusted-integrity registry", async () => }); test("rejects cross-plugin discovery command drift", async () => { - const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [{ ...entry("mlx-agent", "gemini"), install: { command: "gemini extensions install https://github.com/cavi-ai/obsidian-agent" } }, entry("obsidian-agent", "gemini")], opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] }); + const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], gemini: [entry("obsidian-agent", "gemini")], opencode: [{ ...entry("mlx-agent", "opencode"), install: { command: entry("obsidian-agent", "opencode").install.command } }, entry("obsidian-agent", "opencode")] }); const errors = await validateCatalog(root); - assert(errors.some((error) => error.includes("gemini install command mismatch: mlx-agent"))); + assert(errors.some((error) => error.includes("opencode install command mismatch: mlx-agent"))); });