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
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

## Plugins

| Plugin | Claude | Codex | Gemini | OpenCode | AgentSkills |
| --- | :---: | :---: | :---: | :---: | :---: |
| [`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 | ✓ | ✓ | ✓ | ✓ | ✓ |
| Plugin | Claude | Codex | Gemini | OpenCode | OpenClaw | Hermes | AgentSkills |
| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| [`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.
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

Expand Down Expand Up @@ -59,6 +59,18 @@ node obsidian-agent/scripts/install.mjs --host opencode --scope user --dry-run

Review the destination paths and use the exact preview hash when confirming.

## OpenClaw and Hermes

OpenClaw and Hermes discovery metadata is in [`providers/openclaw/catalog.json`](providers/openclaw/catalog.json) and [`providers/hermes/catalog.json`](providers/hermes/catalog.json). The `mlx-agent` installer places its skills in `$OPENCLAW_STATE_DIR/skills` or `$HERMES_HOME/skills`:

```sh
git clone https://github.com/cavi-ai/mlx-agent.git
python3 mlx-agent/scripts/mlx-agent install openclaw --scope user --dry-run --json
python3 mlx-agent/scripts/mlx-agent install hermes --scope user --dry-run --json
```

Review the destination paths and use the exact preview hash when confirming. `obsidian-agent` does not ship an OpenClaw or Hermes adapter.

## AgentSkills

Both plugins publish portable skills under their AgentSkills adapter. Follow the selected repository's project- or user-scope installation instructions; the catalog does not copy or fork skill content.
Expand Down
4 changes: 3 additions & 1 deletion catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
"repository": "cavi-ai/mlx-agent",
"license": "MIT",
"summary": "Discover, verify, and wire local MLX-optimized models on Apple Silicon.",
"hosts": ["claude", "codex", "opencode", "agentskills"],
"hosts": ["claude", "codex", "opencode", "openclaw", "hermes", "agentskills"],
"packages": {
"claude": { "path": "." },
"codex": { "path": "packages/codex/mlx-agent" },
"opencode": { "path": "providers/opencode" },
"openclaw": { "path": "providers/agentskills" },
"hermes": { "path": "providers/agentskills" },
"agentskills": { "path": "providers/agentskills" }
}
},
Expand Down
4 changes: 3 additions & 1 deletion catalog.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
},
"$defs": {
"host": { "enum": ["claude", "codex", "gemini", "opencode", "agentskills"] },
"host": { "enum": ["claude", "codex", "gemini", "opencode", "openclaw", "hermes", "agentskills"] },
"package": {
"type": "object",
"additionalProperties": false,
Expand All @@ -41,6 +41,8 @@
"codex": { "$ref": "#/$defs/package" },
"gemini": { "$ref": "#/$defs/package" },
"opencode": { "$ref": "#/$defs/package" },
"openclaw": { "$ref": "#/$defs/package" },
"hermes": { "$ref": "#/$defs/package" },
"agentskills": { "$ref": "#/$defs/package" }
}
}
Expand Down
13 changes: 13 additions & 0 deletions providers/hermes/catalog.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "cavi-ai",
"host": "hermes",
"protocol": "discovery",
"plugins": [
{
"name": "mlx-agent",
"repository": "cavi-ai/mlx-agent",
"package": "providers/agentskills",
"install": { "command": "git clone https://github.com/cavi-ai/mlx-agent.git && python3 mlx-agent/scripts/mlx-agent install hermes --scope user --dry-run --json" }
}
]
}
13 changes: 13 additions & 0 deletions providers/openclaw/catalog.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "cavi-ai",
"host": "openclaw",
"protocol": "discovery",
"plugins": [
{
"name": "mlx-agent",
"repository": "cavi-ai/mlx-agent",
"package": "providers/agentskills",
"install": { "command": "git clone https://github.com/cavi-ai/mlx-agent.git && python3 mlx-agent/scripts/mlx-agent install openclaw --scope user --dry-run --json" }
}
]
}
19 changes: 14 additions & 5 deletions scripts/validate-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";

