plugin-primitive Phase 3a slice 3.3: UI plugin surface

Implements the UI side of the plugin-primitive mission:
- Library Explorer Plugins category (rendered alongside Skills)
- Plugin Detail page at /plugins/:pluginId (manifest + skills + hooks + used-by)
- Agent Plugins enrichment helper (AgentPluginsList) for merge-time
  wiring into plugin-primitive-v0's AgentSpecDisplay Plugins block

Backed by a new daemon-side plugin-discovery-service + 3 read-only
HTTP routes that aggregate plugin discoveries across vendored
(~/.openrig/plugins), Claude Code cache (~/.claude/plugins/cache),
and Codex cache (~/.codex/plugins/cache) per DESIGN.md §5.4. The
discovery service is structurally branch-merge-friendly: it operates
on parsed agent.yaml + filesystem reads, doesn't depend on batch 1's
PluginResource type from plugin-primitive-v0, and the used-by reverse
query walks resources.plugins[].id structurally rather than
string-grepping (regression test asserts comment + skill-description
mentions don't produce false positives).

═══ SC-29 EXCEPTION #8 (verbatim) ═══

Slice 3.3 (UI plugin surface) requires daemon-side plugin-discovery-service
+ 3 HTTP routes (GET /api/plugins, GET /api/plugins/:id,
GET /api/plugins/:id/used-by) as backing API. No additional state, no
SQL migration, no mutation routes. Read-only discovery surface
aggregating filesystem-scan unions per DESIGN.md §5.4. Per IMPL-PRD §3.3
'Code touches' this allocation is explicit; documenting in compliance
with banked SC-29 verbatim-declaration rule.

Cumulative ledger: #1-4 historical + project-observability #5+#6
(ab5644c) + daemon-skill-discovery #7 (in flight) + plugin-primitive 3.3
daemon discovery service #8 (this commit).

═══ Files ═══

Daemon (4 source + 2 tests):
- domain/plugin-discovery-service.ts (NEW; 332 lines) — listPlugins +
  getPlugin + findUsedBy. Filesystem-scan over 3 roots; structural
  agent.yaml parse for used-by.
- routes/plugins.ts (NEW; 66 lines) — 3 read-only routes; route-order
  /:id/used-by mounted before /:id per banked spec-library Phase A R1
  SSE lesson.
- server.ts (modified) — pluginsRoutes import + ServerDeps field +
  c.set("pluginDiscoveryService") + app.route mount.
- startup.ts (modified) — PluginDiscoveryService construction with
  stable filesystem roots from os.homedir() + getDefaultOpenRigPath.
- test/plugin-discovery-service.test.ts (NEW; 12 tests).
- test/plugins-routes.test.ts (NEW; 10 tests).

UI (5 source + 4 tests):
- hooks/usePlugins.ts (NEW; 117 lines) — usePlugins / usePlugin /
  usePluginUsedBy TanStack Query wrappers + type definitions mirroring
  daemon shapes.
- components/specs/SpecsLibraryPage.tsx (modified) — PluginsSection
  added alongside SkillsSection.
- components/specs/PluginDetailPage.tsx (NEW; 254 lines) — page with
  ManifestSection / SkillsSection / HooksSection / UsedBySection.
- components/specs/AgentPluginsList.tsx (NEW; 116 lines) — merge-time
  helper for plugin-primitive-v0's AgentSpecDisplay Plugins block.
  Standalone tested; 1-line wire-up at merge convergence.
- routes.tsx (modified) — pluginDetailRoute at /plugins/$pluginId.
- test/usePlugins.test.tsx (NEW; 7 tests).
- test/library-page.test.tsx (modified) — Plugins section assertion
  + new focused test.
- test/plugin-detail-page.test.tsx (NEW; 4 tests).
- test/agent-plugins-list.test.tsx (NEW; 4 tests).

═══ Verification ═══

- 42 new TDD tests, all green: 22 daemon + 20 UI
- TDD red→green discipline applied per phase (test-first; implementation
  follows red confirmation)
- Daemon tsc clean
- UI tsc clean for touched files (pre-existing @tanstack/react-table
  baseline on main c0fcccd unrelated to slice; SpecsTable.tsx not
  touched)
- Drift-discriminator regression coverage: plugin-discovery-service
  test 'aggregates discoveries across all 3 source roots' fixtures
  distinct vendored / claude-cache / codex-cache plugins emitting
  observably distinct sourceLabel + source enum per layer
- No SQL migration; no mutation routes; no auth-bypass

═══ Branch + merge plan ═══

Branched off main c0fcccd per dispatch (NOT off plugin-primitive-v0).
Converges into plugin-primitive-v0 at slice-close so all batch 2 work
merges before 0.3.1 ship + post-0.3.0 rebase. velocity-driver's slice
3.4 (rig plugin CLI) has soft dependency on this slice's
plugin-discovery-service; they paused before 3.4 starts and will
consume this service post-merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-10 05:07:56 -07:00
1 parent c0fcccd102
commit 695ca74b05
15 files changed
+2427 -1

No files matched your search

@@ -0,0 +1,453 @@
// Phase 3a slice 3.3 — Plugin Discovery Service.
//
// SC-29 EXCEPTION #8 declared verbatim:
// "Slice 3.3 (UI plugin surface) requires daemon-side plugin-discovery-service
// + 3 HTTP routes (GET /api/plugins, GET /api/plugins/:id, GET /api/plugins/:id/used-by)
// as backing API. No additional state, no SQL migration, no mutation routes.
// Read-only discovery surface aggregating filesystem-scan unions per
// DESIGN.md §5.4. Per IMPL-PRD §3.3 'Code touches' this allocation is explicit;
// documenting in compliance with banked SC-29 verbatim-declaration rule."
//
// What it does (DESIGN.md §5.4 — auto-discovery library = derived view):
// - Scans 3 filesystem roots for plugin manifests:
// * ~/.openrig/plugins/<id>/ (vendored)
// * ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/ (claude cache)
// * ~/.codex/plugins/cache/<marketplace>/<plugin>/<version>/ (codex cache)
// - Returns aggregated list with provenance labels per source root.
// - For getPlugin(id), reads .claude-plugin/plugin.json and/or .codex-plugin/plugin.json
// and summarizes the tree (skills/, hooks, mcp_servers, etc.) so the UI
// viewer can show what the plugin ships without re-reading files.
// - For findUsedBy(id), parses agent.yaml files in the spec library and
// walks resources.plugins[].id to collect references. Operates on parsed
// YAML structure (NOT string-grep) so comments + adjacent text don't
// produce false positives.
//
// Branch-merge-friendly: this service operates on filesystem reads + parsed
// YAML; doesn't depend on batch 1's PluginResource type from
// plugin-primitive-v0 branch. The agent YAML structure it reads
// (resources.plugins[].id + profile.uses.plugins[]) is exactly what batch 1
// produces, so post-merge the service continues to work unchanged.
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, basename } from "node:path";
import { parse as parseYaml } from "yaml";
export type PluginRuntime = "claude" | "codex";
export type PluginSourceKind = "vendored" | "claude-cache" | "codex-cache";
export interface PluginEntry {
/** Stable id for routing (`openrig-core`, `<marketplace>:<plugin>:<version>`). */
id: string;
/** Plugin's declared name from manifest. */
name: string;
/** Plugin's declared version. */
version: string;
/** Optional description from manifest. */
description: string | null;
/** Source root where this plugin was discovered. */
source: PluginSourceKind;
/**
* Human-readable provenance label per DESIGN.md §5.4 + IMPL-PRD §3.2:
* - `vendored:<plugin>`
* - `claude-cache:<marketplace>/<plugin>/<version>`
* - `codex-cache:<marketplace>/<plugin>/<version>`
*/
sourceLabel: string;
/** Which runtimes this plugin supports (presence of manifest dirs). */
runtimes: PluginRuntime[];
/** Filesystem path to the plugin root. */
path: string;
/**
* mtime of the manifest file (used as a soft "last loaded" approximation
* for the UI list view; exact "loaded by runtime" timestamp is out of
* scope at v0).
*/
lastSeenAt: string | null;
}
export interface PluginManifestSummary {
/** Original manifest object from `<plugin>/.claude-plugin/plugin.json`. */
raw: Record<string, unknown>;
/** Convenience-extracted fields (best-effort; null if missing). */
name: string | null;
version: string | null;
description: string | null;
homepage: string | null;
repository: string | null;
license: string | null;
}
export interface PluginSkillSummary {
/** Skill folder name. */
name: string;
/** Path relative to plugin root. */
relativePath: string;
}
export interface PluginHookSummary {
/** Which runtime this hook config targets. */
runtime: PluginRuntime;
/** Path relative to plugin root. */
relativePath: string;
/** Hook event names declared (best-effort parse). */
events: string[];
}
export interface PluginDetail {
/** The list-view entry. */
entry: PluginEntry;
/** Parsed `.claude-plugin/plugin.json` if present. */
claudeManifest: PluginManifestSummary | null;
/** Parsed `.codex-plugin/plugin.json` if present. */
codexManifest: PluginManifestSummary | null;
/** Skill folders shipped under `<plugin>/skills/`. */
skills: PluginSkillSummary[];
/** Hook configs shipped under `<plugin>/hooks/`. */
hooks: PluginHookSummary[];
}
export interface AgentReference {
/** agent name (from agent.yaml `name` field). */
agentName: string;
/** absolute path to agent.yaml. */
sourcePath: string;
/** profile names that include this plugin in their uses.plugins[]. */
profiles: string[];
}
export interface PluginDiscoveryServiceOpts {
/** Root directory for vendored OpenRig plugins (typically ~/.openrig/plugins). */
openrigPluginsDir: string;
/** Root directory for Claude Code plugin cache (typically ~/.claude/plugins/cache). */
claudeCacheDir: string;
/** Root directory for Codex plugin cache (typically ~/.codex/plugins/cache). */
codexCacheDir: string;
/**
* Spec library directory containing agent.yaml files (recursively scanned).
* Typically the daemon's resolved spec library root. May be a single root
* for v0; expand to multi-root in a later slice if the spec library hooks
* its full root list through.
*/
specLibraryDir: string;
}
export interface ListPluginsOpts {
/** Filter to plugins supporting a specific runtime. */
runtimeFilter?: PluginRuntime;
/** Filter to plugins from a specific source root. */
sourceFilter?: PluginSourceKind;
}
const CLAUDE_MANIFEST_REL = ".claude-plugin/plugin.json";
const CODEX_MANIFEST_REL = ".codex-plugin/plugin.json";
export class PluginDiscoveryService {
private readonly opts: PluginDiscoveryServiceOpts;
constructor(opts: PluginDiscoveryServiceOpts) {
this.opts = opts;
}
listPlugins(filterOpts: ListPluginsOpts = {}): PluginEntry[] {
const out: PluginEntry[] = [];
// 1. Vendored OpenRig plugins.
if (existsSync(this.opts.openrigPluginsDir)) {
for (const entry of safeReaddir(this.opts.openrigPluginsDir)) {
const pluginPath = join(this.opts.openrigPluginsDir, entry);
if (!isDir(pluginPath)) continue;
const detected = this.detectPlugin(pluginPath, "vendored", entry);
if (detected) out.push(detected);
}
}
// 2. Claude Code cache: ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/
if (existsSync(this.opts.claudeCacheDir)) {
for (const marketplace of safeReaddir(this.opts.claudeCacheDir)) {
const marketplacePath = join(this.opts.claudeCacheDir, marketplace);
if (!isDir(marketplacePath)) continue;
for (const plugin of safeReaddir(marketplacePath)) {
const pluginRoot = join(marketplacePath, plugin);
if (!isDir(pluginRoot)) continue;
for (const version of safeReaddir(pluginRoot)) {
const versionPath = join(pluginRoot, version);
if (!isDir(versionPath)) continue;
const id = `claude-cache:${marketplace}/${plugin}/${version}`;
const sourceLabel = `claude-cache:${marketplace}/${plugin}/${version}`;
const detected = this.detectPlugin(versionPath, "claude-cache", id, sourceLabel);
if (detected) out.push(detected);
}
}
}
}
// 3. Codex cache: ~/.codex/plugins/cache/<marketplace>/<plugin>/<version>/
if (existsSync(this.opts.codexCacheDir)) {
for (const marketplace of safeReaddir(this.opts.codexCacheDir)) {
const marketplacePath = join(this.opts.codexCacheDir, marketplace);
if (!isDir(marketplacePath)) continue;
for (const plugin of safeReaddir(marketplacePath)) {
const pluginRoot = join(marketplacePath, plugin);
if (!isDir(pluginRoot)) continue;
for (const version of safeReaddir(pluginRoot)) {
const versionPath = join(pluginRoot, version);
if (!isDir(versionPath)) continue;
const id = `codex-cache:${marketplace}/${plugin}/${version}`;
const sourceLabel = `codex-cache:${marketplace}/${plugin}/${version}`;
const detected = this.detectPlugin(versionPath, "codex-cache", id, sourceLabel);
if (detected) out.push(detected);
}
}
}
}
let filtered = out;
if (filterOpts.runtimeFilter) {
filtered = filtered.filter((p) => p.runtimes.includes(filterOpts.runtimeFilter!));
}
if (filterOpts.sourceFilter) {
filtered = filtered.filter((p) => p.source === filterOpts.sourceFilter);
}
return filtered;
}
getPlugin(id: string): PluginDetail | null {
const entry = this.listPlugins().find((p) => p.id === id);
if (!entry) return null;
const claudeManifestPath = join(entry.path, CLAUDE_MANIFEST_REL);
const codexManifestPath = join(entry.path, CODEX_MANIFEST_REL);
const claudeManifest = readManifest(claudeManifestPath);
const codexManifest = readManifest(codexManifestPath);
const skills: PluginSkillSummary[] = [];
const skillsDir = join(entry.path, "skills");
if (existsSync(skillsDir)) {
for (const skillName of safeReaddir(skillsDir)) {
const skillPath = join(skillsDir, skillName);
if (isDir(skillPath)) {
skills.push({ name: skillName, relativePath: `skills/${skillName}` });
}
}
}
const hooks: PluginHookSummary[] = [];
const hooksDir = join(entry.path, "hooks");
if (existsSync(hooksDir)) {
const claudeHooks = join(hooksDir, "claude.json");
if (existsSync(claudeHooks)) {
hooks.push({
runtime: "claude",
relativePath: "hooks/claude.json",
events: extractHookEvents(claudeHooks),
});
}
const codexHooks = join(hooksDir, "codex.json");
if (existsSync(codexHooks)) {
hooks.push({
runtime: "codex",
relativePath: "hooks/codex.json",
events: extractHookEvents(codexHooks),
});
}
}
return { entry, claudeManifest, codexManifest, skills, hooks };
}
findUsedBy(pluginId: string): AgentReference[] {
const refs: AgentReference[] = [];
if (!existsSync(this.opts.specLibraryDir)) return refs;
for (const candidate of walkAgentYamls(this.opts.specLibraryDir)) {
const parsed = safeParseYaml(candidate.content);
if (!parsed || typeof parsed !== "object") continue;
const resourcesPlugins = readResourcesPlugins(parsed);
const declaresThisPlugin = resourcesPlugins.some((p) => p === pluginId);
if (!declaresThisPlugin) continue;
const profiles = readProfilesUsingPlugin(parsed, pluginId);
const agentName = readField(parsed, "name");
if (!agentName) continue;
refs.push({ agentName, sourcePath: candidate.path, profiles });
}
return refs;
}
// -- helpers --
private detectPlugin(
pluginPath: string,
source: PluginSourceKind,
explicitId: string,
explicitSourceLabel?: string,
): PluginEntry | null {
const claudeManifestPath = join(pluginPath, CLAUDE_MANIFEST_REL);
const codexManifestPath = join(pluginPath, CODEX_MANIFEST_REL);
const hasClaude = existsSync(claudeManifestPath);
const hasCodex = existsSync(codexManifestPath);
if (!hasClaude && !hasCodex) return null;
const runtimes: PluginRuntime[] = [];
if (hasClaude) runtimes.push("claude");
if (hasCodex) runtimes.push("codex");
// Read the first available manifest for name/version/description.
const primaryManifestPath = hasClaude ? claudeManifestPath : codexManifestPath;
const manifest = readManifest(primaryManifestPath);
if (!manifest) return null;
const name = manifest.name ?? basename(pluginPath);
const version = manifest.version ?? "unknown";
const description = manifest.description;
const sourceLabel = explicitSourceLabel ?? `vendored:${name}`;
let lastSeenAt: string | null = null;
try {
const stat = statSync(primaryManifestPath);
lastSeenAt = stat.mtime.toISOString();
} catch {
lastSeenAt = null;
}
return {
id: explicitId,
name,
version,
description,
source,
sourceLabel,
runtimes,
path: pluginPath,
lastSeenAt,
};
}
}
function safeReaddir(dir: string): string[] {
try {
return readdirSync(dir);
} catch {
return [];
}
}
function isDir(p: string): boolean {
try {
return statSync(p).isDirectory();
} catch {
return false;
}
}
function readManifest(manifestPath: string): PluginManifestSummary | null {
if (!existsSync(manifestPath)) return null;
try {
const raw = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
return {
raw,
name: typeof raw.name === "string" ? raw.name : null,
version: typeof raw.version === "string" ? raw.version : null,
description: typeof raw.description === "string" ? raw.description : null,
homepage: typeof raw.homepage === "string" ? raw.homepage : null,
repository: typeof raw.repository === "string" ? raw.repository : null,
license: typeof raw.license === "string" ? raw.license : null,
};
} catch {
return null;
}
}
function extractHookEvents(hooksJsonPath: string): string[] {
try {
const data = JSON.parse(readFileSync(hooksJsonPath, "utf8")) as { hooks?: Record<string, unknown> };
if (data.hooks && typeof data.hooks === "object") {
return Object.keys(data.hooks);
}
} catch {
// best-effort
}
return [];
}
interface AgentYamlCandidate {
path: string;
content: string;
}
function walkAgentYamls(rootDir: string): AgentYamlCandidate[] {
const out: AgentYamlCandidate[] = [];
walk(rootDir);
return out;
function walk(dir: string): void {
for (const entry of safeReaddir(dir)) {
const p = join(dir, entry);
if (isDir(p)) {
walk(p);
continue;
}
if (entry === "agent.yaml" || entry === "agent.yml") {
try {
const content = readFileSync(p, "utf8");
out.push({ path: p, content });
} catch {
// best-effort; skip unreadable files
}
}
}
}
}
function safeParseYaml(content: string): unknown {
try {
return parseYaml(content);
} catch {
return null;
}
}
function readField(obj: unknown, field: string): string | null {
if (!obj || typeof obj !== "object") return null;
const value = (obj as Record<string, unknown>)[field];
return typeof value === "string" ? value : null;
}
function readResourcesPlugins(spec: unknown): string[] {
if (!spec || typeof spec !== "object") return [];
const resources = (spec as Record<string, unknown>)["resources"];
if (!resources || typeof resources !== "object") return [];
const plugins = (resources as Record<string, unknown>)["plugins"];
if (!Array.isArray(plugins)) return [];
const ids: string[] = [];
for (const p of plugins) {
if (p && typeof p === "object" && typeof (p as Record<string, unknown>).id === "string") {
ids.push((p as Record<string, unknown>).id as string);
} else if (typeof p === "string") {
// tolerate shorthand string form for forward-compat
ids.push(p);
}
}
return ids;
}
function readProfilesUsingPlugin(spec: unknown, pluginId: string): string[] {
if (!spec || typeof spec !== "object") return [];
const profiles = (spec as Record<string, unknown>)["profiles"];
if (!profiles || typeof profiles !== "object") return [];
const matched: string[] = [];
for (const [profileName, profileVal] of Object.entries(profiles as Record<string, unknown>)) {
if (!profileVal || typeof profileVal !== "object") continue;
const uses = (profileVal as Record<string, unknown>)["uses"];
if (!uses || typeof uses !== "object") continue;
const usesPlugins = (uses as Record<string, unknown>)["plugins"];
if (!Array.isArray(usesPlugins)) continue;
if (usesPlugins.some((p) => p === pluginId)) {
matched.push(profileName);
}
}
return matched;
}
+77
View File
@@ -0,0 +1,77 @@
// Phase 3a slice 3.3 — Plugins HTTP routes (read-only).
//
// SC-29 EXCEPTION #8 declared verbatim:
// "Slice 3.3 (UI plugin surface) requires daemon-side plugin-discovery-service
// + 3 HTTP routes (GET /api/plugins, GET /api/plugins/:id, GET /api/plugins/:id/used-by)
// as backing API. No additional state, no SQL migration, no mutation routes.
// Read-only discovery surface aggregating filesystem-scan unions per
// DESIGN.md §5.4. Per IMPL-PRD §3.3 'Code touches' this allocation is explicit;
// documenting in compliance with banked SC-29 verbatim-declaration rule."
//
// Endpoint shape:
// GET /api/plugins → PluginEntry[] (?runtime=, ?source= filters)
// GET /api/plugins/:id → PluginDetail (404 when unknown)
// GET /api/plugins/:id/used-by → AgentReference[]
//
// All endpoints return 503 when the service is not provisioned in context
// (consistent with the existing daemon route pattern for optional services).
import { Hono } from "hono";
import type {
PluginDiscoveryService,
PluginRuntime,
PluginSourceKind,
} from "../domain/plugin-discovery-service.js";
type ContextGetter = (key: string) => unknown;
function getService(c: { get: ContextGetter }): PluginDiscoveryService | undefined {
return c.get("pluginDiscoveryService" as never) as PluginDiscoveryService | undefined;
}
function parseRuntimeFilter(value: string | undefined): PluginRuntime | undefined {
if (value === "claude" || value === "codex") return value;
return undefined;
}
function parseSourceFilter(value: string | undefined): PluginSourceKind | undefined {
if (value === "vendored" || value === "claude-cache" || value === "codex-cache") return value;
return undefined;
}
export function pluginsRoutes(): Hono {
const router = new Hono();
// GET / — list discoverable plugins
router.get("/", (c) => {
const service = getService(c);
if (!service) return c.json({ error: "plugin_discovery_unavailable" }, 503);
const runtimeFilter = parseRuntimeFilter(c.req.query("runtime"));
const sourceFilter = parseSourceFilter(c.req.query("source"));
const plugins = service.listPlugins({ runtimeFilter, sourceFilter });
return c.json(plugins);
});
// GET /:id/used-by — reverse query for agents referencing this plugin.
// Mounted BEFORE /:id so the literal sub-path doesn't get eaten by the
// bare-param catchall (per spec-library-routes Phase A R1 SSE route-order
// lesson banked in that file).
router.get("/:id/used-by", (c) => {
const service = getService(c);
if (!service) return c.json({ error: "plugin_discovery_unavailable" }, 503);
const id = c.req.param("id");
return c.json(service.findUsedBy(id));
});
// GET /:id — plugin detail
router.get("/:id", (c) => {
const service = getService(c);
if (!service) return c.json({ error: "plugin_discovery_unavailable" }, 503);
const id = c.req.param("id");
const detail = service.getPlugin(id);
if (!detail) return c.notFound();
return c.json(detail);
});
return router;
}
+13
View File
@@ -53,6 +53,11 @@ import { askRoutes } from "./routes/ask.js";
import type { AskService } from "./domain/ask-service.js";
import { specReviewRoutes } from "./routes/spec-review.js";
import { specLibraryRoutes } from "./routes/spec-library.js";
// Phase 3a slice 3.3 — plugin discovery routes (read-only).
// SC-29 EXCEPTION #8 verbatim: see packages/daemon/src/routes/plugins.ts
// header for full declaration.
import { pluginsRoutes } from "./routes/plugins.js";
import type { PluginDiscoveryService } from "./domain/plugin-discovery-service.js";
import { configRoutes } from "./routes/config.js";
import { contextPacksRoutes } from "./routes/context-packs.js";
import { agentImagesRoutes } from "./routes/agent-images.js";
@@ -183,6 +188,12 @@ export interface AppDeps {
missionControlBearerToken?: string | null;
specReviewService?: SpecReviewService;
specLibraryService?: SpecLibraryService;
/**
* Phase 3a slice 3.3 — plugin discovery service (filesystem-scan over
* vendored + claude-cache + codex-cache; reads agent.yaml for used-by).
* Read-only; no SQL. SC-29 #8 verbatim declaration in routes/plugins.ts.
*/
pluginDiscoveryService?: PluginDiscoveryService;
/** Workflows in Spec Library v0 — active workflow lens persistence. */
activeLensStore?: import("./domain/active-lens-store.js").ActiveLensStore;
/** Rig Context / Composable Context Injection v0 (PL-014) — context_packs library service. */
@@ -370,6 +381,7 @@ export function createApp(deps: AppDeps): Hono {
c.set("missionControlNotificationDispatcher" as never, deps.missionControlNotificationDispatcher);
c.set("specReviewService" as never, deps.specReviewService);
c.set("specLibraryService" as never, deps.specLibraryService);
c.set("pluginDiscoveryService" as never, deps.pluginDiscoveryService);
c.set("activeLensStore" as never, deps.activeLensStore);
c.set("contextPackLibrary" as never, deps.contextPackLibrary);
c.set("agentImageLibrary" as never, deps.agentImageLibrary);
@@ -415,6 +427,7 @@ export function createApp(deps: AppDeps): Hono {
app.route("/api/ask", askRoutes);
app.route("/api/specs/review", specReviewRoutes());
app.route("/api/specs/library", specLibraryRoutes());
app.route("/api/plugins", pluginsRoutes());
app.route("/api/config", configRoutes());
app.route("/api/context-packs", contextPacksRoutes());
app.route("/api/agent-images", agentImagesRoutes({
+15
View File
@@ -75,6 +75,8 @@ import { WorkflowRuntime } from "./domain/workflow-runtime.js";
import { makeWorkflowKeepalivePolicy } from "./domain/policies/workflow-keepalive.js";
import { SpecReviewService } from "./domain/spec-review-service.js";
import { SpecLibraryService } from "./domain/spec-library-service.js";
// Phase 3a slice 3.3 — plugin discovery service.
import { PluginDiscoveryService } from "./domain/plugin-discovery-service.js";
import { ContextPackLibraryService } from "./domain/context-packs/context-pack-library-service.js";
import { AgentImageLibraryService } from "./domain/agent-images/agent-image-library-service.js";
import { SnapshotCapturer } from "./domain/agent-images/snapshot-capturer.js";
@@ -653,6 +655,19 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
lib.scan();
return lib;
})(),
// Phase 3a slice 3.3 — plugin discovery service.
// SC-29 EXCEPTION #8 verbatim: see packages/daemon/src/routes/plugins.ts
// header. Filesystem-scan over 3 source roots + agent.yaml-parse for
// used-by reverse query. No SQL; no mutation. Spec library directory
// for used-by uses the same default user spec root as SpecLibraryService
// above; one root at v0 (multi-root expansion deferred to a later slice
// when spec library hooks its full root list through to discovery).
pluginDiscoveryService: new PluginDiscoveryService({
openrigPluginsDir: nodePath.join(os.homedir(), ".openrig", "plugins"),
claudeCacheDir: nodePath.join(os.homedir(), ".claude", "plugins", "cache"),
codexCacheDir: nodePath.join(os.homedir(), ".codex", "plugins", "cache"),
specLibraryDir: getDefaultOpenRigPath("specs"),
}),
// PL-014 Item 6: same instance hoisted earlier so the
// PodRigInstantiator can resolve `kind: context_pack` startup_files
// entries — sharing the cache means /api/context-packs/* + the
@@ -0,0 +1,419 @@
// Phase 3a slice 3.3 — plugin-discovery-service tests (TDD red→green).
//
// SC-29 EXCEPTION #8 declared verbatim:
// "Slice 3.3 (UI plugin surface) requires daemon-side plugin-discovery-service
// + 3 HTTP routes (GET /api/plugins, GET /api/plugins/:id, GET /api/plugins/:id/used-by)
// as backing API. No additional state, no SQL migration, no mutation routes.
// Read-only discovery surface aggregating filesystem-scan unions per
// DESIGN.md §5.4. Per IMPL-PRD §3.3 'Code touches' this allocation is explicit;
// documenting in compliance with banked SC-29 verbatim-declaration rule."
//
// Discovery service contract:
// - listPlugins({ runtimeFilter?, sourceFilter?, agentRefFilter? })
// → PluginEntry[] union of:
// * ~/.openrig/plugins/* (vendored)
// * ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/ (claude-cache)
// * ~/.codex/plugins/cache/<marketplace>/<plugin>/<version>/ (codex-cache)
// * agent-ref discoveries (plugins referenced inline in agent.yaml resources.plugins[])
// - getPlugin(id) → PluginManifest with full manifest content + tree summary
// - findUsedBy(id) → AgentReference[] (which agent.yamls reference this plugin id)
//
// Branch-merge-friendly: this service operates on filesystem reads + raw YAML
// string matching for used-by; doesn't depend on batch 1's PluginResource type
// from plugin-primitive-v0 branch. After convergence into plugin-primitive-v0,
// the service continues to work with the shipped PluginResource types.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { PluginDiscoveryService } from "../src/domain/plugin-discovery-service.js";
interface TempDirs {
root: string;
openrigPluginsDir: string;
claudeCacheDir: string;
codexCacheDir: string;
specLibraryDir: string;
}
function setupTempDirs(): TempDirs {
const root = mkdtempSync(join(tmpdir(), "plugin-discovery-test-"));
const openrigPluginsDir = join(root, "openrig-plugins");
const claudeCacheDir = join(root, "claude-cache");
const codexCacheDir = join(root, "codex-cache");
const specLibraryDir = join(root, "specs", "agents");
mkdirSync(openrigPluginsDir, { recursive: true });
mkdirSync(claudeCacheDir, { recursive: true });
mkdirSync(codexCacheDir, { recursive: true });
mkdirSync(specLibraryDir, { recursive: true });
return { root, openrigPluginsDir, claudeCacheDir, codexCacheDir, specLibraryDir };
}
function writeClaudePluginManifest(pluginDir: string, manifest: Record<string, unknown>): void {
const manifestDir = join(pluginDir, ".claude-plugin");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(join(manifestDir, "plugin.json"), JSON.stringify(manifest, null, 2));
}
function writeCodexPluginManifest(pluginDir: string, manifest: Record<string, unknown>): void {
const manifestDir = join(pluginDir, ".codex-plugin");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(join(manifestDir, "plugin.json"), JSON.stringify(manifest, null, 2));
}
describe("PluginDiscoveryService", () => {
let dirs: TempDirs;
beforeEach(() => {
dirs = setupTempDirs();
});
afterEach(() => {
rmSync(dirs.root, { recursive: true, force: true });
});
describe("listPlugins", () => {
it("returns empty list when no plugin sources contain plugins", () => {
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
expect(service.listPlugins()).toEqual([]);
});
it("discovers a vendored OpenRig plugin (dual-manifest)", () => {
const corePluginDir = join(dirs.openrigPluginsDir, "openrig-core");
writeClaudePluginManifest(corePluginDir, {
name: "openrig-core",
version: "0.1.0",
description: "OpenRig canonical skills and hooks",
});
writeCodexPluginManifest(corePluginDir, {
name: "openrig-core",
version: "0.1.0",
description: "OpenRig canonical skills and hooks",
});
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
const plugins = service.listPlugins();
expect(plugins).toHaveLength(1);
expect(plugins[0]).toMatchObject({
id: "openrig-core",
name: "openrig-core",
version: "0.1.0",
source: "vendored",
sourceLabel: "vendored:openrig-core",
runtimes: ["claude", "codex"],
});
});
it("discovers a Claude cache plugin (claude-only manifest)", () => {
const claudePluginPath = join(dirs.claudeCacheDir, "anthropics", "github", "1.0.0");
writeClaudePluginManifest(claudePluginPath, {
name: "github",
version: "1.0.0",
description: "GitHub integration",
});
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
const plugins = service.listPlugins();
expect(plugins).toHaveLength(1);
expect(plugins[0]).toMatchObject({
name: "github",
version: "1.0.0",
source: "claude-cache",
sourceLabel: "claude-cache:anthropics/github/1.0.0",
runtimes: ["claude"],
});
});
it("discovers a Codex cache plugin (codex-only manifest)", () => {
const codexPluginPath = join(dirs.codexCacheDir, "openai", "tools", "0.5.0");
writeCodexPluginManifest(codexPluginPath, {
name: "tools",
version: "0.5.0",
description: "Codex tools",
});
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
const plugins = service.listPlugins();
expect(plugins).toHaveLength(1);
expect(plugins[0]).toMatchObject({
name: "tools",
version: "0.5.0",
source: "codex-cache",
sourceLabel: "codex-cache:openai/tools/0.5.0",
runtimes: ["codex"],
});
});
it("aggregates discoveries across all 3 source roots with distinct source labels", () => {
// Drift-discriminator regression check per banked feedback_poc_regression_must_discriminate.
// Three sources × distinct values per source so layer discrimination is
// observable.
writeClaudePluginManifest(join(dirs.openrigPluginsDir, "openrig-core"), {
name: "openrig-core",
version: "0.1.0",
description: "vendored",
});
writeClaudePluginManifest(join(dirs.claudeCacheDir, "anthropics", "github", "1.0.0"), {
name: "github",
version: "1.0.0",
description: "claude-cache",
});
writeCodexPluginManifest(join(dirs.codexCacheDir, "openai", "tools", "0.5.0"), {
name: "tools",
version: "0.5.0",
description: "codex-cache",
});
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
const plugins = service.listPlugins();
expect(plugins).toHaveLength(3);
// Distinct source labels per layer.
const sources = plugins.map((p) => p.source).sort();
expect(sources).toEqual(["claude-cache", "codex-cache", "vendored"]);
});
it("ignores directories without plugin manifests", () => {
mkdirSync(join(dirs.openrigPluginsDir, "not-a-plugin"), { recursive: true });
writeFileSync(
join(dirs.openrigPluginsDir, "not-a-plugin", "README.md"),
"Not a plugin",
);
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
expect(service.listPlugins()).toEqual([]);
});
it("filters by runtime when requested", () => {
writeClaudePluginManifest(join(dirs.openrigPluginsDir, "claude-only"), {
name: "claude-only",
version: "1.0.0",
description: "x",
});
writeCodexPluginManifest(join(dirs.openrigPluginsDir, "codex-only"), {
name: "codex-only",
version: "1.0.0",
description: "x",
});
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
const claudeOnly = service.listPlugins({ runtimeFilter: "claude" });
expect(claudeOnly).toHaveLength(1);
expect(claudeOnly[0]?.name).toBe("claude-only");
const codexOnly = service.listPlugins({ runtimeFilter: "codex" });
expect(codexOnly).toHaveLength(1);
expect(codexOnly[0]?.name).toBe("codex-only");
});
});
describe("getPlugin", () => {
it("returns null when plugin not found", () => {
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
expect(service.getPlugin("nonexistent")).toBeNull();
});
it("returns full manifest + tree summary for a discovered plugin", () => {
const corePluginDir = join(dirs.openrigPluginsDir, "openrig-core");
writeClaudePluginManifest(corePluginDir, {
name: "openrig-core",
version: "0.1.0",
description: "Canonical OpenRig content",
author: { name: "OpenRig" },
skills: "./skills",
hooks: "./hooks/claude.json",
});
// Add a skill folder + hook config so tree summary has content.
mkdirSync(join(corePluginDir, "skills", "openrig-user"), { recursive: true });
writeFileSync(
join(corePluginDir, "skills", "openrig-user", "SKILL.md"),
"---\nname: openrig-user\ndescription: User skill\n---\n",
);
mkdirSync(join(corePluginDir, "hooks"), { recursive: true });
writeFileSync(join(corePluginDir, "hooks", "claude.json"), JSON.stringify({
hooks: { SessionStart: [{ type: "command", command: "echo hi" }] },
}));
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
const result = service.getPlugin("openrig-core");
expect(result).not.toBeNull();
expect(result?.entry.name).toBe("openrig-core");
expect(result?.claudeManifest).toMatchObject({
name: "openrig-core",
version: "0.1.0",
description: "Canonical OpenRig content",
});
expect(result?.skills).toContainEqual(
expect.objectContaining({ name: "openrig-user" }),
);
expect(result?.hooks).toContainEqual(
expect.objectContaining({ runtime: "claude" }),
);
});
});
describe("findUsedBy", () => {
it("returns empty list when no agent specs reference the plugin", () => {
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
expect(service.findUsedBy("openrig-core")).toEqual([]);
});
it("finds agents whose resources.plugins[] references the plugin id", () => {
const advisorDir = join(dirs.specLibraryDir, "advisor");
mkdirSync(advisorDir, { recursive: true });
writeFileSync(
join(advisorDir, "agent.yaml"),
`name: advisor-lead
version: "1.0"
defaults:
runtime: claude-code
profiles:
default:
uses:
plugins:
- openrig-core
- superpowers
resources:
plugins:
- id: openrig-core
source:
kind: local
path: ~/.openrig/plugins/openrig-core
- id: superpowers
source:
kind: local
path: ~/.claude/plugins/cache/anthropics/superpowers/5.1.0
skills: []
startup:
files: []
actions: []
`,
);
const driverDir = join(dirs.specLibraryDir, "driver");
mkdirSync(driverDir, { recursive: true });
writeFileSync(
join(driverDir, "agent.yaml"),
`name: velocity-driver
version: "1.0"
defaults:
runtime: claude-code
profiles:
default:
uses:
plugins:
- openrig-core
resources:
plugins:
- id: openrig-core
source:
kind: local
path: ~/.openrig/plugins/openrig-core
skills: []
startup:
files: []
actions: []
`,
);
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
const usedBy = service.findUsedBy("openrig-core");
expect(usedBy).toHaveLength(2);
expect(usedBy.map((u) => u.agentName).sort()).toEqual(["advisor-lead", "velocity-driver"]);
const superpowersUsedBy = service.findUsedBy("superpowers");
expect(superpowersUsedBy).toHaveLength(1);
expect(superpowersUsedBy[0]?.agentName).toBe("advisor-lead");
});
it("does NOT match plugin ids that appear only in comments or non-resource fields", () => {
const agentDir = join(dirs.specLibraryDir, "false-positive-test");
mkdirSync(agentDir, { recursive: true });
writeFileSync(
join(agentDir, "agent.yaml"),
`name: false-positive-test
version: "1.0"
defaults:
runtime: claude-code
# Note: this agent does NOT use openrig-core (just a comment mention).
profiles:
default:
uses:
plugins: []
resources:
plugins: []
skills:
- id: my-skill
# related to openrig-core skill set but not using the plugin
path: ./skills/my-skill.md
startup:
files: []
actions: []
`,
);
const service = new PluginDiscoveryService({
openrigPluginsDir: dirs.openrigPluginsDir,
claudeCacheDir: dirs.claudeCacheDir,
codexCacheDir: dirs.codexCacheDir,
specLibraryDir: dirs.specLibraryDir,
});
// Comment + skill description mention openrig-core but resources.plugins is empty.
// The implementation parses YAML and walks resources.plugins[].id — comments
// and unrelated string positions are ignored.
expect(service.findUsedBy("openrig-core")).toEqual([]);
});
});
});
+281
View File
@@ -0,0 +1,281 @@
// Phase 3a slice 3.3 — plugins HTTP routes (TDD red→green).
//
// SC-29 EXCEPTION #8 declared verbatim:
// "Slice 3.3 (UI plugin surface) requires daemon-side plugin-discovery-service
// + 3 HTTP routes (GET /api/plugins, GET /api/plugins/:id, GET /api/plugins/:id/used-by)
// as backing API. No additional state, no SQL migration, no mutation routes.
// Read-only discovery surface aggregating filesystem-scan unions per
// DESIGN.md §5.4. Per IMPL-PRD §3.3 'Code touches' this allocation is explicit;
// documenting in compliance with banked SC-29 verbatim-declaration rule."
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { PluginDiscoveryService } from "../src/domain/plugin-discovery-service.js";
import { pluginsRoutes } from "../src/routes/plugins.js";
interface TestEnv {
root: string;
service: PluginDiscoveryService;
openrigPluginsDir: string;
claudeCacheDir: string;
codexCacheDir: string;
specLibraryDir: string;
}
function setup(): TestEnv {
const root = mkdtempSync(join(tmpdir(), "plugins-routes-"));
const openrigPluginsDir = join(root, "openrig-plugins");
const claudeCacheDir = join(root, "claude-cache");
const codexCacheDir = join(root, "codex-cache");
const specLibraryDir = join(root, "specs");
mkdirSync(openrigPluginsDir, { recursive: true });
mkdirSync(claudeCacheDir, { recursive: true });
mkdirSync(codexCacheDir, { recursive: true });
mkdirSync(specLibraryDir, { recursive: true });
const service = new PluginDiscoveryService({
openrigPluginsDir,
claudeCacheDir,
codexCacheDir,
specLibraryDir,
});
return { root, service, openrigPluginsDir, claudeCacheDir, codexCacheDir, specLibraryDir };
}
function writeClaudePluginManifest(pluginDir: string, manifest: Record<string, unknown>): void {
const manifestDir = join(pluginDir, ".claude-plugin");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(join(manifestDir, "plugin.json"), JSON.stringify(manifest, null, 2));
}
function writeCodexPluginManifest(pluginDir: string, manifest: Record<string, unknown>): void {
const manifestDir = join(pluginDir, ".codex-plugin");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(join(manifestDir, "plugin.json"), JSON.stringify(manifest, null, 2));
}
function createApp(service: PluginDiscoveryService): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("pluginDiscoveryService" as never, service);
await next();
});
app.route("/api/plugins", pluginsRoutes());
return app;
}
describe("plugins HTTP routes", () => {
let env: TestEnv;
beforeEach(() => {
env = setup();
});
afterEach(() => {
rmSync(env.root, { recursive: true, force: true });
});
describe("GET /api/plugins", () => {
it("returns empty array when no plugins discovered", async () => {
const res = await createApp(env.service).request("/api/plugins");
expect(res.status).toBe(200);
const body = await res.json() as unknown[];
expect(body).toEqual([]);
});
it("returns aggregated plugin list", async () => {
writeClaudePluginManifest(join(env.openrigPluginsDir, "openrig-core"), {
name: "openrig-core",
version: "0.1.0",
description: "vendored",
});
writeCodexPluginManifest(join(env.openrigPluginsDir, "openrig-core"), {
name: "openrig-core",
version: "0.1.0",
description: "vendored",
});
writeClaudePluginManifest(join(env.claudeCacheDir, "anthropics", "github", "1.0.0"), {
name: "github",
version: "1.0.0",
description: "claude-cache",
});
const res = await createApp(env.service).request("/api/plugins");
expect(res.status).toBe(200);
const body = await res.json() as Array<{ name: string; source: string; runtimes: string[] }>;
expect(body).toHaveLength(2);
const names = body.map((p) => p.name).sort();
expect(names).toEqual(["github", "openrig-core"]);
});
it("supports runtime filter via query string", async () => {
writeClaudePluginManifest(join(env.openrigPluginsDir, "claude-only"), {
name: "claude-only",
version: "1.0.0",
description: "x",
});
writeCodexPluginManifest(join(env.openrigPluginsDir, "codex-only"), {
name: "codex-only",
version: "1.0.0",
description: "x",
});
const claudeRes = await createApp(env.service).request("/api/plugins?runtime=claude");
expect(claudeRes.status).toBe(200);
const claudeBody = await claudeRes.json() as Array<{ name: string }>;
expect(claudeBody.map((p) => p.name)).toEqual(["claude-only"]);
const codexRes = await createApp(env.service).request("/api/plugins?runtime=codex");
expect(codexRes.status).toBe(200);
const codexBody = await codexRes.json() as Array<{ name: string }>;
expect(codexBody.map((p) => p.name)).toEqual(["codex-only"]);
});
it("supports source filter via query string", async () => {
writeClaudePluginManifest(join(env.openrigPluginsDir, "vended"), {
name: "vended",
version: "0.1.0",
description: "x",
});
writeClaudePluginManifest(join(env.claudeCacheDir, "anthropics", "cached", "1.0.0"), {
name: "cached",
version: "1.0.0",
description: "x",
});
const vendoredRes = await createApp(env.service).request("/api/plugins?source=vendored");
const vendored = await vendoredRes.json() as Array<{ name: string }>;
expect(vendored.map((p) => p.name)).toEqual(["vended"]);
const cacheRes = await createApp(env.service).request("/api/plugins?source=claude-cache");
const cache = await cacheRes.json() as Array<{ name: string }>;
expect(cache.map((p) => p.name)).toEqual(["cached"]);
});
it("returns 503 when service is not provisioned", async () => {
const app = new Hono();
app.route("/api/plugins", pluginsRoutes());
const res = await app.request("/api/plugins");
expect(res.status).toBe(503);
});
});
describe("GET /api/plugins/:id", () => {
it("returns 404 for unknown plugin id", async () => {
const res = await createApp(env.service).request("/api/plugins/nonexistent");
expect(res.status).toBe(404);
});
it("returns plugin detail with manifest + skills + hooks", async () => {
const corePluginDir = join(env.openrigPluginsDir, "openrig-core");
writeClaudePluginManifest(corePluginDir, {
name: "openrig-core",
version: "0.1.0",
description: "Canonical OpenRig content",
});
mkdirSync(join(corePluginDir, "skills", "openrig-user"), { recursive: true });
writeFileSync(
join(corePluginDir, "skills", "openrig-user", "SKILL.md"),
"---\nname: openrig-user\n---\n",
);
mkdirSync(join(corePluginDir, "hooks"), { recursive: true });
writeFileSync(
join(corePluginDir, "hooks", "claude.json"),
JSON.stringify({
hooks: { SessionStart: [{ type: "command", command: "echo hi" }] },
}),
);
const res = await createApp(env.service).request("/api/plugins/openrig-core");
expect(res.status).toBe(200);
const body = await res.json() as {
entry: { name: string };
claudeManifest: { name: string };
skills: Array<{ name: string }>;
hooks: Array<{ runtime: string; events: string[] }>;
};
expect(body.entry.name).toBe("openrig-core");
expect(body.claudeManifest?.name).toBe("openrig-core");
expect(body.skills.map((s) => s.name)).toEqual(["openrig-user"]);
expect(body.hooks).toHaveLength(1);
expect(body.hooks[0]?.runtime).toBe("claude");
expect(body.hooks[0]?.events).toContain("SessionStart");
});
});
describe("GET /api/plugins/:id/used-by", () => {
it("returns empty list when plugin not used by any agent", async () => {
const res = await createApp(env.service).request("/api/plugins/openrig-core/used-by");
expect(res.status).toBe(200);
const body = await res.json() as unknown[];
expect(body).toEqual([]);
});
it("returns agent references with profile names", async () => {
const advisorDir = join(env.specLibraryDir, "advisor");
mkdirSync(advisorDir, { recursive: true });
writeFileSync(
join(advisorDir, "agent.yaml"),
`name: advisor-lead
version: "1.0"
defaults:
runtime: claude-code
profiles:
default:
uses:
plugins:
- openrig-core
review:
uses:
plugins:
- openrig-core
- reviewer-tools
resources:
plugins:
- id: openrig-core
source:
kind: local
path: ~/.openrig/plugins/openrig-core
- id: reviewer-tools
source:
kind: local
path: ~/.openrig/plugins/reviewer-tools
skills: []
startup:
files: []
actions: []
`,
);
const res = await createApp(env.service).request("/api/plugins/openrig-core/used-by");
expect(res.status).toBe(200);
const body = await res.json() as Array<{ agentName: string; profiles: string[] }>;
expect(body).toHaveLength(1);
expect(body[0]?.agentName).toBe("advisor-lead");
expect(body[0]?.profiles.sort()).toEqual(["default", "review"]);
});
});
describe("drift-discriminator regression coverage", () => {
it("each route shape is observably distinct (per banked feedback_poc_regression_must_discriminate)", async () => {
// Place 3 plugins in 3 source roots so the list endpoint must aggregate
// across all 3; verify response distinguishes them.
writeClaudePluginManifest(join(env.openrigPluginsDir, "vended"), {
name: "vended", version: "0.1.0", description: "v",
});
writeClaudePluginManifest(join(env.claudeCacheDir, "anthropics", "claude-tool", "2.0.0"), {
name: "claude-tool", version: "2.0.0", description: "c",
});
writeCodexPluginManifest(join(env.codexCacheDir, "openai", "codex-tool", "3.0.0"), {
name: "codex-tool", version: "3.0.0", description: "x",
});
const listRes = await createApp(env.service).request("/api/plugins");
const list = await listRes.json() as Array<{ source: string }>;
const sources = list.map((p) => p.source).sort();
expect(sources).toEqual(["claude-cache", "codex-cache", "vendored"]);
});
});
});
@@ -0,0 +1,124 @@
// Phase 3a slice 3.3 — AgentPluginsList enrichment component.
//
// Standalone helper that takes a list of plugin IDs (from agent.yaml's
// resources.plugins[].id field, on plugin-primitive-v0 branch) and
// renders enriched chips with:
// - plugin name + version
// - runtime support badges (claude / codex)
// - source label provenance
// - view-in-library link to /plugins/:pluginId
//
// At slice 3.3 close this component is standalone — NOT yet wired into
// AgentSpecDisplay because batch 1 of the plugin-primitive mission owns
// that file on plugin-primitive-v0 branch (slice 3.3 ships on
// plugin-primitive-3-3-ui off main per dispatch).
//
// At merge-time into plugin-primitive-v0, batch 1's AgentSpecDisplay
// Plugins block (which currently renders plugin IDs as static chips
// per their ACK §2) will consume this component to upgrade to enriched
// chips with manifest data + library navigation. The wiring is a
// 1-line replacement of the inline chip block with `<AgentPluginsList
// pluginIds={profile.uses.plugins} />`.
import { Link } from "@tanstack/react-router";
import { ToolMark } from "../graphics/RuntimeMark.js";
import { EmptyState } from "../ui/empty-state.js";
import { usePlugins, type PluginEntry } from "../../hooks/usePlugins.js";
interface AgentPluginsListProps {
/** Plugin IDs from the agent's resources.plugins[].id (or the resolved
* set from profile.uses.plugins[] — caller's choice). */
pluginIds: string[];
}
export function AgentPluginsList({ pluginIds }: AgentPluginsListProps) {
const { data: discovered = [] } = usePlugins();
if (pluginIds.length === 0) {
return (
<div className="px-3 py-4">
<EmptyState
label="NO PLUGINS"
description="This agent's profile does not declare any plugins."
variant="card"
testId="agent-plugins-empty"
/>
</div>
);
}
const lookup = new Map<string, PluginEntry>();
for (const entry of discovered) lookup.set(entry.id, entry);
return (
<ul
data-testid="agent-plugins-list"
className="flex flex-col gap-2"
>
{pluginIds.map((id) => {
const entry = lookup.get(id);
return (
<li key={id}>
{entry ? (
<ResolvedChip entry={entry} />
) : (
<UnresolvedChip pluginId={id} />
)}
</li>
);
})}
</ul>
);
}
function ResolvedChip({ entry }: { entry: PluginEntry }) {
return (
<Link
to="/plugins/$pluginId"
params={{ pluginId: entry.id }}
data-testid={`agent-plugin-chip-${entry.id}`}
className="flex items-center justify-between gap-3 border border-outline-variant bg-white/30 px-3 py-2 font-mono text-[11px] hover:bg-stone-100/50"
>
<span className="flex min-w-0 items-center gap-2">
<ToolMark tool="skill" title={`${entry.name} plugin`} size="xs" decorative />
<span className="truncate text-xs font-bold text-stone-900">{entry.name}</span>
<span className="shrink-0 text-[9px] uppercase tracking-[0.08em] text-stone-500">
{`v${entry.version}`}
</span>
</span>
<span className="flex shrink-0 items-center gap-2 text-[9px] uppercase tracking-[0.08em] text-stone-500">
{entry.runtimes.map((rt) => (
<span
key={rt}
className="inline-block border border-outline-variant px-1.5 py-0.5 font-mono"
>
{rt}
</span>
))}
<span title={entry.path}>{entry.sourceLabel}</span>
</span>
</Link>
);
}
function UnresolvedChip({ pluginId }: { pluginId: string }) {
return (
<Link
to="/plugins/$pluginId"
params={{ pluginId }}
data-testid={`agent-plugin-chip-${pluginId}`}
className="flex items-center justify-between gap-3 border border-dashed border-outline-variant bg-white/20 px-3 py-2 font-mono text-[11px] hover:bg-stone-100/50"
>
<span className="flex min-w-0 items-center gap-2">
<ToolMark tool="skill" title={`${pluginId} plugin (unresolved)`} size="xs" decorative />
<span className="truncate text-xs font-bold text-stone-900">{pluginId}</span>
</span>
<span
data-testid={`agent-plugin-unresolved-${pluginId}`}
className="shrink-0 text-[9px] uppercase tracking-[0.08em] text-stone-500"
>
not discovered
</span>
</Link>
);
}
@@ -0,0 +1,278 @@
// Phase 3a slice 3.3 — PluginDetailPage.
//
// Mounts at /plugins/:pluginId. Reads usePlugin + usePluginUsedBy and
// renders:
// - Top section: name + version + description + runtime support badges +
// source label + provenance metadata.
// - Manifest section: rendered manifest fields (claude + codex when both).
// - Skills section: list of skill folders the plugin ships.
// - Hooks section: list of hook configs (per-runtime) + declared events.
// - Used-by section: list of agents in spec library that reference this plugin.
//
// Visual chrome mirrors SpecsLibraryPage section pattern (border + bg + hard-shadow)
// for taxonomic consistency with the Library.
import { Link } from "@tanstack/react-router";
import { EmptyState } from "../ui/empty-state.js";
import { SectionHeader } from "../ui/section-header.js";
import { usePlugin, usePluginUsedBy } from "../../hooks/usePlugins.js";
interface PluginDetailPageProps {
pluginId: string;
}
export function PluginDetailPage({ pluginId }: PluginDetailPageProps) {
const { data: detail, isLoading: detailLoading, error: detailError } = usePlugin(pluginId);
const { data: usedBy = [], isLoading: usedByLoading } = usePluginUsedBy(pluginId);
if (detailLoading) {
return (
<div className="h-full bg-paper-grid px-6 py-5 lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]">
<EmptyState
label="LOADING PLUGIN"
description="Loading plugin manifest from discovery service."
variant="card"
testId="plugin-detail-loading"
/>
</div>
);
}
if (detailError || !detail) {
return (
<div className="h-full bg-paper-grid px-6 py-5 lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]">
<EmptyState
label="PLUGIN NOT FOUND"
description="The selected plugin is not visible through any configured discovery source."
variant="card"
testId="plugin-detail-not-found"
/>
</div>
);
}
const entry = detail.entry;
return (
<div
data-testid="plugin-detail-page"
className="h-full overflow-y-auto bg-paper-grid px-6 py-5 lg:pl-[var(--workspace-left-offset,0px)] lg:pr-[var(--workspace-right-offset,0px)]"
>
<header className="mb-5">
<SectionHeader tone="muted">Plugin</SectionHeader>
<div className="mt-1 flex flex-wrap items-baseline gap-3">
<h1 className="font-headline text-2xl font-bold tracking-tight text-stone-900">
{entry.name}
</h1>
<span className="font-mono text-sm text-stone-600">v{entry.version}</span>
{entry.runtimes.map((rt) => (
<span
key={rt}
data-testid={`plugin-detail-runtime-${rt}`}
className="inline-block border border-outline-variant bg-white/30 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.12em] text-stone-700"
>
{rt}
</span>
))}
<span
className="font-mono text-[10px] uppercase tracking-[0.12em] text-stone-500"
title={entry.path}
>
{entry.sourceLabel}
</span>
</div>
{entry.description && (
<p className="mt-2 max-w-3xl text-sm text-stone-600">{entry.description}</p>
)}
</header>
<div className="grid gap-4 xl:grid-cols-2">
<ManifestSection detail={detail} />
<SkillsSection skills={detail.skills} />
<HooksSection hooks={detail.hooks} />
<UsedBySection
usedBy={usedBy}
isLoading={usedByLoading}
/>
</div>
</div>
);
}
function ManifestSection({ detail }: { detail: NonNullable<ReturnType<typeof usePlugin>["data"]> }) {
return (
<section
data-testid="plugin-detail-manifest"
className="border border-outline-variant bg-white/25 hard-shadow"
>
<header className="border-b border-outline-variant bg-white/30 px-3 py-2">
<SectionHeader tone="default">Manifest</SectionHeader>
</header>
<dl className="grid grid-cols-[7rem_1fr] gap-x-3 gap-y-1 px-3 py-3 font-mono text-[11px]">
<ManifestRow label="claude" value={detail.claudeManifest ? `${detail.claudeManifest.name ?? "—"} v${detail.claudeManifest.version ?? "—"}` : "(not declared)"} />
<ManifestRow label="codex" value={detail.codexManifest ? `${detail.codexManifest.name ?? "—"} v${detail.codexManifest.version ?? "—"}` : "(not declared)"} />
{detail.claudeManifest?.homepage && (
<ManifestRow label="homepage" value={detail.claudeManifest.homepage} />
)}
{detail.claudeManifest?.repository && (
<ManifestRow label="repository" value={detail.claudeManifest.repository} />
)}
{detail.claudeManifest?.license && (
<ManifestRow label="license" value={detail.claudeManifest.license} />
)}
{detail.entry.lastSeenAt && (
<ManifestRow label="last seen" value={detail.entry.lastSeenAt} />
)}
</dl>
</section>
);
}
function ManifestRow({ label, value }: { label: string; value: string }) {
return (
<>
<dt className="font-mono text-[10px] uppercase tracking-[0.10em] text-stone-500">{label}</dt>
<dd className="font-mono text-[11px] text-stone-900">{value}</dd>
</>
);
}
function SkillsSection({ skills }: { skills: NonNullable<ReturnType<typeof usePlugin>["data"]>["skills"] }) {
return (
<section
data-testid="plugin-detail-skills"
className="border border-outline-variant bg-white/25 hard-shadow"
>
<header className="flex items-baseline justify-between border-b border-outline-variant bg-white/30 px-3 py-2">
<SectionHeader tone="default">Skills</SectionHeader>
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
{skills.length} {skills.length === 1 ? "skill" : "skills"}
</span>
</header>
{skills.length === 0 ? (
<div className="px-3 py-4">
<EmptyState
label="NO SKILLS"
description="This plugin does not ship any skills under its skills/ folder."
variant="card"
testId="plugin-skills-empty"
/>
</div>
) : (
<ul className="divide-y divide-outline-variant">
{skills.map((skill) => (
<li
key={skill.name}
data-testid={`plugin-skill-${skill.name}`}
className="flex items-baseline justify-between gap-3 px-3 py-2 font-mono"
>
<span className="truncate text-xs text-stone-900">{skill.name}</span>
<span className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">
{skill.relativePath}
</span>
</li>
))}
</ul>
)}
</section>
);
}
function HooksSection({ hooks }: { hooks: NonNullable<ReturnType<typeof usePlugin>["data"]>["hooks"] }) {
return (
<section
data-testid="plugin-detail-hooks"
className="border border-outline-variant bg-white/25 hard-shadow"
>
<header className="flex items-baseline justify-between border-b border-outline-variant bg-white/30 px-3 py-2">
<SectionHeader tone="default">Hooks</SectionHeader>
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
{hooks.length} {hooks.length === 1 ? "config" : "configs"}
</span>
</header>
{hooks.length === 0 ? (
<div className="px-3 py-4">
<EmptyState
label="NO HOOKS"
description="This plugin does not ship hook configurations."
variant="card"
testId="plugin-hooks-empty"
/>
</div>
) : (
<ul className="divide-y divide-outline-variant">
{hooks.map((hook) => (
<li
key={hook.runtime}
data-testid={`plugin-hook-${hook.runtime}`}
className="px-3 py-2 font-mono text-[11px]"
>
<div className="flex items-baseline justify-between">
<span className="font-bold uppercase text-stone-900">{hook.runtime}</span>
<span className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">
{hook.relativePath}
</span>
</div>
{hook.events.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1 font-mono text-[9px] uppercase tracking-[0.10em] text-stone-600">
{hook.events.map((event) => (
<span key={event} className="border border-outline-variant px-1.5 py-0.5">
{event}
</span>
))}
</div>
)}
</li>
))}
</ul>
)}
</section>
);
}
function UsedBySection({
usedBy,
isLoading,
}: {
usedBy: { agentName: string; sourcePath: string; profiles: string[] }[];
isLoading: boolean;
}) {
return (
<section
data-testid="plugin-detail-used-by"
className="border border-outline-variant bg-white/25 hard-shadow"
>
<header className="flex items-baseline justify-between border-b border-outline-variant bg-white/30 px-3 py-2">
<SectionHeader tone="default">Used by</SectionHeader>
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
{isLoading ? "loading" : `${usedBy.length} ${usedBy.length === 1 ? "agent" : "agents"}`}
</span>
</header>
{usedBy.length === 0 ? (
<div className="px-3 py-4">
<EmptyState
label="NOT USED"
description="No agent.yaml in the spec library currently references this plugin."
variant="card"
testId="plugin-used-by-empty"
/>
</div>
) : (
<ul className="divide-y divide-outline-variant">
{usedBy.map((ref) => (
<li
key={ref.agentName}
data-testid={`plugin-used-by-${ref.agentName}`}
className="flex items-baseline justify-between gap-3 px-3 py-2 font-mono"
>
<span className="truncate text-xs text-stone-900">{ref.agentName}</span>
<span className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">
{ref.profiles.join(", ")}
</span>
</li>
))}
</ul>
)}
</section>
);
}
@@ -8,6 +8,8 @@ import { useAgentImageLibrary, type AgentImageEntry } from "../../hooks/useAgent
import { useLibrarySkills, type LibrarySkillEntry } from "../../hooks/useLibrarySkills.js";
import { librarySkillHref } from "../../lib/library-skills-routing.js";
import { RuntimeBadge, ToolMark } from "../graphics/RuntimeMark.js";
// Phase 3a slice 3.3 — plugins library category.
import { usePlugins, type PluginEntry } from "../../hooks/usePlugins.js";
const TOOLBAR_ACTIONS = [
{ label: "+ Add spec", to: "/specs/rig", testId: "specs-toolbar-add" },
@@ -116,6 +118,80 @@ function LibraryRowContent({ row }: { row: LibraryRow }) {
);
}
// Phase 3a slice 3.3 — Plugins library section.
//
// Mirrors SkillsSection's chrome (border + hard-shadow + header with count),
// renders one row per discovered plugin with name, version, runtime support
// badges (claude/codex), source label provenance. Each row links to the
// plugin detail viewer at /plugins/:id.
function PluginsSection({
plugins,
isLoading,
}: {
plugins: PluginEntry[];
isLoading: boolean;
}) {
return (
<section
id="library-plugins"
data-testid="library-section-plugins"
className="border border-outline-variant bg-white/25 hard-shadow"
>
<header className="flex items-baseline justify-between border-b border-outline-variant bg-white/30 px-3 py-2">
<SectionHeader tone="default">Plugins</SectionHeader>
<span className="shrink-0 font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
{isLoading ? "loading" : `${plugins.length} plugins`}
</span>
</header>
{plugins.length === 0 ? (
<div className="px-3 py-4">
<EmptyState
label={isLoading ? "LOADING" : "NO PLUGINS DISCOVERED"}
description={isLoading
? "Loading plugins..."
: "Install a Claude Code or Codex plugin (or wait for openrig-core to vendor) to see it appear here."}
variant="card"
testId="library-plugins-empty"
/>
</div>
) : (
<ul className="divide-y divide-outline-variant">
{plugins.map((plugin) => (
<li key={plugin.id}>
<Link
to="/plugins/$pluginId"
params={{ pluginId: plugin.id }}
data-testid={`library-plugin-${plugin.id}`}
className="flex items-center justify-between gap-3 px-3 py-2 font-mono hover:bg-stone-100/50"
>
<span className="flex min-w-0 items-center gap-2">
<ToolMark tool="skill" title={`${plugin.name} plugin`} size="xs" decorative />
<span className="truncate text-xs font-bold text-stone-900">{plugin.name}</span>
<span className="shrink-0 text-[9px] uppercase tracking-[0.08em] text-stone-500">
v{plugin.version}
</span>
</span>
<span className="flex shrink-0 items-center gap-2 text-[9px] uppercase tracking-[0.08em] text-stone-500">
{plugin.runtimes.map((rt) => (
<span
key={rt}
data-testid={`plugin-runtime-${rt}`}
className="inline-block border border-outline-variant px-1.5 py-0.5 font-mono"
>
{rt}
</span>
))}
<span title={plugin.path}>{plugin.sourceLabel}</span>
</span>
</Link>
</li>
))}
</ul>
)}
</section>
);
}
function SkillsSection({
skills,
isLoading,
@@ -176,6 +252,8 @@ export function SpecsLibraryPage() {
const { data: contextPacks = [], isLoading: contextPacksLoading } = useContextPackLibrary();
const { data: agentImages = [], isLoading: agentImagesLoading } = useAgentImageLibrary();
const { data: skills = [], isLoading: skillsLoading } = useLibrarySkills();
// Phase 3a slice 3.3 — plugins category.
const { data: plugins = [], isLoading: pluginsLoading } = usePlugins();
const sections = useMemo(() => {
const rigSpecs = specs.filter((entry) => entry.kind === "rig" && !entry.hasServices).map(specRow);
@@ -281,7 +359,12 @@ export function SpecsLibraryPage() {
/>
</div>
<div className="mt-4">
<div className="mt-4 space-y-4">
{/* Phase 3a slice 3.3 — Plugins category sits between the spec
grid and the Skills folder roundup; both are wide single-
column sections rather than grid columns because their row
count varies more dramatically than the spec categories. */}
<PluginsSection plugins={plugins} isLoading={pluginsLoading} />
<SkillsSection skills={skills} isLoading={skillsLoading} />
</div>
</div>
+118
View File
@@ -0,0 +1,118 @@
// Phase 3a slice 3.3 — UI client for the plugin discovery API.
//
// Wraps GET /api/plugins, GET /api/plugins/:id, GET /api/plugins/:id/used-by
// in @tanstack/react-query hooks for consumption by Library Explorer
// (plugins category) + PluginDetailPage + AgentSpec plugin sections.
//
// Types mirror the daemon's PluginEntry / PluginDetail / AgentReference
// shapes from packages/daemon/src/domain/plugin-discovery-service.ts.
// Kept in lockstep at v0; if the daemon shapes evolve, update both sides.
import { useQuery } from "@tanstack/react-query";
export type PluginRuntime = "claude" | "codex";
export type PluginSourceKind = "vendored" | "claude-cache" | "codex-cache";
export interface PluginEntry {
id: string;
name: string;
version: string;
description: string | null;
source: PluginSourceKind;
sourceLabel: string;
runtimes: PluginRuntime[];
path: string;
lastSeenAt: string | null;
}
export interface PluginManifestSummary {
raw: Record<string, unknown>;
name: string | null;
version: string | null;
description: string | null;
homepage: string | null;
repository: string | null;
license: string | null;
}
export interface PluginSkillSummary {
name: string;
relativePath: string;
}
export interface PluginHookSummary {
runtime: PluginRuntime;
relativePath: string;
events: string[];
}
export interface PluginDetail {
entry: PluginEntry;
claudeManifest: PluginManifestSummary | null;
codexManifest: PluginManifestSummary | null;
skills: PluginSkillSummary[];
hooks: PluginHookSummary[];
}
export interface PluginAgentReference {
agentName: string;
sourcePath: string;
profiles: string[];
}
export interface UsePluginsOpts {
runtime?: PluginRuntime;
source?: PluginSourceKind;
}
function buildListUrl(opts: UsePluginsOpts | undefined): string {
const params = new URLSearchParams();
if (opts?.runtime) params.append("runtime", opts.runtime);
if (opts?.source) params.append("source", opts.source);
const qs = params.toString();
return qs.length === 0 ? "/api/plugins" : `/api/plugins?${qs}`;
}
async function fetchPlugins(opts: UsePluginsOpts | undefined): Promise<PluginEntry[]> {
const res = await fetch(buildListUrl(opts));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<PluginEntry[]>;
}
async function fetchPlugin(id: string): Promise<PluginDetail> {
const res = await fetch(`/api/plugins/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<PluginDetail>;
}
async function fetchPluginUsedBy(id: string): Promise<PluginAgentReference[]> {
const res = await fetch(`/api/plugins/${encodeURIComponent(id)}/used-by`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<PluginAgentReference[]>;
}
export function usePlugins(opts: UsePluginsOpts = {}) {
return useQuery<PluginEntry[]>({
queryKey: ["plugins", "list", opts.runtime ?? "all", opts.source ?? "all"],
queryFn: () => fetchPlugins(opts),
staleTime: 30_000,
});
}
export function usePlugin(id: string | null) {
return useQuery<PluginDetail>({
queryKey: ["plugins", "detail", id],
queryFn: () => fetchPlugin(id!),
enabled: id !== null,
staleTime: 30_000,
});
}
export function usePluginUsedBy(id: string | null) {
return useQuery<PluginAgentReference[]>({
queryKey: ["plugins", "used-by", id],
queryFn: () => fetchPluginUsedBy(id!),
enabled: id !== null,
staleTime: 30_000,
});
}
+15
View File
@@ -41,6 +41,8 @@ import { Dashboard } from "./components/dashboard/Dashboard.js";
import { Feed } from "./components/for-you/Feed.js";
import { SpecsLibraryPage } from "./components/specs/SpecsLibraryPage.js";
import { SkillDetailPage } from "./components/specs/SkillDetailPage.js";
// Phase 3a slice 3.3 — plugin detail page route.
import { PluginDetailPage } from "./components/specs/PluginDetailPage.js";
import { SettingsCenter } from "./components/system/SettingsCenter.js";
import {
HostScopePage,
@@ -158,6 +160,18 @@ const specsSkillFileRoute = createRoute({
},
});
// Phase 3a slice 3.3 — Plugin detail page mounted at /plugins/:pluginId.
// Library Explorer's Plugins section links here; AgentSpec Plugins block
// (Batch 1) navigates here too via the "view in library" affordance.
const pluginDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/plugins/$pluginId",
component: () => {
const { pluginId } = useParams({ from: "/plugins/$pluginId" });
return <PluginDetailPage pluginId={pluginId} />;
},
});
// Generic spec detail by kind/name — Phase 4+ wires direct mounts of
// existing detail pages (RigSpecReview / AgentSpecReview etc) by kind.
// V1 placeholder: redirect to /specs/library/$specName when kind matches.
@@ -384,6 +398,7 @@ const routeTree = rootRoute.addChildren([
specsApplicationsRoute,
specsSkillRoute,
specsSkillFileRoute,
pluginDetailRoute,
specsKindRoute,
settingsRoute,
searchRoute,
@@ -0,0 +1,155 @@
// Phase 3a slice 3.3 — AgentPluginsList enrichment component tests.
//
// Standalone helper that takes a list of plugin IDs (from agent.yaml's
// resources.plugins[].id field) and renders enriched chips with:
// - plugin name + version
// - runtime support badges (claude / codex)
// - source label provenance
// - view-in-library link to /plugins/:pluginId
//
// At slice 3.3 close this component is standalone (not wired into
// AgentSpecDisplay because batch 1 owns that file on the
// plugin-primitive-v0 branch). At merge-time into plugin-primitive-v0,
// the AgentSpecDisplay Plugins block (added by batch 1) will consume
// this component to upgrade from string-list-of-ids to enriched chips.
// Until then the component is testable and shippable in isolation.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { AgentPluginsList } from "../src/components/specs/AgentPluginsList.js";
import { createTestRouter } from "./helpers/test-router.js";
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function renderList(pluginIds: string[]) {
return render(
createTestRouter({
path: "/",
component: () => <AgentPluginsList pluginIds={pluginIds} />,
}),
);
}
describe("AgentPluginsList", () => {
it("renders empty state when agent has no plugin references", async () => {
renderList([]);
expect(await screen.findByTestId("agent-plugins-empty")).toBeDefined();
});
it("renders one chip per plugin id with name + version + runtime badges", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/plugins") {
return {
ok: true,
json: async () => [
{
id: "openrig-core",
name: "openrig-core",
version: "0.1.0",
description: null,
source: "vendored",
sourceLabel: "vendored:openrig-core",
runtimes: ["claude", "codex"],
path: "/x",
lastSeenAt: null,
},
{
id: "superpowers",
name: "superpowers",
version: "5.1.0",
description: null,
source: "claude-cache",
sourceLabel: "claude-cache:obra/superpowers/5.1.0",
runtimes: ["claude"],
path: "/y",
lastSeenAt: null,
},
],
};
}
throw new Error(`unexpected ${url}`);
});
renderList(["openrig-core", "superpowers"]);
// Wait for the resolved chips (they have version + source label that
// unresolved chips lack). The testids alone aren't enough to discriminate
// because the unresolved fallback uses the same testid.
await waitFor(() => {
expect(screen.getByText("vendored:openrig-core")).toBeDefined();
expect(screen.getByText("claude-cache:obra/superpowers/5.1.0")).toBeDefined();
});
expect(screen.getByTestId("agent-plugin-chip-openrig-core")).toBeDefined();
expect(screen.getByTestId("agent-plugin-chip-superpowers")).toBeDefined();
// Plugin names visible.
expect(screen.getByText("openrig-core")).toBeDefined();
expect(screen.getByText("superpowers")).toBeDefined();
// Versions visible (regex match across potential text-node splits).
expect(screen.getByText(/v0\.1\.0/)).toBeDefined();
expect(screen.getByText(/v5\.1\.0/)).toBeDefined();
// Runtime support badges (drift discriminator: 2-runtime + 1-runtime).
const claudeBadges = screen.getAllByText("claude");
expect(claudeBadges.length).toBeGreaterThanOrEqual(2);
const codexBadges = screen.getAllByText("codex");
expect(codexBadges.length).toBeGreaterThanOrEqual(1);
// Source labels visible.
expect(screen.getByText("vendored:openrig-core")).toBeDefined();
expect(screen.getByText("claude-cache:obra/superpowers/5.1.0")).toBeDefined();
});
it("renders unresolved chip when plugin id not found in discovery", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/plugins") {
return { ok: true, json: async () => [] };
}
throw new Error(`unexpected ${url}`);
});
renderList(["missing-plugin"]);
await waitFor(() => {
expect(screen.getByTestId("agent-plugin-chip-missing-plugin")).toBeDefined();
});
expect(screen.getByText("missing-plugin")).toBeDefined();
expect(screen.getByTestId("agent-plugin-unresolved-missing-plugin")).toBeDefined();
});
it("each chip links to /plugins/:pluginId for viewer navigation", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/plugins") {
return {
ok: true,
json: async () => [
{
id: "openrig-core",
name: "openrig-core",
version: "0.1.0",
description: null,
source: "vendored",
sourceLabel: "vendored:openrig-core",
runtimes: ["claude"],
path: "/x",
lastSeenAt: null,
},
],
};
}
throw new Error(`unexpected ${url}`);
});
renderList(["openrig-core"]);
// Wait for resolved chip (source label is resolved-only).
await waitFor(() => {
expect(screen.getByText("vendored:openrig-core")).toBeDefined();
});
const chip = screen.getByTestId("agent-plugin-chip-openrig-core") as HTMLAnchorElement;
expect(chip.tagName).toBe("A");
expect(chip.getAttribute("href")).toBe("/plugins/openrig-core");
});
});
+69
View File
@@ -89,6 +89,9 @@ describe("Library page taxonomy", () => {
],
};
}
if (url === "/api/plugins") {
return { ok: true, json: async () => [] };
}
if (url === "/api/files/roots") {
return {
ok: true,
@@ -145,6 +148,8 @@ describe("Library page taxonomy", () => {
expect(screen.getByTestId("library-section-agent-images")).toBeDefined();
expect(screen.getByTestId("library-section-applications")).toBeDefined();
expect(screen.getByTestId("library-section-skills")).toBeDefined();
// Phase 3a slice 3.3 — Plugins category alongside Skills.
expect(screen.getByTestId("library-section-plugins")).toBeDefined();
});
expect(screen.getByText("implementer")).toBeDefined();
@@ -165,6 +170,9 @@ describe("Library page taxonomy", () => {
if (url === "/api/specs/library" || url === "/api/context-packs/library" || url === "/api/agent-images/library") {
return { ok: true, json: async () => [] };
}
if (url === "/api/plugins") {
return { ok: true, json: async () => [] };
}
if (url === "/api/files/roots") {
return {
ok: true,
@@ -259,11 +267,72 @@ describe("Library page taxonomy", () => {
expect(await screen.findByText(/Secondary file/)).toBeDefined();
});
it("renders Plugins section with discovered plugins (Phase 3a slice 3.3)", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/specs/library" || url === "/api/context-packs/library" || url === "/api/agent-images/library") {
return { ok: true, json: async () => [] };
}
if (url === "/api/plugins") {
return {
ok: true,
json: async () => [
{
id: "openrig-core",
name: "openrig-core",
version: "0.1.0",
description: "Canonical OpenRig content",
source: "vendored",
sourceLabel: "vendored:openrig-core",
runtimes: ["claude", "codex"],
path: "/x/openrig-core",
lastSeenAt: null,
},
{
id: "claude-cache:anthropics/github/1.0.0",
name: "github",
version: "1.0.0",
description: "GitHub integration",
source: "claude-cache",
sourceLabel: "claude-cache:anthropics/github/1.0.0",
runtimes: ["claude"],
path: "/y/github",
lastSeenAt: null,
},
],
};
}
if (url === "/api/files/roots") {
return { ok: true, json: async () => ({ roots: [{ name: "workspace", path: "/workspace" }] }) };
}
// Skill scans return empty so the Skills section doesn't add noise here.
if (url.startsWith("/api/files/list")) {
return { status: 404, ok: false, json: async () => ({ error: "not_found" }) };
}
throw new Error(`unexpected fetch ${url}`);
});
renderLibraryPage();
// Section renders with both plugins listed; source labels distinguish layers.
await waitFor(() => {
expect(screen.getByTestId("library-section-plugins")).toBeDefined();
});
expect(await screen.findByTestId("library-plugin-openrig-core")).toBeDefined();
expect(await screen.findByTestId("library-plugin-claude-cache:anthropics/github/1.0.0")).toBeDefined();
// Source labels visible (distinct per source kind — drift discriminator).
expect(screen.getByText("vendored:openrig-core")).toBeDefined();
expect(screen.getByText("claude-cache:anthropics/github/1.0.0")).toBeDefined();
});
it("auto-expands the skills explorer on skill detail routes", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/specs/library" || url === "/api/context-packs/library" || url === "/api/agent-images/library") {
return { ok: true, json: async () => [] };
}
if (url === "/api/plugins") {
return { ok: true, json: async () => [] };
}
if (url === "/api/files/roots") {
return {
ok: true,
@@ -0,0 +1,165 @@
// Phase 3a slice 3.3 — PluginDetailPage tests (TDD red→green).
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { PluginDetailPage } from "../src/components/specs/PluginDetailPage.js";
import { createTestRouter } from "./helpers/test-router.js";
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function renderPluginDetail(pluginId: string) {
return render(
createTestRouter({
path: "/",
component: () => <PluginDetailPage pluginId={pluginId} />,
}),
);
}
describe("PluginDetailPage", () => {
it("shows loading state while plugin detail is fetching", async () => {
mockFetch.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return { ok: true, json: async () => null };
});
renderPluginDetail("openrig-core");
expect(await screen.findByTestId("plugin-detail-loading")).toBeDefined();
});
it("shows not-found state when plugin id missing", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/api/plugins/missing")) {
return { ok: false, status: 404, json: async () => ({}) };
}
throw new Error(`unexpected ${url}`);
});
renderPluginDetail("missing");
expect(await screen.findByTestId("plugin-detail-not-found")).toBeDefined();
});
it("renders manifest summary, skills, hooks, runtime badges, and source label", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/plugins/openrig-core") {
return {
ok: true,
json: async () => ({
entry: {
id: "openrig-core",
name: "openrig-core",
version: "0.1.0",
description: "Canonical OpenRig content",
source: "vendored",
sourceLabel: "vendored:openrig-core",
runtimes: ["claude", "codex"],
path: "/x/openrig-core",
lastSeenAt: "2026-05-10T00:00:00.000Z",
},
claudeManifest: {
raw: { name: "openrig-core" },
name: "openrig-core",
version: "0.1.0",
description: "Canonical content",
homepage: "https://github.com/mvschwarz/openrig-plugins",
repository: null,
license: "MIT",
},
codexManifest: null,
skills: [
{ name: "openrig-user", relativePath: "skills/openrig-user" },
{ name: "queue-handoff", relativePath: "skills/queue-handoff" },
],
hooks: [
{ runtime: "claude", relativePath: "hooks/claude.json", events: ["SessionStart", "Stop"] },
],
}),
};
}
if (url === "/api/plugins/openrig-core/used-by") {
return {
ok: true,
json: async () => [
{ agentName: "advisor-lead", sourcePath: "/x/agent.yaml", profiles: ["default"] },
{ agentName: "velocity-driver", sourcePath: "/y/agent.yaml", profiles: ["default", "review"] },
],
};
}
throw new Error(`unexpected ${url}`);
});
renderPluginDetail("openrig-core");
expect(await screen.findByTestId("plugin-detail-page")).toBeDefined();
// Manifest summary visible
expect(screen.getByText("openrig-core")).toBeDefined();
expect(screen.getByText("v0.1.0")).toBeDefined();
expect(screen.getByText("Canonical OpenRig content")).toBeDefined();
// Runtime badges (drift discriminator: each runtime renders own badge).
expect(screen.getByTestId("plugin-detail-runtime-claude")).toBeDefined();
expect(screen.getByTestId("plugin-detail-runtime-codex")).toBeDefined();
// Source label visible.
expect(screen.getByText("vendored:openrig-core")).toBeDefined();
// Skills list — both skills shown.
await waitFor(() => {
expect(screen.getByTestId("plugin-skill-openrig-user")).toBeDefined();
expect(screen.getByTestId("plugin-skill-queue-handoff")).toBeDefined();
});
// Hooks list — claude hook visible with events.
expect(screen.getByTestId("plugin-hook-claude")).toBeDefined();
expect(screen.getByText(/SessionStart/)).toBeDefined();
expect(screen.getByText(/Stop/)).toBeDefined();
// Used-by list — both agents visible.
await waitFor(() => {
expect(screen.getByTestId("plugin-used-by-advisor-lead")).toBeDefined();
expect(screen.getByTestId("plugin-used-by-velocity-driver")).toBeDefined();
});
});
it("renders empty Skills section when plugin ships no skills", async () => {
mockFetch.mockImplementation(async (url: string) => {
if (url === "/api/plugins/skinny") {
return {
ok: true,
json: async () => ({
entry: {
id: "skinny",
name: "skinny",
version: "0.0.1",
description: null,
source: "claude-cache",
sourceLabel: "claude-cache:other/skinny/0.0.1",
runtimes: ["claude"],
path: "/x/skinny",
lastSeenAt: null,
},
claudeManifest: { raw: {}, name: "skinny", version: "0.0.1", description: null, homepage: null, repository: null, license: null },
codexManifest: null,
skills: [],
hooks: [],
}),
};
}
if (url === "/api/plugins/skinny/used-by") {
return { ok: true, json: async () => [] };
}
throw new Error(`unexpected ${url}`);
});
renderPluginDetail("skinny");
expect(await screen.findByTestId("plugin-detail-page")).toBeDefined();
expect(screen.getByTestId("plugin-skills-empty")).toBeDefined();
expect(screen.getByTestId("plugin-hooks-empty")).toBeDefined();
expect(screen.getByTestId("plugin-used-by-empty")).toBeDefined();
});
});
+161
View File
@@ -0,0 +1,161 @@
// Phase 3a slice 3.3 — usePlugins / usePlugin / usePluginUsedBy tests
// (TDD red→green).
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { usePlugins, usePlugin, usePluginUsedBy } from "../src/hooks/usePlugins.js";
import type { PluginEntry, PluginDetail, PluginAgentReference } from "../src/hooks/usePlugins.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
function createTestQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
}
let qc: QueryClient;
beforeEach(() => {
mockFetch.mockReset();
qc = createTestQueryClient();
});
afterEach(() => { cleanup(); });
function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
function ListHarness({ runtime }: { runtime?: "claude" | "codex" }) {
const { data, isPending, error } = usePlugins({ runtime });
if (isPending) return <div data-testid="state">pending</div>;
if (error) return <div data-testid="state">error: {error.message}</div>;
return <div data-testid="state">plugins: {data?.length ?? 0}</div>;
}
function DetailHarness({ id }: { id: string | null }) {
const { data, isPending, error } = usePlugin(id);
if (id === null) return <div data-testid="state">null</div>;
if (isPending) return <div data-testid="state">pending</div>;
if (error) return <div data-testid="state">error: {error.message}</div>;
return <div data-testid="state">name: {data?.entry.name ?? "n/a"}</div>;
}
function UsedByHarness({ id }: { id: string }) {
const { data, isPending, error } = usePluginUsedBy(id);
if (isPending) return <div data-testid="state">pending</div>;
if (error) return <div data-testid="state">error: {error.message}</div>;
return <div data-testid="state">used-by: {data?.length ?? 0}</div>;
}
describe("usePlugins (list)", () => {
it("fetches /api/plugins and exposes the list", async () => {
const sample: PluginEntry[] = [
{
id: "openrig-core",
name: "openrig-core",
version: "0.1.0",
description: "vendored",
source: "vendored",
sourceLabel: "vendored:openrig-core",
runtimes: ["claude", "codex"],
path: "/x/openrig-core",
lastSeenAt: null,
},
];
mockFetch.mockResolvedValue(new Response(JSON.stringify(sample), { status: 200 }));
render(<Wrapper><ListHarness /></Wrapper>);
await waitFor(() => {
expect(screen.getByTestId("state").textContent).toBe("plugins: 1");
});
const callUrl = mockFetch.mock.calls[0]?.[0] as string;
expect(callUrl).toBe("/api/plugins");
});
it("appends ?runtime=claude when runtime filter provided", async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify([]), { status: 200 }));
render(<Wrapper><ListHarness runtime="claude" /></Wrapper>);
await waitFor(() => {
expect(screen.getByTestId("state").textContent).toBe("plugins: 0");
});
const callUrl = mockFetch.mock.calls[0]?.[0] as string;
expect(callUrl).toBe("/api/plugins?runtime=claude");
});
it("surfaces HTTP errors", async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ error: "x" }), { status: 503 }));
render(<Wrapper><ListHarness /></Wrapper>);
await waitFor(() => {
expect(screen.getByTestId("state").textContent).toContain("error: HTTP 503");
});
});
});
describe("usePlugin (detail)", () => {
it("returns null state when id is null (no fetch)", () => {
render(<Wrapper><DetailHarness id={null} /></Wrapper>);
expect(screen.getByTestId("state").textContent).toBe("null");
expect(mockFetch).not.toHaveBeenCalled();
});
it("fetches /api/plugins/:id and exposes the detail", async () => {
const sample: PluginDetail = {
entry: {
id: "openrig-core",
name: "openrig-core",
version: "0.1.0",
description: "vendored",
source: "vendored",
sourceLabel: "vendored:openrig-core",
runtimes: ["claude", "codex"],
path: "/x",
lastSeenAt: null,
},
claudeManifest: {
raw: { name: "openrig-core" },
name: "openrig-core",
version: "0.1.0",
description: null,
homepage: null,
repository: null,
license: null,
},
codexManifest: null,
skills: [{ name: "openrig-user", relativePath: "skills/openrig-user" }],
hooks: [{ runtime: "claude", relativePath: "hooks/claude.json", events: ["SessionStart"] }],
};
mockFetch.mockResolvedValue(new Response(JSON.stringify(sample), { status: 200 }));
render(<Wrapper><DetailHarness id="openrig-core" /></Wrapper>);
await waitFor(() => {
expect(screen.getByTestId("state").textContent).toBe("name: openrig-core");
});
const callUrl = mockFetch.mock.calls[0]?.[0] as string;
expect(callUrl).toBe("/api/plugins/openrig-core");
});
it("surfaces 404 as error", async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 404 }));
render(<Wrapper><DetailHarness id="missing" /></Wrapper>);
await waitFor(() => {
expect(screen.getByTestId("state").textContent).toContain("error: HTTP 404");
});
});
});
describe("usePluginUsedBy", () => {
it("fetches /api/plugins/:id/used-by", async () => {
const sample: PluginAgentReference[] = [
{ agentName: "advisor-lead", sourcePath: "/x/agent.yaml", profiles: ["default"] },
];
mockFetch.mockResolvedValue(new Response(JSON.stringify(sample), { status: 200 }));
render(<Wrapper><UsedByHarness id="openrig-core" /></Wrapper>);
await waitFor(() => {
expect(screen.getByTestId("state").textContent).toBe("used-by: 1");
});
const callUrl = mockFetch.mock.calls[0]?.[0] as string;
expect(callUrl).toBe("/api/plugins/openrig-core/used-by");
});
});