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": "harness",
"source": { "source": "github", "repo": "cavi-ai/harness" },
"description": "Policy engine for Claude Code sessions: hard stops, intent-scoped gates, advisories, outcome contracts."
}
]
}
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 | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`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 either plugin:
Add the marketplace, then install any 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
Expand Down
10 changes: 10 additions & 0 deletions catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
"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": "." }
}
}
]
}
8 changes: 3 additions & 5 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,15 +27,14 @@
"additionalProperties": false,
"required": ["name", "repository", "license", "summary", "hosts", "packages"],
"properties": {
"name": { "enum": ["mlx-agent", "obsidian-agent"] },
"name": { "enum": ["mlx-agent", "obsidian-agent", "harness"] },
"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" },
Expand Down
17 changes: 10 additions & 7 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", "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", "harness"];
const REQUIRED = ["mlx-agent", "obsidian-agent"];
const INCLUDED_ROOTS = [".codex-plugin", "skills"];
const SKILL_INVENTORIES = {
Expand Down Expand Up @@ -84,7 +86,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 || 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 @@ -94,27 +96,29 @@ 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")) {
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"); });
}
}
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; }
Expand Down Expand Up @@ -262,14 +266,13 @@ 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 (!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
30 changes: 26 additions & 4 deletions scripts/validate-catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function plugin(name, repository = `cavi-ai/${name}`, supportedHosts = hosts) {
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" ? (name === "mlx-agent" ? "providers/gemini" : ".") : host === "opencode" ? "providers/opencode" : "providers/agentskills" }]))
};
}

Expand Down Expand Up @@ -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 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 @@ -94,6 +94,27 @@ 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 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",
Expand Down Expand Up @@ -145,11 +166,12 @@ 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, undeclared host packages, 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")]
};
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")],
Expand All @@ -159,7 +181,7 @@ test("requires the complete host matrix without duplicate host or projection ent
});
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: agentskills")));
assert(errors.some((error) => error.includes("claude duplicate projection: mlx-agent")));
});

Expand Down
Loading