Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "." }
}
}
]
}
5 changes: 2 additions & 3 deletions catalog.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
"name": { "const": "cavi-ai" },
"plugins": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"minItems": 1,
"items": { "$ref": "#/$defs/plugin" }
}
},
Expand All @@ -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 },
Expand Down
10 changes: 6 additions & 4 deletions scripts/validate-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>@<marketplace>`.
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 = {
Expand Down Expand Up @@ -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");
Expand All @@ -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")) {
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 30 additions & 1 deletion scripts/validate-catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand All @@ -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,
Expand Down
Loading