Files
oh-my-pi/packages/coding-agent/test/marketplace/slash-install-parser.test.ts
T
Miroslav Drbal 05966c8910 feat(coding-agent): marketplace plugin project scope
Plugins can now be installed at user scope (global) or project scope
(per-project, higher capability priority). Scope is encoded in registry
file location, not a metadata field:

  user:    ~/.omp/plugins/installed_plugins.json
  project: <nearest-project>/.omp/plugins/installed_plugins.json
  cache:   ~/.omp/plugins/cache/plugins/  (shared, path-referenced)

Project root discovery: resolveActiveProjectRegistryPath(cwd) walks up
from cwd looking for the nearest .omp/ directory, falling back to the
nearest .git root. This is the single resolver used by install, uninstall,
list, upgrade, discovery, and doctor.

Discovery: listClaudePluginRoots(home, cwd?) reads both registries when
cwd is provided. Project entries shadow user entries for the same plugin
ID. Cache key is canonical ("${home}:${resolvedProjectPath}") so nested
cwds within the same project share a cache entry.

Manager changes:
- installPlugin({ scope? }): routes registry reads/writes by scope;
  checks collectReferencedPaths() across both registries before deleting
  any cached plugin dir to prevent cross-scope data loss
- uninstallPlugin(id, scope?), setPluginEnabled(id, enabled, scope?),
  upgradePlugin(id, scope?): throw a disambiguation error when the plugin
  exists in both scopes and no scope is specified
- upgradePluginAcrossScopes(id): new; upgrades all scopes the plugin is
  installed in; returns InstalledPluginEntry[]
- upgradeAllPlugins(): uses upgradePluginAcrossScopes; result includes scope
- listInstalledPlugins(): returns InstalledPluginSummary[] merged from both
  registries; user entries marked shadowedBy: "project" when overridden

CLI: omp plugin install|uninstall|upgrade|enable|disable --scope user|project
Slash: /marketplace install [--scope user|project] name@marketplace
MarketplaceManager constructed with projectInstalledRegistryPath in all
CLI handlers and builtin-registry.ts via resolveActiveProjectRegistryPath.

.gitignore: .omp/plugins/ added (local runtime state, not committed).
preloadPluginRoots/clearClaudePluginRootsCache carry cwd through for LSP.
main.ts passes getProjectDir() at startup.

Tests: 226 pass across 13 files. New: project-scope.test.ts (resolver
walk-up, .git fallback, null return, canonical path, shadow precedence);
manager scope tests (registry isolation, disambiguation errors, cross-scope
cache-ref protection, upgradePluginAcrossScopes, shadowedBy marking).

fixes #581
2026-03-31 17:43:09 +02:00