export const HOSTS = ["claude", "codex", "gemini", "opencode", "agentskills"];
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";
const REQUIRED = ["mlx-agent", "obsidian-agent"];
Expand All @@ -24,14 +24,23 @@ const DISCOVERY_COMMANDS = {
opencode: {
"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",
"obsidian-agent": "git clone https://github.com/cavi-ai/obsidian-agent.git && node obsidian-agent/scripts/install.mjs --host opencode --scope user --dry-run"
},
openclaw: {
"mlx-agent": "git clone https://github.com/cavi-ai/mlx-agent.git && python3 mlx-agent/scripts/mlx-agent install openclaw --scope user --dry-run --json"
},
hermes: {
"mlx-agent": "git clone https://github.com/cavi-ai/mlx-agent.git && python3 mlx-agent/scripts/mlx-agent install hermes --scope user --dry-run --json"
}
};
const DISCOVERY_HOSTS = Object.keys(DISCOVERY_COMMANDS);
const LEGACY = new Set(["claude-plugins", "claude-obsidian", "claude-obsidian-plugin"]);
const PROJECTIONS = {
claude: ".claude-plugin/marketplace.json",
codex: ".agents/plugins/marketplace.json",
gemini: "providers/gemini/catalog.json",
opencode: "providers/opencode/catalog.json"
opencode: "providers/opencode/catalog.json",
openclaw: "providers/openclaw/catalog.json",
hermes: "providers/hermes/catalog.json"
};
const JSON_FAILURE = Symbol("JSON_FAILURE");

