mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
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 mainc0fcccdunrelated 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 mainc0fcccdper 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:
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user