170 lines
5.9 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { parseMarketplaceInstallArgs, parsePluginScopeArgs } from "../../src/slash-commands/marketplace-install-parser";
// ── Helpers ───────────────────────────────────────────────────────────────────
function ok(rest: string) {
const r = parseMarketplaceInstallArgs(rest);
if ("error" in r) throw new Error(`Expected success, got error: ${r.error}`);
return r;
}
function err(rest: string) {
const r = parseMarketplaceInstallArgs(rest);
if (!("error" in r)) throw new Error(`Expected error, got success: ${JSON.stringify(r)}`);
return r.error;
}
// ── Success paths ─────────────────────────────────────────────────────────────
describe("parseMarketplaceInstallArgs — success", () => {
it("bare spec → defaults force=false scope=user", () => {
expect(ok("hello@market")).toEqual({ force: false, scope: "user", installSpec: "hello@market" });
});
it("--force flag", () => {
expect(ok("--force hello@market")).toEqual({ force: true, scope: "user", installSpec: "hello@market" });
});
it("--scope user", () => {
expect(ok("--scope user hello@market")).toEqual({ force: false, scope: "user", installSpec: "hello@market" });
});
it("--scope project", () => {
expect(ok("--scope project hello@market")).toEqual({
force: false,
scope: "project",
installSpec: "hello@market",
});
});
it("--force and --scope project together", () => {
expect(ok("--force --scope project hello@market")).toEqual({
force: true,
scope: "project",
installSpec: "hello@market",
});
});
it("flags after the positional", () => {
expect(ok("hello@market --force")).toEqual({ force: true, scope: "user", installSpec: "hello@market" });
});
it("positional between flags", () => {
expect(ok("--scope project hello@market --force")).toEqual({
force: true,
scope: "project",
installSpec: "hello@market",
});
});
it("extra whitespace is tolerated", () => {
expect(ok(" hello@market ")).toEqual({ force: false, scope: "user", installSpec: "hello@market" });
});
});
// ── Error paths ───────────────────────────────────────────────────────────────
describe("parseMarketplaceInstallArgs — errors", () => {
it("empty string → usage", () => {
expect(err("")).toMatch(/Usage:/);
});
it("spec without @ → usage", () => {
expect(err("hello-world")).toMatch(/Usage:/);
});
it("unknown flag → Unknown flag message", () => {
expect(err("--froce hello@market")).toMatch(/Unknown flag.*--froce/);
});
it("unknown flag before positional still rejects", () => {
expect(err("--unknown")).toMatch(/Unknown flag/);
});
it("invalid scope value → clear error", () => {
expect(err("--scope admin hello@market")).toMatch(/Invalid --scope value.*admin/);
});
it("--scope with no value at end → requires a value", () => {
expect(err("hello@market --scope")).toMatch(/requires a value/);
});
it("--scope followed immediately by another flag → requires a value", () => {
expect(err("--scope --force hello@market")).toMatch(/requires a value/);
});
it("multiple positional args → unexpected argument", () => {
expect(err("a@x b@y")).toMatch(/Unexpected argument/);
});
it("flags only, no positional → usage", () => {
expect(err("--force")).toMatch(/Usage:/);
});
});
// ── parsePluginScopeArgs ─────────────────────────────────────────────────────
function scopeOk(rest: string) {
const r = parsePluginScopeArgs(rest, "Usage: /test [--scope user|project] <id@mkt>");
if ("error" in r) throw new Error(`Expected success, got error: ${r.error}`);
return r;
}
function scopeErr(rest: string) {
const r = parsePluginScopeArgs(rest, "Usage: /test [--scope user|project] <id@mkt>");
if (!("error" in r)) throw new Error(`Expected error, got success: ${JSON.stringify(r)}`);
return r.error;
}
describe("parsePluginScopeArgs — success", () => {
it("bare id → scope undefined", () => {
expect(scopeOk("hello@market")).toEqual({ pluginId: "hello@market", scope: undefined });
});
it("--scope user", () => {
expect(scopeOk("--scope user hello@market")).toEqual({ pluginId: "hello@market", scope: "user" });
});
it("--scope project", () => {
expect(scopeOk("hello@market --scope project")).toEqual({ pluginId: "hello@market", scope: "project" });
});
it("extra whitespace is tolerated", () => {
expect(scopeOk(" hello@market ")).toEqual({ pluginId: "hello@market", scope: undefined });
});
});
describe("parsePluginScopeArgs — errors", () => {
it("empty string → usage hint", () => {
expect(scopeErr("")).toMatch(/Usage:/);
});
it("unknown flag → Unknown flag message with usage", () => {
expect(scopeErr("--froce hello@market")).toMatch(/Unknown flag.*--froce/);
});
it("multiple positional args → unexpected argument", () => {
expect(scopeErr("a@x b@y")).toMatch(/Unexpected argument/);
});
it("invalid scope value → clear error", () => {
expect(scopeErr("--scope admin hello@market")).toMatch(/Invalid --scope value.*admin/);
});
it("--scope with no value at end → requires a value", () => {
expect(scopeErr("hello@market --scope")).toMatch(/requires a value/);
});
it("--scope followed by another flag → requires a value", () => {
expect(scopeErr("--scope --other hello@market")).toMatch(/requires a value/);
});
});
// extra error path: valid flags but no positional
describe("parsePluginScopeArgs — valid flags, missing id", () => {
it("--scope with no plugin id → usage hint", () => {
expect(scopeErr("--scope project")).toMatch(/Usage:/);
});
});