Expand Down Expand Up @@ -277,7 +286,7 @@ export async function validateCatalog(root = process.cwd()) {
if (!projection || typeof projection !== "object" || Array.isArray(projection)) { errors.push(`${host} projection must be an object`); continue; }
if (projection.name !== MARKETPLACE) errors.push(`${host} projection name must be ${MARKETPLACE}`);
if (host === "claude" || host === "codex") validateNativeProjection(host, projection, errors);
if (host === "gemini" || host === "opencode") {
if (DISCOVERY_HOSTS.includes(host)) {
if (projection.host !== host) errors.push(`${host} projection host mismatch`);
if (projection.protocol !== "discovery") errors.push(`${host} projection must use discovery protocol`);
}
Expand All @@ -288,10 +297,10 @@ export async function validateCatalog(root = process.cwd()) {
const projected = new Set();
for (const raw of projection.plugins) {
const entry = projectionIdentity(raw);
if ((host === "gemini" || host === "opencode") && (typeof raw?.install?.command !== "string" || !raw.install.command.trim())) {
if (DISCOVERY_HOSTS.includes(host) && (typeof raw?.install?.command !== "string" || !raw.install.command.trim())) {
errors.push(`${host} install command missing: ${entry.name}`);
}
if ((host === "gemini" || host === "opencode") && DISCOVERY_COMMANDS[host]?.[entry.name] !== raw?.install?.command) errors.push(`${host} install command mismatch: ${entry.name}`);
if (DISCOVERY_HOSTS.includes(host) && DISCOVERY_COMMANDS[host]?.[entry.name] !== raw?.install?.command) errors.push(`${host} install command mismatch: ${entry.name}`);
if (projected.has(entry.name)) errors.push(`${host} duplicate projection: ${entry.name}`);
projected.add(entry.name);
const canonical = byName.get(entry.name);
Expand Down
41 changes: 34 additions & 7 deletions scripts/validate-catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import test from "node:test";

import { MARKETPLACE, validateCatalog } from "./validate-catalog.mjs";

const hosts = ["claude", "codex", "gemini", "opencode", "agentskills"];
const hosts = ["claude", "codex", "gemini", "opencode", "openclaw", "hermes", "agentskills"];
const inventories = {
"mlx-agent": ["mlx-adopt", "mlx-bench", "mlx-scout", "mlx-wire"],
"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"] };
const pluginHosts = {
"mlx-agent": ["claude", "codex", "opencode", "openclaw", "hermes", "agentskills"],
"obsidian-agent": ["claude", "codex", "gemini", "opencode", "agentskills"]
};

function plugin(name, repository = `cavi-ai/${name}`, supportedHosts = pluginHosts[name] ?? hosts) {
return {
Expand Down Expand Up @@ -46,12 +49,11 @@ async function createFixturePackage(root, name) {
await writeFile(path.join(packageRoot, "provenance.json"), JSON.stringify({ repository: `cavi-ai/${name}`, source_commit: sourceCommits[name], source_path: name === "mlx-agent" ? "providers/codex" : ".", included: [".codex-plugin", "skills"], ...(name === "mlx-agent" ? { excluded: [".mlx-agent-generated-files.json", "skills/*/scripts/mlx-agent-mcp", "skills/*/src/mlx_agent/mcp_server.py"], exclusion_reason: "Codex skills use the packaged CLI entrypoint; MCP transport artifacts are not required or distributed by this catalog." } : {}), integrity: { algorithm: "sha256", tree: await fixtureTreeHash(packageRoot) } }));
}

async function fixture({ catalog, claude = [], codex = [], gemini = [], opencode = [] }) {
async function fixture({ catalog, claude = [], codex = [], gemini = [], opencode = [], openclaw = [], hermes = [] }) {
const root = await mkdtemp(path.join(tmpdir(), "plugins-catalog-"));
await mkdir(path.join(root, ".claude-plugin"), { recursive: true });
await mkdir(path.join(root, ".agents/plugins"), { recursive: true });
await mkdir(path.join(root, "providers/gemini"), { recursive: true });
await mkdir(path.join(root, "providers/opencode"), { recursive: true });
for (const host of ["gemini", "opencode", "openclaw", "hermes"]) await mkdir(path.join(root, "providers", host), { recursive: true });
await cp(path.resolve(new URL("../catalog.schema.json", import.meta.url).pathname), path.join(root, "catalog.schema.json"));
await writeFile(path.join(root, "catalog.json"), JSON.stringify(catalog));
const nativeClaude = claude.map((item) => item.source ? item : { name: item.name, source: { source: "github", repo: item.repository }, description: `${item.name} fixture` });
Expand All @@ -60,6 +62,8 @@ async function fixture({ catalog, claude = [], codex = [], gemini = [], opencode
await writeFile(path.join(root, ".agents/plugins/marketplace.json"), JSON.stringify({ name: MARKETPLACE, interface: { displayName: "Cavi AI" }, plugins: nativeCodex }));
await writeFile(path.join(root, "providers/gemini/catalog.json"), JSON.stringify({ name: MARKETPLACE, host: "gemini", protocol: "discovery", plugins: gemini }));
await writeFile(path.join(root, "providers/opencode/catalog.json"), JSON.stringify({ name: MARKETPLACE, host: "opencode", protocol: "discovery", plugins: opencode }));
await writeFile(path.join(root, "providers/openclaw/catalog.json"), JSON.stringify({ name: MARKETPLACE, host: "openclaw", protocol: "discovery", plugins: openclaw }));
await writeFile(path.join(root, "providers/hermes/catalog.json"), JSON.stringify({ name: MARKETPLACE, host: "hermes", protocol: "discovery", plugins: hermes }));
for (const name of Object.keys(inventories)) await createFixturePackage(root, name);
const expectedIntegrity = {};
for (const name of Object.keys(inventories)) {
Expand All @@ -81,7 +85,8 @@ const entry = (name, host) => ({
repository: `cavi-ai/${name}`,
package: canonical.plugins.find((item) => item.name === name).packages[host].path,
...(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" } } : {})
...(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" } } : {}),
...(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 () => {
Expand All @@ -90,11 +95,33 @@ test("accepts exactly the two canonical plugins and truthful host projections",
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")]
opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")],
openclaw: [entry("mlx-agent", "openclaw")],
hermes: [entry("mlx-agent", "hermes")]
});
assert.deepEqual(await validateCatalog(root), []);
});

test("requires openclaw and hermes discovery records only for plugins that declare them", 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("obsidian-agent", "gemini")],
opencode: [entry("mlx-agent", "opencode"), entry("obsidian-agent", "opencode")],
openclaw: [entry("mlx-agent", "openclaw"), { name: "obsidian-agent", repository: "cavi-ai/obsidian-agent", package: "providers/agentskills", install: { command: "git clone https://github.com/cavi-ai/obsidian-agent.git && node obsidian-agent/scripts/install.mjs --host openclaw --scope user --dry-run" } }],
hermes: [{ ...entry("mlx-agent", "hermes"), install: { command: entry("mlx-agent", "openclaw").install.command } }]
});
const errors = await validateCatalog(root);
assert(errors.some((error) => error.includes("openclaw projects unsupported plugin host: obsidian-agent")));
assert(errors.some((error) => error.includes("openclaw install command mismatch: obsidian-agent")));
assert(errors.some((error) => error.includes("hermes install command mismatch: mlx-agent")));
const missing = await validateCatalog(await fixture({ catalog: canonical }));
assert(missing.includes("openclaw projection missing: mlx-agent"));
assert(missing.includes("hermes projection missing: mlx-agent"));
assert(!missing.some((error) => error.includes("projection missing: obsidian-agent") && /^(openclaw|hermes) /.test(error)));
});

test("rejects a discovery projection for a host the plugin does not declare", async () => {
const root = await fixture({
catalog: canonical,
Expand Down
Loading