feat(marketplace): prefer .omp-plugin/marketplace.json catalog path

Adds .omp-plugin/marketplace.json as the preferred catalog location for omp
marketplaces. fetchMarketplace now searches .omp-plugin/marketplace.json
first and falls back to .claude-plugin/marketplace.json so existing
Claude Code-compatible marketplaces keep loading.

Docs and authoring skill updated to reflect the new preferred path.

Co-Authored-By: omp <noreply@oh-my-pi.dev>
This commit is contained in:
David Marshall
2026-05-18 15:34:21 -05:00
parent 3f073b82de
commit 587f68a899
5 changed files with 93 additions and 40 deletions
+3 -3
View File
@@ -13,7 +13,7 @@ Or just type `/marketplace` with no arguments to open the interactive plugin bro
## Concepts
A **marketplace** is a Git repository (or local directory) containing a catalog file at `.claude-plugin/marketplace.json`. The catalog lists available plugins with their sources, descriptions, and metadata.
A **marketplace** is a Git repository (or local directory) containing a catalog file at `.omp-plugin/marketplace.json` (preferred) or `.claude-plugin/marketplace.json` (Claude Code-compatible fallback). The catalog lists available plugins with their sources, descriptions, and metadata.
A **plugin** is a directory containing skills, commands, hooks, MCP servers, or LSP servers. Plugins are identified by `name@marketplace` (e.g. `code-review@claude-plugins-official`).
@@ -77,11 +77,11 @@ When you run `/marketplace add <source>`, the system classifies the source:
| `https://...*.git` or `git@...` | Git repository | `https://github.com/org/repo.git` |
| `./path` or `~/path` or `/path` | Local directory | `./my-marketplace` |
The system clones the repository (or reads the local directory), locates `.claude-plugin/marketplace.json`, validates it, and caches the catalog locally.
The system clones the repository (or reads the local directory), locates the catalog (`.omp-plugin/marketplace.json` if present, otherwise `.claude-plugin/marketplace.json`), validates it, and caches the catalog locally.
## Catalog format (marketplace.json)
A marketplace catalog lives at `.claude-plugin/marketplace.json` in the repository root:
A marketplace catalog lives at `.omp-plugin/marketplace.json` in the repository root. When omp is the only intended consumer, prefer this path. To remain Claude Code-compatible (omp loads the same shape from either path), publish at `.claude-plugin/marketplace.json` instead — omp uses it as a fallback when `.omp-plugin/marketplace.json` is absent. A repository may ship both: omp reads the `.omp-plugin/` copy, Claude Code reads the `.claude-plugin/` copy. Same catalog format either way:
```json
{
+3 -3
View File
@@ -5,7 +5,7 @@ description: Use when creating a new omp marketplace. Covers marketplace.json sc
# Authoring Marketplaces
A marketplace is a Git repository (or local directory) that contains a catalog file at `.claude-plugin/marketplace.json`. Anyone can author one. Users add it with `/marketplace add owner/repo` and then install individual plugins from it.
A marketplace is a Git repository (or local directory) that contains a catalog file at either `.omp-plugin/marketplace.json` (preferred for omp-specific catalogs) or `.claude-plugin/marketplace.json` (Claude Code-compatible; used as the fallback). Anyone can author one. Users add it with `/marketplace add owner/repo` and then install individual plugins from it.
## Minimum viable marketplace
@@ -42,7 +42,7 @@ Push to GitHub. Users install with:
## marketplace.json schema
The catalog file must live at `.claude-plugin/marketplace.json` in the repository root.
The catalog file lives at either `.omp-plugin/marketplace.json` or `.claude-plugin/marketplace.json` in the repository root. omp prefers the `.omp-plugin/` path and falls back to the Claude path; a repository may publish both to expose tool-specific catalogs from a single source tree.
### Top-level fields
@@ -241,7 +241,7 @@ Invalid: `-bad-start`, `bad-end-`, `.dot-start`, `Under_score`, `HAS_CAPS`
## Publishing workflow
1. Create `marketplace.json` at `.claude-plugin/marketplace.json` in a new Git repo.
1. Create `marketplace.json` at `.omp-plugin/marketplace.json` (omp-only) or `.claude-plugin/marketplace.json` (shared with Claude Code) in a new Git repo.
2. Add plugin entries pointing to subdirectories (or external sources).
3. Push to GitHub.
4. Share the `owner/repo` string. Users add it with `/marketplace add owner/repo`.
+4
View File
@@ -439,7 +439,11 @@
- Fixed `RULES.md` not being injected. The documented sticky-rules file at `~/.omp/agent/RULES.md` and `<repo>/.omp/RULES.md` was never read by any discovery provider; only `.omp/rules/*.md` was scanned. The native provider now loads both as always-apply rules so they re-attach every turn as documented ([#1266](https://github.com/can1357/oh-my-pi/issues/1266)).
## [15.2.1] - 2026-05-21
### Added
- Added `.omp-plugin/marketplace.json` as a preferred marketplace catalog path. `fetchMarketplace` now searches `.omp-plugin/marketplace.json` before `.claude-plugin/marketplace.json` for every local and cloned source. Lets a single marketplace repository publish a tool-specific catalog (e.g. an omp-only superset of a shared Claude Code marketplace) without forcing the omp/Claude distinction into per-plugin tagging. Mirrors the `package.json#omp.extensions` precedence pattern; the `.claude-plugin/marketplace.json` fallback keeps every existing marketplace loading unchanged.
## [15.2.1] - 2026-05-21
### Fixed
- Fixed compaction routing to the wrong provider when `modelRoles.default` is set to a different model than the active chat. Auto- and manual compaction now prefer the active session's model and only fall back to role-based candidates when the current model has no usable credentials. Previously, an Anthropic chat with `modelRoles.default = openai/gpt-5` would compact through OpenAI (including the remote-compaction endpoint), even though the live conversation never used OpenAI.
@@ -192,8 +192,33 @@ export function parseMarketplaceCatalog(content: string, filePath: string): Mark
// ── fetchMarketplace ──────────────────────────────────────────────────
/** Relative path from a marketplace root to its catalog file. */
const CATALOG_RELATIVE_PATH = path.join(".claude-plugin", "marketplace.json");
/**
* Catalog paths tried in priority order: omp-namespaced override first, then
* the Claude Code-compatible fallback so existing marketplaces keep loading.
*/
const CATALOG_RELATIVE_PATHS: readonly string[] = [
path.join(".omp-plugin", "marketplace.json"),
path.join(".claude-plugin", "marketplace.json"),
];
async function readMarketplaceCatalog(root: string): Promise<{ catalogPath: string; content: string }> {
const tried: string[] = [];
for (const rel of CATALOG_RELATIVE_PATHS) {
const catalogPath = path.join(root, rel);
tried.push(catalogPath);
try {
const content = await Bun.file(catalogPath).text();
return { catalogPath, content };
} catch (err) {
if (isEnoent(err)) continue;
throw err;
}
}
throw new Error(
`Marketplace catalog not found at ${tried.map(p => `"${p}"`).join(" or ")}. ` +
`Ensure the directory exists and contains one of: ${CATALOG_RELATIVE_PATHS.join(", ")}.`,
);
}
/**
* Expand a `~/...` path to an absolute path using os.homedir().
@@ -220,21 +245,7 @@ export async function fetchMarketplace(source: string, cacheDir: string): Promis
if (type === "local") {
const resolved = path.resolve(expandHome(source));
const catalogPath = path.join(resolved, CATALOG_RELATIVE_PATH);
let content: string;
try {
content = await Bun.file(catalogPath).text();
} catch (err) {
if (isEnoent(err)) {
throw new Error(
`Marketplace catalog not found at "${catalogPath}". ` +
`Ensure the directory exists and contains a .claude-plugin/marketplace.json file.`,
);
}
throw err;
}
const { catalogPath, content } = await readMarketplaceCatalog(resolved);
const catalog = parseMarketplaceCatalog(content, catalogPath);
return { catalog };
}
@@ -280,27 +291,14 @@ async function cloneAndReadCatalog(url: string, cacheDir: string): Promise<Fetch
logger.debug(`[marketplace] cloning ${url} → ${tmpDir}`);
await git.clone(url, tmpDir);
const catalogPath = path.join(tmpDir, CATALOG_RELATIVE_PATH);
let content: string;
try {
content = await Bun.file(catalogPath).text();
} catch (err) {
await fs.rm(tmpDir, { recursive: true, force: true });
if (isEnoent(err)) {
throw new Error(`Cloned repository has no marketplace catalog at ${CATALOG_RELATIVE_PATH}`);
}
throw err;
}
let catalog: MarketplaceCatalog;
try {
catalog = parseMarketplaceCatalog(content, catalogPath);
const { catalogPath, content } = await readMarketplaceCatalog(tmpDir);
const catalog = parseMarketplaceCatalog(content, catalogPath);
return { catalog, clonePath: tmpDir };
} catch (err) {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
throw err;
throw new Error(`Cloned repository ${url}: ${(err as Error).message}`, { cause: err });
}
return { catalog, clonePath: tmpDir };
}
/**
@@ -180,6 +180,57 @@ describe("fetchMarketplace", () => {
await expect(fetchMarketplace(fakeSrc, tmpDir)).rejects.toThrow(/Marketplace catalog not found/);
});
it("loads catalog from .omp-plugin/marketplace.json when present", async () => {
const root = path.join(tmpDir, "omp-only");
fs.mkdirSync(path.join(root, ".omp-plugin"), { recursive: true });
const catalog = {
name: "omp-only-marketplace",
owner: { name: "Test" },
plugins: [{ name: "omp-plugin", source: "./plugins/omp-plugin", description: "x" }],
};
fs.writeFileSync(path.join(root, ".omp-plugin", "marketplace.json"), JSON.stringify(catalog));
const result = await fetchMarketplace(root, tmpDir);
expect(result.catalog.name).toBe("omp-only-marketplace");
expect(result.catalog.plugins[0].name).toBe("omp-plugin");
});
it("prefers .omp-plugin/marketplace.json over .claude-plugin/marketplace.json when both exist", async () => {
const root = path.join(tmpDir, "both-catalogs");
fs.mkdirSync(path.join(root, ".omp-plugin"), { recursive: true });
fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true });
const ompCatalog = {
name: "from-omp-plugin",
owner: { name: "Test" },
plugins: [{ name: "p", source: "./p", description: "x" }],
};
const claudeCatalog = {
name: "from-claude-plugin",
owner: { name: "Test" },
plugins: [{ name: "p", source: "./p", description: "x" }],
};
fs.writeFileSync(path.join(root, ".omp-plugin", "marketplace.json"), JSON.stringify(ompCatalog));
fs.writeFileSync(path.join(root, ".claude-plugin", "marketplace.json"), JSON.stringify(claudeCatalog));
const result = await fetchMarketplace(root, tmpDir);
expect(result.catalog.name).toBe("from-omp-plugin");
});
it("falls back to .claude-plugin/marketplace.json when .omp-plugin is absent", async () => {
// The shared fixture only ships .claude-plugin/marketplace.json — confirms
// the legacy path still loads unchanged.
const result = await fetchMarketplace(FIXTURE_DIR, tmpDir);
expect(result.catalog.name).toBe("test-marketplace");
});
it("error message names both candidate paths when neither exists", async () => {
const empty = path.join(tmpDir, "empty-dir");
fs.mkdirSync(empty, { recursive: true });
await expect(fetchMarketplace(empty, tmpDir)).rejects.toThrow(
/\.omp-plugin[\\/]marketplace\.json.*\.claude-plugin[\\/]marketplace\.json/,
);
});
// Network-dependent tests — skip in CI / offline environments.
// These verify real git clone and HTTP fetch error handling.
it.skip("github source throws on nonexistent repo", async () => {