diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3105a40..4184a39 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,6 +12,11 @@ "name": "obsidian-agent", "source": { "source": "github", "repo": "cavi-ai/obsidian-agent" }, "description": "Portable Obsidian vault workflows powered by the official Obsidian CLI." + }, + { + "name": "loop-killer", + "source": { "source": "github", "repo": "cavi-ai/loop-killer" }, + "description": "Ends a stop-hook re-prompt loop: after repeated re-prompt turns with no tool work, it clears the active goal and says so." } ] } diff --git a/README.md b/README.md index f1b6b69..494d8ee 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,19 @@ | --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | | [`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 | ✓ | ✓ | ✓ | ✓ | — | — | ✓ | +| [`loop-killer`](https://github.com/cavi-ai/loop-killer) — end stop-hook re-prompt loops by clearing the active goal | ✓ | — | — | — | — | — | — | The machine-readable source of truth is [`catalog.json`](catalog.json). Claude and Codex consume native marketplace projections. Gemini, OpenCode, OpenClaw, and Hermes 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 either plugin: +Add the marketplace, then install a plugin: ```text /plugin marketplace add cavi-ai/plugins /plugin install mlx-agent@cavi-ai /plugin install obsidian-agent@cavi-ai +/plugin install loop-killer@cavi-ai ``` ## Codex diff --git a/catalog.json b/catalog.json index aab0469..1cba0b1 100644 --- a/catalog.json +++ b/catalog.json @@ -30,6 +30,16 @@ "opencode": { "path": "providers/opencode" }, "agentskills": { "path": "providers/agentskills" } } + }, + { + "name": "loop-killer", + "repository": "cavi-ai/loop-killer", + "license": "MIT", + "summary": "End stop-hook re-prompt loops by clearing the active goal.", + "hosts": ["claude"], + "packages": { + "claude": { "path": "." } + } } ] } diff --git a/catalog.schema.json b/catalog.schema.json index 9dc432c..017488b 100644 --- a/catalog.schema.json +++ b/catalog.schema.json @@ -10,8 +10,7 @@ "name": { "const": "cavi-ai" }, "plugins": { "type": "array", - "minItems": 2, - "maxItems": 2, + "minItems": 1, "items": { "$ref": "#/$defs/plugin" } } }, @@ -28,7 +27,7 @@ "additionalProperties": false, "required": ["name", "repository", "license", "summary", "hosts", "packages"], "properties": { - "name": { "enum": ["mlx-agent", "obsidian-agent"] }, + "name": { "enum": ["mlx-agent", "obsidian-agent", "loop-killer"] }, "repository": { "type": "string", "pattern": "^cavi-ai/[a-z0-9-]+$" }, "license": { "type": "string", "minLength": 1 }, "summary": { "type": "string", "minLength": 1 }, diff --git a/scripts/validate-catalog.mjs b/scripts/validate-catalog.mjs index 38c9aa1..616dda3 100644 --- a/scripts/validate-catalog.mjs +++ b/scripts/validate-catalog.mjs @@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url"; export const HOSTS = ["claude", "codex", "gemini", "opencode", "openclaw", "hermes", "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", "loop-killer"]; const REQUIRED = ["mlx-agent", "obsidian-agent"]; const INCLUDED_ROOTS = [".codex-plugin", "skills"]; const SKILL_INVENTORIES = { @@ -92,7 +94,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 !== 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"); + 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 || JSON.stringify(schema?.$defs?.plugin?.properties?.name?.enum) !== JSON.stringify(CANONICAL) || 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"); @@ -102,13 +104,13 @@ 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 !== 2) violation("plugins", "must contain exactly 2 items"); + else if (catalog.plugins.length < 1) violation("plugins", "must contain at least 1 item"); 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") && !REQUIRED.includes(plugin.name)) violation(`${location}.name`, "must be a canonical plugin name"); + if (Object.hasOwn(plugin, "name") && !CANONICAL.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")) { @@ -278,7 +280,7 @@ export async function validateCatalog(root = process.cwd()) { } } for (const name of REQUIRED) if (!byName.has(name)) errors.push(`required plugin missing: ${name}`); - for (const name of byName.keys()) if (!REQUIRED.includes(name)) errors.push(`unexpected plugin: ${name}`); + for (const name of byName.keys()) if (!CANONICAL.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 c7387ca..5f9447c 100644 --- a/scripts/validate-catalog.test.mjs +++ b/scripts/validate-catalog.test.mjs @@ -89,7 +89,7 @@ const entry = (name, host) => ({ ...(host === "openclaw" || host === "hermes" ? { install: { command: `git clone https://github.com/cavi-ai/mlx-agent.git && python3 mlx-agent/scripts/mlx-agent install ${host} --scope user --dry-run --json` } } : {}) }); -test("accepts exactly the two canonical plugins and truthful host projections", async () => { +test("accepts the canonical plugins and truthful host projections", async () => { const root = await fixture({ catalog: canonical, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], @@ -102,6 +102,35 @@ test("accepts exactly the two canonical plugins and truthful host projections", assert.deepEqual(await validateCatalog(root), []); }); +test("accepts a claude-only plugin projected only to claude", async () => { + const withLoopKiller = { ...canonical, plugins: [...canonical.plugins, plugin("loop-killer", "cavi-ai/loop-killer", ["claude"])] }; + const loopKiller = { name: "loop-killer", repository: "cavi-ai/loop-killer", package: "." }; + const projections = { + gemini: [entry("obsidian-agent", "gemini")], + opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")], + openclaw: [entry("mlx-agent", "openclaw")], + hermes: [entry("mlx-agent", "hermes")] + }; + const root = await fixture({ + catalog: withLoopKiller, + claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude"), loopKiller], + codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], + ...projections + }); + assert.deepEqual(await validateCatalog(root), []); + const projectedWide = await fixture({ + catalog: withLoopKiller, + claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude"), loopKiller], + codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], + ...projections, + opencode: [...projections.opencode, { ...loopKiller, install: { command: "git clone https://github.com/cavi-ai/loop-killer.git" } }] + }); + const errors = await validateCatalog(projectedWide); + assert(errors.some((error) => error.includes("opencode projects unsupported plugin host: loop-killer"))); + const unprojected = await validateCatalog(await fixture({ catalog: withLoopKiller, claude: [entry("mlx-agent", "claude"), entry("obsidian-agent", "claude")], codex: [entry("mlx-agent", "codex"), entry("obsidian-agent", "codex")], ...projections })); + assert(unprojected.includes("claude projection missing: loop-killer")); +}); + test("requires openclaw and hermes discovery records only for plugins that declare them", async () => { const root = await fixture({ catalog: canonical,