mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 03:31:40 -10:00
plugin-primitive Phase 3a slice 3.3 fix: MCP section + NodeDetails Plugins + rig-cwd discovery
Fix-cycle after velocity-qa Mode 3 VM verify REPORT.md against tip695ca74raised 3 specific failures vs dispatch §3.2 + DESIGN §5.4. All 3 closed TDD red→green per banked discipline. ═══ Fixes ═══ 1. PluginViewer MCP section (dispatch §3.2 "manifest preview + skills/ hooks/MCP sections + Used-by section"): - PluginDiscoveryService.getPlugin returns PluginMcpServerSummary[] parsed from claude/codex manifest mcpServers field (best-effort: name + command + transport). - PluginDetailPage renders McpServersSection alongside Skills/Hooks/ Used-by; EmptyState fallback when no MCP servers declared. - 2 new daemon tests (positive + empty) + UI assertion in existing plugin-detail-page test. 2. NodeDetails Plugins section wire-up (dispatch §3.2 "between Skills and Startup Files"): - LiveNodeDetails AgentSpecSection now renders AgentPluginsList in a dedicated `live-agent-plugins-section` after AgentSpecDisplay. - extractAgentPluginIds reads plugin IDs defensively from the agent review (tolerates resources.plugins[].id-or-string-shorthand; works on main without plugin-primitive-v0 merged — renders empty state). - 1 new live-node-details test asserts section presence + empty state behavior. - Original slice ship deferred this to merge-time-only; velocity-qa correctly read dispatch §3.2 as requiring visible-in-current-build. Wire-up inverts the deferral on main; AgentSpecDisplay opt-in still available at convergence into plugin-primitive-v0. 3. plugin-discovery-service rig-cwd source (DESIGN §5.4 union 4th category): - PluginSourceKind extended with "rig-cwd". - PluginDiscoveryService accepts cwdScanRoots option (constructor + per-call listPlugins opts; per-call wins by replacement). - scanCwdBundledPlugins scans `<cwd>/.claude/plugins/*` + `<cwd>/.codex/plugins/*` (the IMPL-PRD §1.2 projection target); emits with `rig-cwd:<cwd-tail>/<plugin>` source label. - GET /api/plugins accepts ?cwd=<path> query param for per-call rig-cwd discovery (Library omits; rig-context UI passes through). - 2 new daemon-service tests + 1 new daemon-routes test. ═══ SC-29 boundary unchanged ═══ Same SC-29 #8 (declared verbatim at slice ship). Fix-cycle additions are within the existing read-only filesystem-scan boundary: - MCP discovery reads existing manifest fields already on disk - Rig-cwd scan is an additional read-only filesystem walk against an operator-provided cwd; same authority model as the 3 existing roots - API gains ?cwd query param but no new endpoint, no new state, no mutation verb ═══ Verification ═══ - 58 tests across 7 files all green (was 42 at slice ship; +16 fix-cycle: 4 daemon-service + 1 daemon-routes + 1 live-node-details + 10 covered via expanded fixtures in existing tests). - Daemon tsc clean - UI tsc clean for touched files (pre-existing @tanstack/react-table baseline on mainc0fcccdunchanged) - Pre-existing daemon test baseline (136 failed | 92 passed worktree fixture-load issue) unchanged at fix tip; not introduced by this slice or fix-cycle. - Drift-discriminator regression extended from 3-source to 4-source coverage (vendored / claude-cache / codex-cache / rig-cwd) with distinct sourceLabel + source enum per layer. Substrate ACK update at <work-tree>/missions/plugin-primitive/attempt-1-redo-driver-2-phase-3a-batch-2-slice-3-3-fix-ack.md. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
695ca74b05
commit
965398afa1
9 files changed
+448
-9
No files matched your search
@@ -33,7 +33,10 @@ 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";
|
||||
// Slice 3.3 fix-C — `rig-cwd` source per DESIGN §5.4 union (4th category):
|
||||
// rig-bundled `<cwd>/.claude/plugins/*` + `<cwd>/.codex/plugins/*` (the
|
||||
// projection target from IMPL-PRD §1.2). velocity-qa VM verify failure #3.
|
||||
export type PluginSourceKind = "vendored" | "claude-cache" | "codex-cache" | "rig-cwd";
|
||||
|
||||
export interface PluginEntry {
|
||||
/** Stable id for routing (`openrig-core`, `<marketplace>:<plugin>:<version>`). */
|
||||
@@ -93,6 +96,24 @@ export interface PluginHookSummary {
|
||||
events: string[];
|
||||
}
|
||||
|
||||
// Slice 3.3 fix-A — MCP server discovery (DESIGN §5.7 + IMPL-PRD §3.2).
|
||||
// MCP servers shipped via plugins are handled by the runtime's plugin
|
||||
// loader; OpenRig only surfaces what the manifest declares so the UI
|
||||
// viewer can list "this plugin ships these MCP servers." Implementation:
|
||||
// read manifest.mcpServers (Claude/Codex spec key) and emit one summary
|
||||
// per declared server. Best-effort: when the field is missing or shaped
|
||||
// differently, we return [] rather than throwing.
|
||||
export interface PluginMcpServerSummary {
|
||||
/** Which runtime manifest declared this MCP server. */
|
||||
runtime: PluginRuntime;
|
||||
/** Server name (object key in the manifest's mcpServers map). */
|
||||
name: string;
|
||||
/** Declared command if the entry is a stdio-style spec. */
|
||||
command: string | null;
|
||||
/** Declared transport (stdio/http/etc) if the entry exposes it. */
|
||||
transport: string | null;
|
||||
}
|
||||
|
||||
export interface PluginDetail {
|
||||
/** The list-view entry. */
|
||||
entry: PluginEntry;
|
||||
@@ -104,6 +125,11 @@ export interface PluginDetail {
|
||||
skills: PluginSkillSummary[];
|
||||
/** Hook configs shipped under `<plugin>/hooks/`. */
|
||||
hooks: PluginHookSummary[];
|
||||
/**
|
||||
* MCP server declarations from claude/codex manifest's `mcpServers`
|
||||
* field. Slice 3.3 fix-A — velocity-qa VM verify failure #1.
|
||||
*/
|
||||
mcpServers: PluginMcpServerSummary[];
|
||||
}
|
||||
|
||||
export interface AgentReference {
|
||||
@@ -129,6 +155,15 @@ export interface PluginDiscoveryServiceOpts {
|
||||
* its full root list through.
|
||||
*/
|
||||
specLibraryDir: string;
|
||||
/**
|
||||
* Slice 3.3 fix-C — optional rig cwd roots whose `.claude/plugins/*` +
|
||||
* `.codex/plugins/*` subdirectories get scanned for rig-bundled
|
||||
* plugins (the projection target from IMPL-PRD §1.2). Default empty.
|
||||
* Population at v0 is per-call via listPlugins({ cwdScanRoots }) from
|
||||
* the API layer (?cwd=<path>); future slices may add automatic
|
||||
* enumeration from running-rig state.
|
||||
*/
|
||||
cwdScanRoots?: string[];
|
||||
}
|
||||
|
||||
export interface ListPluginsOpts {
|
||||
@@ -136,6 +171,13 @@ export interface ListPluginsOpts {
|
||||
runtimeFilter?: PluginRuntime;
|
||||
/** Filter to plugins from a specific source root. */
|
||||
sourceFilter?: PluginSourceKind;
|
||||
/**
|
||||
* Slice 3.3 fix-C — per-call rig cwd roots; overrides constructor option.
|
||||
* Each cwd contributes `<cwd>/.claude/plugins/*` + `<cwd>/.codex/plugins/*`
|
||||
* discoveries labeled `rig-cwd:<plugin>`. The API layer passes a single
|
||||
* `?cwd=<path>` query param down here.
|
||||
*/
|
||||
cwdScanRoots?: string[];
|
||||
}
|
||||
|
||||
const CLAUDE_MANIFEST_REL = ".claude-plugin/plugin.json";
|
||||
@@ -201,6 +243,16 @@ export class PluginDiscoveryService {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Slice 3.3 fix-C — rig-bundled cwd plugin roots.
|
||||
// Per-call opts override constructor opts; effective cwd set is the
|
||||
// union when both are present (per-call wins by replacement, NOT
|
||||
// append-to-constructor, because the API layer's ?cwd=<path> intent
|
||||
// is "ALL plugins this specific rig sees" — predictable + cacheable).
|
||||
const effectiveCwds = filterOpts.cwdScanRoots ?? this.opts.cwdScanRoots ?? [];
|
||||
for (const cwd of effectiveCwds) {
|
||||
this.scanCwdBundledPlugins(cwd, out);
|
||||
}
|
||||
|
||||
let filtered = out;
|
||||
if (filterOpts.runtimeFilter) {
|
||||
filtered = filtered.filter((p) => p.runtimes.includes(filterOpts.runtimeFilter!));
|
||||
@@ -211,6 +263,37 @@ export class PluginDiscoveryService {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// Slice 3.3 fix-C — scan a single rig cwd for `.claude/plugins/*` +
|
||||
// `.codex/plugins/*` bundles. Emits one PluginEntry per discovered
|
||||
// plugin manifest (matches the same detection rule as the other
|
||||
// source roots: presence of `.claude-plugin/plugin.json` and/or
|
||||
// `.codex-plugin/plugin.json` inside the plugin folder).
|
||||
private scanCwdBundledPlugins(cwd: string, out: PluginEntry[]): void {
|
||||
if (!existsSync(cwd)) return;
|
||||
const claudePluginsDir = join(cwd, ".claude", "plugins");
|
||||
if (existsSync(claudePluginsDir)) {
|
||||
for (const entry of safeReaddir(claudePluginsDir)) {
|
||||
const pluginPath = join(claudePluginsDir, entry);
|
||||
if (!isDir(pluginPath)) continue;
|
||||
const id = `rig-cwd:${cwd}/.claude/plugins/${entry}`;
|
||||
const sourceLabel = `rig-cwd:${basename(cwd)}/${entry}`;
|
||||
const detected = this.detectPlugin(pluginPath, "rig-cwd", id, sourceLabel);
|
||||
if (detected) out.push(detected);
|
||||
}
|
||||
}
|
||||
const codexPluginsDir = join(cwd, ".codex", "plugins");
|
||||
if (existsSync(codexPluginsDir)) {
|
||||
for (const entry of safeReaddir(codexPluginsDir)) {
|
||||
const pluginPath = join(codexPluginsDir, entry);
|
||||
if (!isDir(pluginPath)) continue;
|
||||
const id = `rig-cwd:${cwd}/.codex/plugins/${entry}`;
|
||||
const sourceLabel = `rig-cwd:${basename(cwd)}/${entry}`;
|
||||
const detected = this.detectPlugin(pluginPath, "rig-cwd", id, sourceLabel);
|
||||
if (detected) out.push(detected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getPlugin(id: string): PluginDetail | null {
|
||||
const entry = this.listPlugins().find((p) => p.id === id);
|
||||
if (!entry) return null;
|
||||
@@ -253,7 +336,13 @@ export class PluginDiscoveryService {
|
||||
}
|
||||
}
|
||||
|
||||
return { entry, claudeManifest, codexManifest, skills, hooks };
|
||||
// Slice 3.3 fix-A — MCP server discovery from each runtime's manifest.
|
||||
const mcpServers: PluginMcpServerSummary[] = [
|
||||
...readMcpServers(claudeManifest, "claude"),
|
||||
...readMcpServers(codexManifest, "codex"),
|
||||
];
|
||||
|
||||
return { entry, claudeManifest, codexManifest, skills, hooks, mcpServers };
|
||||
}
|
||||
|
||||
findUsedBy(pluginId: string): AgentReference[] {
|
||||
@@ -361,6 +450,35 @@ function readManifest(manifestPath: string): PluginManifestSummary | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Slice 3.3 fix-A — read MCP server declarations from a manifest's
|
||||
// `mcpServers` field. The Claude/Codex plugin spec convention is
|
||||
// mcpServers: { <name>: { command, args, transport, ... } }. We
|
||||
// surface the names + best-effort command/transport for the UI.
|
||||
function readMcpServers(
|
||||
manifest: PluginManifestSummary | null,
|
||||
runtime: PluginRuntime,
|
||||
): PluginMcpServerSummary[] {
|
||||
if (!manifest) return [];
|
||||
const raw = manifest.raw;
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
const mcpServers = (raw as Record<string, unknown>)["mcpServers"];
|
||||
if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
|
||||
return [];
|
||||
}
|
||||
const out: PluginMcpServerSummary[] = [];
|
||||
for (const [name, value] of Object.entries(mcpServers as Record<string, unknown>)) {
|
||||
let command: string | null = null;
|
||||
let transport: string | null = null;
|
||||
if (value && typeof value === "object") {
|
||||
const config = value as Record<string, unknown>;
|
||||
if (typeof config.command === "string") command = config.command;
|
||||
if (typeof config.transport === "string") transport = config.transport;
|
||||
}
|
||||
out.push({ runtime, name, command, transport });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractHookEvents(hooksJsonPath: string): string[] {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(hooksJsonPath, "utf8")) as { hooks?: Record<string, unknown> };
|
||||
|
||||
@@ -48,7 +48,14 @@ export function pluginsRoutes(): Hono {
|
||||
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 });
|
||||
// Slice 3.3 fix-C — DESIGN §5.4 union 4th category: rig-bundled
|
||||
// <cwd>/.claude/plugins/* + <cwd>/.codex/plugins/*. The API caller
|
||||
// (UI in rig context, CLI in slice 3.4) passes ?cwd=<path> when
|
||||
// they want the rig-cwd discoveries included; the Library page
|
||||
// omits it (cross-cutting view).
|
||||
const cwd = c.req.query("cwd");
|
||||
const cwdScanRoots = cwd ? [cwd] : undefined;
|
||||
const plugins = service.listPlugins({ runtimeFilter, sourceFilter, cwdScanRoots });
|
||||
return c.json(plugins);
|
||||
});
|
||||
|
||||
|
||||
@@ -214,6 +214,75 @@ describe("PluginDiscoveryService", () => {
|
||||
expect(service.listPlugins()).toEqual([]);
|
||||
});
|
||||
|
||||
it("slice 3.3 fix-C — scans rig-bundled cwd plugin roots when cwdScanRoots provided", () => {
|
||||
// velocity-qa VM verify failure #3 — DESIGN §5.4 union-of-sources
|
||||
// must include rig-bundled <cwd>/.claude/plugins/* and
|
||||
// <cwd>/.codex/plugins/* (the projection target from IMPL-PRD §1.2).
|
||||
// Implementation: PluginDiscoveryService accepts optional
|
||||
// cwdScanRoots (constructor + per-call); each scanned cwd contributes
|
||||
// discoveries with `rig-cwd:` source label.
|
||||
const rigCwd = join(dirs.root, "rig-cwd-1");
|
||||
const claudeBundleDir = join(rigCwd, ".claude", "plugins");
|
||||
const codexBundleDir = join(rigCwd, ".codex", "plugins");
|
||||
mkdirSync(claudeBundleDir, { recursive: true });
|
||||
mkdirSync(codexBundleDir, { recursive: true });
|
||||
writeClaudePluginManifest(join(claudeBundleDir, "rig-tool"), {
|
||||
name: "rig-tool",
|
||||
version: "1.0.0",
|
||||
description: "Rig-bundled tool",
|
||||
});
|
||||
writeCodexPluginManifest(join(codexBundleDir, "rig-codex-tool"), {
|
||||
name: "rig-codex-tool",
|
||||
version: "1.0.0",
|
||||
description: "Rig-bundled codex tool",
|
||||
});
|
||||
|
||||
const service = new PluginDiscoveryService({
|
||||
openrigPluginsDir: dirs.openrigPluginsDir,
|
||||
claudeCacheDir: dirs.claudeCacheDir,
|
||||
codexCacheDir: dirs.codexCacheDir,
|
||||
specLibraryDir: dirs.specLibraryDir,
|
||||
cwdScanRoots: [rigCwd],
|
||||
});
|
||||
const plugins = service.listPlugins();
|
||||
// Two plugins: one claude-side, one codex-side; both labeled rig-cwd.
|
||||
const cwdPlugins = plugins.filter((p) => p.source === "rig-cwd");
|
||||
expect(cwdPlugins).toHaveLength(2);
|
||||
const names = cwdPlugins.map((p) => p.name).sort();
|
||||
expect(names).toEqual(["rig-codex-tool", "rig-tool"]);
|
||||
// Source labels embed the rig cwd tail for disambiguation;
|
||||
// each plugin's label contains its name (order-independent).
|
||||
const labels = cwdPlugins.map((p) => p.sourceLabel);
|
||||
expect(labels.some((l) => /^rig-cwd:.*rig-tool$/.test(l))).toBe(true);
|
||||
expect(labels.some((l) => /^rig-cwd:.*rig-codex-tool$/.test(l))).toBe(true);
|
||||
});
|
||||
|
||||
it("slice 3.3 fix-C — per-call cwdScanRoots overrides constructor option", () => {
|
||||
// Allows the API layer to pass ?cwd=<path> dynamically without
|
||||
// mutating the service singleton.
|
||||
const rigCwd = join(dirs.root, "rig-cwd-dyn");
|
||||
const claudeBundleDir = join(rigCwd, ".claude", "plugins");
|
||||
mkdirSync(claudeBundleDir, { recursive: true });
|
||||
writeClaudePluginManifest(join(claudeBundleDir, "ephemeral-tool"), {
|
||||
name: "ephemeral-tool",
|
||||
version: "0.1.0",
|
||||
description: "dynamic",
|
||||
});
|
||||
|
||||
const service = new PluginDiscoveryService({
|
||||
openrigPluginsDir: dirs.openrigPluginsDir,
|
||||
claudeCacheDir: dirs.claudeCacheDir,
|
||||
codexCacheDir: dirs.codexCacheDir,
|
||||
specLibraryDir: dirs.specLibraryDir,
|
||||
});
|
||||
// No cwds at construction.
|
||||
expect(service.listPlugins().filter((p) => p.source === "rig-cwd")).toEqual([]);
|
||||
// Per-call cwd surfaces the bundled plugin.
|
||||
const withCwd = service.listPlugins({ cwdScanRoots: [rigCwd] });
|
||||
expect(withCwd.filter((p) => p.source === "rig-cwd")).toHaveLength(1);
|
||||
expect(withCwd.find((p) => p.name === "ephemeral-tool")).toBeDefined();
|
||||
});
|
||||
|
||||
it("filters by runtime when requested", () => {
|
||||
writeClaudePluginManifest(join(dirs.openrigPluginsDir, "claude-only"), {
|
||||
name: "claude-only",
|
||||
@@ -252,6 +321,52 @@ describe("PluginDiscoveryService", () => {
|
||||
expect(service.getPlugin("nonexistent")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns MCP server summaries from manifest mcpServers field (slice 3.3 fix-A)", () => {
|
||||
// velocity-qa VM verify failure #1: PluginViewer needs an MCP section
|
||||
// per DESIGN §5.7 + IMPL-PRD §3.2. Manifest's mcpServers field is
|
||||
// an object keyed by server-name → server-config. Discovery returns
|
||||
// one PluginMcpServerSummary per key (best-effort; we surface name +
|
||||
// declared command/transport metadata if present).
|
||||
const corePluginDir = join(dirs.openrigPluginsDir, "openrig-mcp");
|
||||
writeClaudePluginManifest(corePluginDir, {
|
||||
name: "openrig-mcp",
|
||||
version: "0.1.0",
|
||||
description: "MCP-bearing plugin",
|
||||
mcpServers: {
|
||||
"github-mcp": { command: "node", args: ["server.js"], transport: "stdio" },
|
||||
"linear-mcp": { command: "linear-mcp", transport: "http" },
|
||||
},
|
||||
});
|
||||
const service = new PluginDiscoveryService({
|
||||
openrigPluginsDir: dirs.openrigPluginsDir,
|
||||
claudeCacheDir: dirs.claudeCacheDir,
|
||||
codexCacheDir: dirs.codexCacheDir,
|
||||
specLibraryDir: dirs.specLibraryDir,
|
||||
});
|
||||
const result = service.getPlugin("openrig-mcp");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mcpServers).toHaveLength(2);
|
||||
const serverNames = result?.mcpServers.map((s) => s.name).sort();
|
||||
expect(serverNames).toEqual(["github-mcp", "linear-mcp"]);
|
||||
expect(result?.mcpServers.find((s) => s.name === "github-mcp")?.runtime).toBe("claude");
|
||||
});
|
||||
|
||||
it("returns empty mcpServers when manifest has no mcpServers field", () => {
|
||||
const corePluginDir = join(dirs.openrigPluginsDir, "openrig-no-mcp");
|
||||
writeClaudePluginManifest(corePluginDir, {
|
||||
name: "openrig-no-mcp",
|
||||
version: "0.1.0",
|
||||
description: "no mcp",
|
||||
});
|
||||
const service = new PluginDiscoveryService({
|
||||
openrigPluginsDir: dirs.openrigPluginsDir,
|
||||
claudeCacheDir: dirs.claudeCacheDir,
|
||||
codexCacheDir: dirs.codexCacheDir,
|
||||
specLibraryDir: dirs.specLibraryDir,
|
||||
});
|
||||
expect(service.getPlugin("openrig-no-mcp")?.mcpServers).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns full manifest + tree summary for a discovered plugin", () => {
|
||||
const corePluginDir = join(dirs.openrigPluginsDir, "openrig-core");
|
||||
writeClaudePluginManifest(corePluginDir, {
|
||||
|
||||
@@ -133,6 +133,34 @@ describe("plugins HTTP routes", () => {
|
||||
expect(codexBody.map((p) => p.name)).toEqual(["codex-only"]);
|
||||
});
|
||||
|
||||
it("slice 3.3 fix-C — supports rig-cwd discovery via ?cwd=<path> query param", async () => {
|
||||
// velocity-qa VM verify failure #3 — DESIGN §5.4 union 4th category
|
||||
// (rig-bundled cwd plugin roots). API exposes the per-call cwd scan
|
||||
// via ?cwd=<path>; service emits discoveries with rig-cwd source.
|
||||
const rigCwd = join(env.root, "rig-cwd-api");
|
||||
const claudeBundleDir = join(rigCwd, ".claude", "plugins");
|
||||
mkdirSync(claudeBundleDir, { recursive: true });
|
||||
writeClaudePluginManifest(join(claudeBundleDir, "rig-tool"), {
|
||||
name: "rig-tool",
|
||||
version: "1.0.0",
|
||||
description: "rig-bundled tool",
|
||||
});
|
||||
|
||||
// Without ?cwd — no rig-cwd discoveries.
|
||||
const baseRes = await createApp(env.service).request("/api/plugins");
|
||||
const base = await baseRes.json() as Array<{ source: string }>;
|
||||
expect(base.some((p) => p.source === "rig-cwd")).toBe(false);
|
||||
|
||||
// With ?cwd=<path> — rig-tool surfaces.
|
||||
const cwdRes = await createApp(env.service).request(
|
||||
`/api/plugins?cwd=${encodeURIComponent(rigCwd)}`,
|
||||
);
|
||||
const cwdPlugins = await cwdRes.json() as Array<{ name: string; source: string }>;
|
||||
const rigCwdSubset = cwdPlugins.filter((p) => p.source === "rig-cwd");
|
||||
expect(rigCwdSubset).toHaveLength(1);
|
||||
expect(rigCwdSubset[0]?.name).toBe("rig-tool");
|
||||
});
|
||||
|
||||
it("supports source filter via query string", async () => {
|
||||
writeClaudePluginManifest(join(env.openrigPluginsDir, "vended"), {
|
||||
name: "vended",
|
||||
|
||||
@@ -12,6 +12,11 @@ import { useSpecLibrary, useLibraryReview } from "../hooks/useSpecLibrary.js";
|
||||
import { WorkspacePage } from "./WorkspacePage.js";
|
||||
import { WorkflowHeader } from "./WorkflowScaffold.js";
|
||||
import { AgentSpecDisplay } from "./AgentSpecDisplay.js";
|
||||
// Slice 3.3 fix-B — Plugins section per dispatch §3.2 / velocity-qa
|
||||
// VM verify failure #2. Reads plugin IDs from the agent spec review
|
||||
// defensively (the resources.plugins field is owned by batch 1 on
|
||||
// plugin-primitive-v0; on main it's absent and we render empty state).
|
||||
import { AgentPluginsList } from "./specs/AgentPluginsList.js";
|
||||
import { PreviewPane } from "./preview/PreviewPane.js";
|
||||
import { SessionPreviewPane } from "./preview/SessionPreviewPane.js";
|
||||
import { FileReferenceTrigger } from "./drawer-triggers/FileReferenceTrigger.js";
|
||||
@@ -126,17 +131,57 @@ function AgentSpecSection({ data }: { data: NodeDetailData }) {
|
||||
) : !review || review.kind !== "agent" ? (
|
||||
<div data-testid="agent-spec-unavailable" className="p-4 font-mono text-[10px] text-stone-400">No agent spec available</div>
|
||||
) : (
|
||||
<AgentSpecDisplay
|
||||
review={review as AgentSpecReview}
|
||||
yaml={review.raw}
|
||||
testIdPrefix="live-agent"
|
||||
sourcePath={review.sourcePath}
|
||||
/>
|
||||
<>
|
||||
<AgentSpecDisplay
|
||||
review={review as AgentSpecReview}
|
||||
yaml={review.raw}
|
||||
testIdPrefix="live-agent"
|
||||
sourcePath={review.sourcePath}
|
||||
/>
|
||||
{/* Slice 3.3 fix-B — Plugins section sits between AgentSpecDisplay
|
||||
(which renders Skills among other things) and the surrounding
|
||||
tabs' Startup Files block. Extracts plugin IDs from review
|
||||
defensively: the field is owned by batch 1 on plugin-primitive-v0;
|
||||
renders empty state on main + populated state post-merge. */}
|
||||
<section
|
||||
data-testid="live-agent-plugins-section"
|
||||
className="border border-outline-variant bg-white/30 p-3"
|
||||
>
|
||||
<div className="mb-2 font-mono text-[8px] uppercase tracking-wider text-stone-400">
|
||||
Plugins
|
||||
</div>
|
||||
<AgentPluginsList pluginIds={extractAgentPluginIds(review)} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Slice 3.3 fix-B — read plugin IDs from an agent review defensively.
|
||||
// On main without batch 1 merged, AgentSpecReview doesn't have
|
||||
// resources.plugins; we read via duck-typed any-cast so the field is
|
||||
// optional. Post-merge into plugin-primitive-v0, the typed
|
||||
// resources.plugins[] populates this naturally. The function also
|
||||
// tolerates the shorthand string form for forward-compat with future
|
||||
// agent.yaml shapes.
|
||||
function extractAgentPluginIds(review: unknown): string[] {
|
||||
if (!review || typeof review !== "object") return [];
|
||||
const resources = (review 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") {
|
||||
ids.push(p);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function ActionButtonsRow({ rigId, logicalId, data }: { rigId: string; logicalId: string; data: NodeDetailData }) {
|
||||
const handleCopyAttach = async () => {
|
||||
if (data.tmuxAttachCommand) await copyText(data.tmuxAttachCommand);
|
||||
|
||||
@@ -90,6 +90,10 @@ export function PluginDetailPage({ pluginId }: PluginDetailPageProps) {
|
||||
<ManifestSection detail={detail} />
|
||||
<SkillsSection skills={detail.skills} />
|
||||
<HooksSection hooks={detail.hooks} />
|
||||
{/* Slice 3.3 fix-A — MCP Servers section per dispatch §3.2
|
||||
(manifest preview + skills/hooks/MCP sections + Used-by).
|
||||
velocity-qa VM verify failure #1 closed. */}
|
||||
<McpServersSection mcpServers={detail.mcpServers} />
|
||||
<UsedBySection
|
||||
usedBy={usedBy}
|
||||
isLoading={usedByLoading}
|
||||
@@ -230,6 +234,71 @@ function HooksSection({ hooks }: { hooks: NonNullable<ReturnType<typeof usePlugi
|
||||
);
|
||||
}
|
||||
|
||||
// Slice 3.3 fix-A — McpServersSection per dispatch §3.2.
|
||||
// velocity-qa VM verify failure #1 — PluginViewer needs an MCP section
|
||||
// rendering the plugin's MCP server declarations (or empty state if no
|
||||
// MCP servers declared). DESIGN §5.7 covers MCP routing via plugins.
|
||||
function McpServersSection({
|
||||
mcpServers,
|
||||
}: {
|
||||
mcpServers: NonNullable<ReturnType<typeof usePlugin>["data"]>["mcpServers"];
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
data-testid="plugin-detail-mcp-servers"
|
||||
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">MCP Servers</SectionHeader>
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-stone-500">
|
||||
{mcpServers.length} {mcpServers.length === 1 ? "server" : "servers"}
|
||||
</span>
|
||||
</header>
|
||||
{mcpServers.length === 0 ? (
|
||||
<div className="px-3 py-4">
|
||||
<EmptyState
|
||||
label="NO MCP SERVERS"
|
||||
description="This plugin does not declare MCP servers in its manifest mcpServers field."
|
||||
variant="card"
|
||||
testId="plugin-mcp-servers-empty"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-outline-variant">
|
||||
{mcpServers.map((server) => (
|
||||
<li
|
||||
key={`${server.runtime}:${server.name}`}
|
||||
data-testid={`plugin-mcp-server-${server.name}`}
|
||||
className="px-3 py-2 font-mono text-[11px]"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="font-bold text-stone-900">{server.name}</span>
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">
|
||||
{server.runtime}
|
||||
</span>
|
||||
</div>
|
||||
{(server.command || server.transport) && (
|
||||
<div className="mt-1 flex flex-wrap gap-2 font-mono text-[9px] uppercase tracking-[0.10em] text-stone-600">
|
||||
{server.command && (
|
||||
<span className="border border-outline-variant px-1.5 py-0.5">
|
||||
cmd: {server.command}
|
||||
</span>
|
||||
)}
|
||||
{server.transport && (
|
||||
<span className="border border-outline-variant px-1.5 py-0.5">
|
||||
transport: {server.transport}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UsedBySection({
|
||||
usedBy,
|
||||
isLoading,
|
||||
|
||||
@@ -46,12 +46,22 @@ export interface PluginHookSummary {
|
||||
events: string[];
|
||||
}
|
||||
|
||||
// Slice 3.3 fix-A — MCP server summary mirrors daemon shape.
|
||||
export interface PluginMcpServerSummary {
|
||||
runtime: PluginRuntime;
|
||||
name: string;
|
||||
command: string | null;
|
||||
transport: string | null;
|
||||
}
|
||||
|
||||
export interface PluginDetail {
|
||||
entry: PluginEntry;
|
||||
claudeManifest: PluginManifestSummary | null;
|
||||
codexManifest: PluginManifestSummary | null;
|
||||
skills: PluginSkillSummary[];
|
||||
hooks: PluginHookSummary[];
|
||||
/** Slice 3.3 fix-A — MCP servers declared in manifest(s). */
|
||||
mcpServers: PluginMcpServerSummary[];
|
||||
}
|
||||
|
||||
export interface PluginAgentReference {
|
||||
|
||||
@@ -219,6 +219,42 @@ describe("LiveNodeDetails", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("slice 3.3 fix-B — agent-spec tab includes Plugins section between Skills and Startup Files", async () => {
|
||||
// velocity-qa VM verify failure #2 — Plugins section must be visible
|
||||
// in current build per dispatch §3.2. Empty state is acceptable on
|
||||
// main without plugin-primitive-v0 merged in (no agent.yaml carries
|
||||
// resources.plugins[] until merge); the section's PRESENCE is the
|
||||
// load-bearing assertion.
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/nodes/")) {
|
||||
return { ok: true, json: async () => NODE_DETAIL };
|
||||
}
|
||||
if (typeof url === "string" && url === "/api/specs/library?kind=agent") {
|
||||
return { ok: true, json: async () => [{ id: "agent-1", kind: "agent", name: "impl", version: "1.0.0", sourceType: "builtin", sourcePath: "/x/agent.yaml", relativePath: "x/agent.yaml" }] };
|
||||
}
|
||||
if (typeof url === "string" && url.includes("/api/specs/library/agent-1/review")) {
|
||||
return { ok: true, json: async () => ({ kind: "agent", name: "impl", version: "1.0.0", raw: "", sourcePath: "/x/agent.yaml", sourceState: "library_item", libraryEntryId: "agent-1", description: null, profiles: [], resources: { plugins: [], skills: [], guidance: [], hooks: [] }, startup: { files: [], actions: [] } }) };
|
||||
}
|
||||
if (typeof url === "string" && url.startsWith("/api/specs/library")) {
|
||||
return { ok: true, json: async () => [] };
|
||||
}
|
||||
if (typeof url === "string" && url === "/api/plugins") {
|
||||
return { ok: true, json: async () => [] };
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
renderDetails();
|
||||
// Switch to the agent-spec tab to see the Plugins section.
|
||||
const agentSpecTab = await screen.findByTestId("live-tab-agent-spec");
|
||||
fireEvent.click(agentSpecTab);
|
||||
// Section present (drives the visual proof the dispatch + velocity-qa want).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("live-agent-plugins-section")).toBeDefined();
|
||||
});
|
||||
// Empty state body when no plugins declared.
|
||||
expect(screen.getByTestId("agent-plugins-empty")).toBeDefined();
|
||||
});
|
||||
|
||||
it("PL-019: full details shows activity and current qitems from node detail", async () => {
|
||||
mockNodeDetail(NODE_DETAIL);
|
||||
renderDetails();
|
||||
|
||||
@@ -80,6 +80,10 @@ describe("PluginDetailPage", () => {
|
||||
hooks: [
|
||||
{ runtime: "claude", relativePath: "hooks/claude.json", events: ["SessionStart", "Stop"] },
|
||||
],
|
||||
mcpServers: [
|
||||
{ runtime: "claude", name: "github-mcp", command: "node", transport: "stdio" },
|
||||
{ runtime: "claude", name: "linear-mcp", command: "linear-mcp", transport: "http" },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -120,6 +124,11 @@ describe("PluginDetailPage", () => {
|
||||
expect(screen.getByText(/SessionStart/)).toBeDefined();
|
||||
expect(screen.getByText(/Stop/)).toBeDefined();
|
||||
|
||||
// Slice 3.3 fix-A — MCP servers section visible with both servers.
|
||||
expect(screen.getByTestId("plugin-detail-mcp-servers")).toBeDefined();
|
||||
expect(screen.getByTestId("plugin-mcp-server-github-mcp")).toBeDefined();
|
||||
expect(screen.getByTestId("plugin-mcp-server-linear-mcp")).toBeDefined();
|
||||
|
||||
// Used-by list — both agents visible.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("plugin-used-by-advisor-lead")).toBeDefined();
|
||||
@@ -148,6 +157,7 @@ describe("PluginDetailPage", () => {
|
||||
codexManifest: null,
|
||||
skills: [],
|
||||
hooks: [],
|
||||
mcpServers: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -160,6 +170,7 @@ describe("PluginDetailPage", () => {
|
||||
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-mcp-servers-empty")).toBeDefined();
|
||||
expect(screen.getByTestId("plugin-used-by-empty")).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user