diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4ab4c4f..3105a40 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,11 +12,6 @@ "name": "obsidian-agent", "source": { "source": "github", "repo": "cavi-ai/obsidian-agent" }, "description": "Portable Obsidian vault workflows powered by the official Obsidian CLI." - }, - { - "name": "harness", - "source": { "source": "github", "repo": "cavi-ai/harness" }, - "description": "Policy engine for Claude Code sessions: hard stops, intent-scoped gates, advisories, outcome contracts." } ] } diff --git a/README.md b/README.md index 13e4bf3..961422c 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,17 @@ | --- | :---: | :---: | :---: | :---: | :---: | | [`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 | ✓ | ✓ | ✓ | ✓ | ✓ | -| [`harness`](https://github.com/cavi-ai/harness) — policy engine for Claude Code sessions (hard stops, intent gates, advisories, outcome contracts) | ✓ | — | — | — | — | 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. ## Claude Code -Add the marketplace, then install any plugin: +Add the marketplace, then install either plugin: ```text /plugin marketplace add cavi-ai/plugins /plugin install mlx-agent@cavi-ai /plugin install obsidian-agent@cavi-ai -/plugin install harness@cavi-ai ``` ## Codex diff --git a/catalog.json b/catalog.json index 1b16543..55833a0 100644 --- a/catalog.json +++ b/catalog.json @@ -29,16 +29,6 @@ "opencode": { "path": "providers/opencode" }, "agentskills": { "path": "providers/agentskills" } } - }, - { - "name": "harness", - "repository": "cavi-ai/harness", - "license": "MIT", - "summary": "Policy engine for Claude Code sessions: hard stops, intent-scoped gates, advisories, outcome contracts.", - "hosts": ["claude"], - "packages": { - "claude": { "path": "." } - } } ] } diff --git a/catalog.schema.json b/catalog.schema.json index e9b3f4b..361e15b 100644 --- a/catalog.schema.json +++ b/catalog.schema.json @@ -10,7 +10,8 @@ "name": { "const": "cavi-ai" }, "plugins": { "type": "array", - "minItems": 1, + "minItems": 2, + "maxItems": 2, "items": { "$ref": "#/$defs/plugin" } } }, @@ -27,14 +28,15 @@ "additionalProperties": false, "required": ["name", "repository", "license", "summary", "hosts", "packages"], "properties": { - "name": { "enum": ["mlx-agent", "obsidian-agent", "harness"] }, + "name": { "enum": ["mlx-agent", "obsidian-agent"] }, "repository": { "type": "string", "pattern": "^cavi-ai/[a-z0-9-]+$" }, "license": { "type": "string", "minLength": 1 }, "summary": { "type": "string", "minLength": 1 }, - "hosts": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/host" } }, + "hosts": { "type": "array", "minItems": 5, "maxItems": 5, "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/scripts/validate-catalog.mjs b/scripts/validate-catalog.mjs index 4b54fdb..d5747d4 100644 --- a/scripts/validate-catalog.mjs +++ b/scripts/validate-catalog.mjs @@ -7,8 +7,6 @@ import { fileURLToPath } from "node:url"; export const HOSTS = ["claude", "codex", "gemini", "opencode", "agentskills"]; // Marketplace identity: what users type after @ in `plugin install @`. export const MARKETPLACE = "cavi-ai"; -// Every plugin the catalog may list. REQUIRED is the subset that must be present and ships a pinned Codex package. -const CANONICAL = ["mlx-agent", "obsidian-agent", "harness"]; const REQUIRED = ["mlx-agent", "obsidian-agent"]; const INCLUDED_ROOTS = [".codex-plugin", "skills"]; const SKILL_INVENTORIES = { @@ -86,7 +84,7 @@ function unknownKeys(value, allowed) { } function validateCanonicalSchema(schema, catalog, errors) { - if (schema?.$schema !== "https://json-schema.org/draft/2020-12/schema" || schema?.$id !== "https://github.com/cavi-ai/plugins/catalog.schema.json" || schema?.type !== "object" || schema?.additionalProperties !== false || schema?.properties?.name?.const !== MARKETPLACE || schema?.properties?.plugins?.minItems !== 1 || schema?.properties?.plugins?.maxItems !== undefined || schema?.$defs?.plugin?.additionalProperties !== false || JSON.stringify(schema?.$defs?.host?.enum) !== JSON.stringify(HOSTS)) errors.push("catalog schema contract invalid"); + if (schema?.$schema !== "https://json-schema.org/draft/2020-12/schema" || schema?.$id !== "https://github.com/cavi-ai/plugins/catalog.schema.json" || schema?.type !== "object" || schema?.additionalProperties !== false || schema?.properties?.name?.const !== MARKETPLACE || schema?.properties?.plugins?.minItems !== 2 || schema?.properties?.plugins?.maxItems !== 2 || schema?.$defs?.plugin?.additionalProperties !== false || JSON.stringify(schema?.$defs?.host?.enum) !== JSON.stringify(HOSTS)) errors.push("catalog schema contract invalid"); const violation = (location, message) => errors.push(`catalog schema violation: ${location} ${message}`); if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) { violation("root", "must be an object"); return; } if (catalog.$schema !== "./catalog.schema.json") errors.push("catalog schema reference invalid"); @@ -96,19 +94,19 @@ function validateCanonicalSchema(schema, catalog, errors) { else if (catalog.name !== MARKETPLACE) violation("name", `must equal ${MARKETPLACE}`); if (!Object.hasOwn(catalog, "plugins")) violation("plugins", "is required"); else if (!Array.isArray(catalog.plugins)) violation("plugins", "must be an array"); - else if (catalog.plugins.length < 1) violation("plugins", "must contain at least 1 item"); + else if (catalog.plugins.length !== 2) violation("plugins", "must contain exactly 2 items"); for (const [index, plugin] of (Array.isArray(catalog.plugins) ? catalog.plugins : []).entries()) { const location = `plugins[${index}]`; if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) { violation(location, "must be an object"); continue; } for (const key of unknownKeys(plugin, ["name", "repository", "license", "summary", "hosts", "packages"])) errors.push(`catalog plugin ${plugin?.name} unknown property: ${key}`); for (const key of ["name", "repository", "license", "summary", "hosts", "packages"]) if (!Object.hasOwn(plugin, key)) violation(`${location}.${key}`, "is required"); - if (Object.hasOwn(plugin, "name") && !CANONICAL.includes(plugin.name)) violation(`${location}.name`, "must be a canonical plugin name"); + if (Object.hasOwn(plugin, "name") && !REQUIRED.includes(plugin.name)) violation(`${location}.name`, "must be a canonical plugin name"); if (Object.hasOwn(plugin, "repository") && (typeof plugin.repository !== "string" || !/^cavi-ai\/[a-z0-9-]+$/.test(plugin.repository))) violation(`${location}.repository`, "must match ^cavi-ai/[a-z0-9-]+$"); for (const key of ["license", "summary"]) if (Object.hasOwn(plugin, key) && (typeof plugin[key] !== "string" || plugin[key].length < 1)) violation(`${location}.${key}`, "must be a non-empty string"); if (Object.hasOwn(plugin, "hosts")) { if (!Array.isArray(plugin.hosts)) violation(`${location}.hosts`, "must be an array"); else { - if (plugin.hosts.length < 1) violation(`${location}.hosts`, "must contain at least 1 item"); + if (plugin.hosts.length !== 5) violation(`${location}.hosts`, "must contain exactly 5 items"); 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"); }); } @@ -116,9 +114,7 @@ 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}`); - 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) { + for (const host of HOSTS) { 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; } @@ -266,13 +262,14 @@ 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}`); } } } for (const name of REQUIRED) if (!byName.has(name)) errors.push(`required plugin missing: ${name}`); - for (const name of byName.keys()) if (!CANONICAL.includes(name)) errors.push(`unexpected plugin: ${name}`); + for (const name of byName.keys()) if (!REQUIRED.includes(name)) errors.push(`unexpected plugin: ${name}`); for (const [host, relative] of Object.entries(PROJECTIONS)) { const projection = await json(root, relative, errors); diff --git a/scripts/validate-catalog.test.mjs b/scripts/validate-catalog.test.mjs index caf9c2c..2822db9 100644 --- a/scripts/validate-catalog.test.mjs +++ b/scripts/validate-catalog.test.mjs @@ -21,7 +21,7 @@ function plugin(name, repository = `cavi-ai/${name}`, supportedHosts = hosts) { license: "MIT", summary: `${name} summary`, hosts: supportedHosts, - packages: Object.fromEntries([...new Set(supportedHosts)].filter((host) => hosts.includes(host)).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(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" }])) }; } @@ -83,7 +83,7 @@ const entry = (name, host) => ({ ...(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" } } : {}) }); -test("accepts the canonical plugins and truthful host projections", async () => { +test("accepts exactly the two canonical plugins and truthful host projections", async () => { const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], @@ -94,27 +94,6 @@ test("accepts the canonical plugins and truthful host projections", async () => assert.deepEqual(await validateCatalog(root), []); }); -test("accepts a claude-only plugin projected only to claude", async () => { - const withHarness = { ...canonical, plugins: [...canonical.plugins, plugin("harness", "cavi-ai/harness", ["claude"])] }; - const root = await fixture({ - catalog: withHarness, - claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude"), { name: "harness", repository: "cavi-ai/harness", package: "." }], - 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")] - }); - assert.deepEqual(await validateCatalog(root), []); - const projectedWide = await fixture({ - catalog: withHarness, - claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude"), { name: "harness", repository: "cavi-ai/harness", package: "." }], - codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex"), { name: "harness", repository: "cavi-ai/harness", package: "packages/codex/harness" }], - gemini: [entry("mlx-agent", "gemini"), entry("obsidian-agent", "gemini")], - opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")] - }); - const errors = await validateCatalog(projectedWide); - assert(errors.some((error) => error.includes("codex projects unsupported plugin host: harness"))); -}); - test("rejects duplicates, unknown hosts, missing repositories, and legacy identities", async () => { const bad = { name: "claude-plugins", @@ -166,12 +145,11 @@ test("rejects repository identity and package path mismatches", async () => { assert(errors.some((error) => error.includes("opencode package mismatch: obsidian-agent"))); }); -test("rejects duplicate hosts, undeclared host packages, and duplicate projection entries", async () => { +test("requires the complete host matrix without duplicate host or projection entries", async () => { const incomplete = { name: MARKETPLACE, plugins: [plugin("mlx-agent", "cavi-ai/mlx-agent", ["claude", "claude", "codex", "gemini", "opencode"]), plugin("obsidian-agent")] }; - incomplete.plugins[0].packages.agentskills = { path: "providers/agentskills" }; const root = await fixture({ catalog: incomplete, claude: [entry("mlx-agent", "claude"), entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], @@ -181,7 +159,7 @@ test("rejects duplicate hosts, undeclared host packages, and duplicate projectio }); const errors = await validateCatalog(root); assert(errors.some((error) => error.includes("duplicate host: claude"))); - assert(errors.some((error) => error.includes("catalog plugin mlx-agent package for undeclared host: agentskills"))); + assert(errors.some((error) => error.includes("required host missing: agentskills"))); assert(errors.some((error) => error.includes("claude duplicate projection: mlx-agent"))); });