fix: show plugin-contributed skills in the workflow editor

The dashboard's discovered-skills catalog was built only from the
disk-scanning package manager, so plugin-contributed skills (e.g.
compound-engineering ce-*) — which the engine materializes for executor
sessions separately — never appeared in the editor. Built-in workflow
nodes that reference them (builtin:compound-engineering) showed
"— select skill —" / unresolved.

- skills-adapter: merge plugin skill contributions into the discovered
  list (deduped by bare name) via an optional getPluginSkills thunk;
  add shared bareSkillName normalizer.
- wire getPluginSkills into all three server entry points: serve,
  daemon, and dashboard (the UI-serving command — verified via live
  end-to-end that omitting it left the editor catalog empty).
- node-summary + WorkflowNodeEditor: resolve namespaced skillNames
  (compound-engineering:ce-work) against the catalog's two-segment
  names (ce-work/SKILL.md) so nodes display and select the right skill.

Verified: dashboard + CLI typecheck, 136 dashboard tests, and a live
dashboard E2E (discovered skills 0→11; Plan node resolves to "ce-plan"
in both the canvas label and the inspector dropdown).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 00:05:03 -07:00
parent 849eefa35e
commit 91017050d4
9 changed files with 249 additions and 19 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Show plugin-contributed skills (e.g. compound-engineering `ce-*`) in the workflow editor. The dashboard's discovered-skills catalog was built only from the disk-scanning package manager, so plugin skills — which the engine materializes for executor sessions separately — never appeared, and built-in workflow nodes that reference them (like `builtin:compound-engineering`) showed "— select skill —" / unresolved. The skills adapter now merges plugin skill contributions into the discovered list (deduped by bare name), and the editor's node summary + skill dropdown match namespaced skillNames (`compound-engineering:ce-work`) against the catalog's two-segment names (`ce-work/SKILL.md`) via a shared bare-name normalizer.

View File

@@ -685,6 +685,9 @@ export async function runDaemon(opts: DaemonOptions = {}) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager
packageManager: packageManager as any,
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
// Surface plugin-contributed skills (e.g. compound-engineering ce-*) in
// the editor catalog; the package manager only scans disk.
getPluginSkills: () => pluginLoader.getPluginSkills(),
})
: undefined;

View File

@@ -1549,6 +1549,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager
packageManager: packageManager as any,
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
// Surface plugin-contributed skills (e.g. compound-engineering ce-*) in
// the discovered-skills catalog so the workflow editor can resolve and
// display them. Lazy thunk: plugins finish loading before discovery runs.
getPluginSkills: () => pluginLoader.getPluginSkills(),
})
: undefined;

View File

@@ -794,6 +794,9 @@ export async function runServe(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager
packageManager: packageManager as any,
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
// Surface plugin-contributed skills (e.g. compound-engineering ce-*) in
// the editor catalog; the package manager only scans disk.
getPluginSkills: () => pluginLoader.getPluginSkills(),
})
: undefined;

View File

@@ -52,7 +52,7 @@ import { useAppSettings } from "../hooks/useAppSettings";
import { isMobileViewport, useViewportMode } from "../hooks/useViewportMode";
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext";
import type { NodeSummaryCatalogs } from "./nodes/node-summary";
import { bareSkillName, type NodeSummaryCatalogs } from "./nodes/node-summary";
import {
irToFlow,
flowToIr,
@@ -3419,20 +3419,32 @@ function InnerEditor({
);
})()}
{currentExecutor === "skill" && (
<label className="wf-field">
<span>{t("workflowEditor.skill", "Skill")}</span>
<select
value={String(selectedNode.data.config?.skillName ?? "")}
onChange={(e) => updateSelectedData({ config: { skillName: e.target.value || undefined } })}
>
<option value="">{t("workflowEditor.selectSkill", "— select skill —")}</option>
{skills.map((s) => (
<option key={s.id} value={s.name}>{s.name}</option>
))}
</select>
</label>
)}
{currentExecutor === "skill" && (() => {
// The stored skillName may be namespaced (e.g.
// "compound-engineering:ce-work") while the <option> values are
// the catalog's two-segment names ("ce-work/SKILL.md"). Resolve
// through bareSkillName so the matching option shows as selected
// instead of falling back to "— select skill —".
const rawSkill = String(selectedNode.data.config?.skillName ?? "");
const matchedSkill = rawSkill
? skills.find((s) => bareSkillName(s.name) === bareSkillName(rawSkill))
: undefined;
const selectedSkillValue = matchedSkill ? matchedSkill.name : rawSkill;
return (
<label className="wf-field">
<span>{t("workflowEditor.skill", "Skill")}</span>
<select
value={selectedSkillValue}
onChange={(e) => updateSelectedData({ config: { skillName: e.target.value || undefined } })}
>
<option value="">{t("workflowEditor.selectSkill", "— select skill —")}</option>
{skills.map((s) => (
<option key={s.id} value={s.name}>{s.name}</option>
))}
</select>
</label>
);
})()}
{currentExecutor === "cli" && (
<>

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { nodeConfigSummary, type NodeSummaryCatalogs } from "../node-summary";
import { bareSkillName, nodeConfigSummary, type NodeSummaryCatalogs } from "../node-summary";
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "../WorkflowNodeTypes";
function node(kind: WorkflowEditorNodeKind, config: Record<string, unknown> = {}): WorkflowFlowNodeData {
@@ -50,6 +50,35 @@ describe("nodeConfigSummary", () => {
expect(summary).toBe("deep-research");
});
it("skill executor → resolves a plugin-namespaced skillName to the catalog's bare name", () => {
const catalogs: NodeSummaryCatalogs = { skills: [{ id: "p::skills/ce-work/SKILL.md", name: "ce-work" }] };
const summary = nodeConfigSummary(
node("prompt", { executor: "skill", skillName: "compound-engineering:ce-work" }),
catalogs,
);
expect(summary).toBe("ce-work");
});
it("skill executor → resolves a namespaced skillName against a two-segment catalog name", () => {
const catalogs: NodeSummaryCatalogs = {
skills: [{ id: "src::skills/ce-work/SKILL.md", name: "ce-work/SKILL.md" }],
};
const summary = nodeConfigSummary(
node("prompt", { executor: "skill", skillName: "compound-engineering:ce-work" }),
catalogs,
);
expect(summary).toBe("ce-work/SKILL.md");
});
it("skill executor → falls back to raw skillName when no catalog entry matches", () => {
const catalogs: NodeSummaryCatalogs = { skills: [{ id: "s1", name: "something-else" }] };
const summary = nodeConfigSummary(
node("prompt", { executor: "skill", skillName: "compound-engineering:ce-work" }),
catalogs,
);
expect(summary).toBe("compound-engineering:ce-work");
});
it("cli command executor → truncated command", () => {
const long = "npm run test -- --runInBand --reporter verbose --bail --watch=false";
const summary = nodeConfigSummary(node("prompt", { executor: "cli", cliMode: "command", cliCommand: long }));
@@ -187,3 +216,17 @@ describe("nodeConfigSummary", () => {
expect(nodeConfigSummary(node("prompt", {}), {}, t)).toBe("T:workflowNodes.summaryNotConfigured");
});
});
describe("bareSkillName", () => {
it("reduces every skill-name form to the same bare token", () => {
expect(bareSkillName("compound-engineering:ce-work")).toBe("ce-work");
expect(bareSkillName("ce-work/SKILL.md")).toBe("ce-work");
expect(bareSkillName("compound-engineering::skills/ce-work/SKILL.md")).toBe("ce-work");
expect(bareSkillName("ce-work")).toBe("ce-work");
});
it("is case-insensitive and handles empty input", () => {
expect(bareSkillName("Compound-Engineering:CE-Work")).toBe("ce-work");
expect(bareSkillName("")).toBe("");
});
});

View File

@@ -70,6 +70,26 @@ function joinModeSummary(config: Record<string, unknown>): string {
return typeof m === "string" ? m : "all";
}
/**
* Normalize any of the skill-name forms the workflow editor encounters down to
* a single bare token (lowercased) for matching:
* "compound-engineering:ce-work" → "ce-work" (plugin-namespaced node skillName)
* "ce-work/SKILL.md" → "ce-work" (catalog two-segment name)
* "<source>::skills/ce-work/SKILL.md" → "ce-work" (catalog id)
* "ce-work" → "ce-work"
* Builtin workflow nodes store a `pluginId:skill` skillName, but the discovered-
* skills catalog keys entries by two-segment name / `source::path` id, so an
* exact comparison never matches. Reducing both sides to the bare skill token
* lets them resolve.
*/
export function bareSkillName(name: string): string {
if (!name) return "";
const withoutSkillMd = name.replace(/\/SKILL\.md$/i, "");
const lastPathSegment = withoutSkillMd.split("/").pop() ?? withoutSkillMd;
const afterNamespace = lastPathSegment.split(":").pop() ?? lastPathSegment;
return afterNamespace.toLowerCase();
}
/**
* Map a node's `data.kind` + `data.config` to a short, single-line summary used
* by the card-style node's summary row. Returns "" for kinds with no meaningful
@@ -116,8 +136,18 @@ export function nodeConfigSummary(
if (executor === "skill") {
const skillName = str(config.skillName);
if (!skillName) return t("workflowNodes.summaryNotConfigured", "Not configured");
// skillName is stored as the skill's name; resolve by name or id.
const match = catalogs.skills?.find((s) => s.name === skillName || s.id === skillName);
// skillName may be stored namespaced (e.g. "compound-engineering:ce-work")
// while catalog entries use a two-segment name ("ce-work/SKILL.md") or a
// "<source>::path" id — bareSkillName() normalizes all forms so plugin-
// contributed and builtin-workflow skills resolve, not just exact matches.
const bare = bareSkillName(skillName);
const match = catalogs.skills?.find(
(s) =>
s.name === skillName ||
s.id === skillName ||
bareSkillName(s.name) === bare ||
bareSkillName(s.id) === bare,
);
return match?.name || skillName;
}
if (executor === "cli") {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createSkillsAdapter, extractSkillName, computeSkillId } from "../skills-adapter.js";
import { createSkillsAdapter, extractSkillName, computeSkillId, bareSkillName } from "../skills-adapter.js";
import { writeFile, mkdir, access, readFile, rm } from "node:fs/promises";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
@@ -761,3 +761,79 @@ describe("extractSkillName", () => {
expect(extractSkillName("windows-fix", "npm")).toBe("windows-fix");
});
});
describe("createSkillsAdapter - plugin skill merge", () => {
// A disk-discovered skill "ce-work" lives at <baseDir>/ce-work/SKILL.md →
// discoverSkills derives the catalog name "ce-work/SKILL.md" (bare "ce-work").
const diskSkillResource = {
path: "/tmp/skills-root/ce-work/SKILL.md",
enabled: true,
metadata: {
source: "owner/repo",
scope: "project" as const,
origin: "top-level" as const,
baseDir: "/tmp/skills-root",
},
};
it("adds plugin-contributed skills to the discovered list with bare names", async () => {
const adapter = createSkillsAdapter({
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
getSettingsPath: () => "/tmp/does-not-exist-settings.json",
getPluginSkills: () => [
{ pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-plan", enabled: true } },
{ pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-work" } },
{ pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-debug", enabled: false } },
],
});
const skills = await adapter.discoverSkills("/tmp/project");
const byName = new Map(skills.map((s) => [s.name, s]));
expect(byName.has("ce-plan")).toBe(true);
expect(byName.has("ce-work")).toBe(true);
expect(byName.get("ce-plan")!.metadata.source).toBe("plugin:fusion-plugin-compound-engineering");
// enabled defaults to true unless the contribution sets enabled === false.
expect(byName.get("ce-work")!.enabled).toBe(true);
expect(byName.get("ce-debug")!.enabled).toBe(false);
// Ids are stable + parseable, and distinct from any disk skill id.
expect(byName.get("ce-plan")!.id).toContain("::");
});
it("dedups a plugin skill that is already discovered on disk (by bare name)", async () => {
const adapter = createSkillsAdapter({
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [diskSkillResource] }) },
getSettingsPath: () => "/tmp/does-not-exist-settings.json",
getPluginSkills: () => [
{ pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-work" } },
],
});
const skills = await adapter.discoverSkills("/tmp/project");
const ceWorkEntries = skills.filter((s) => bareSkillName(s.name) === "ce-work");
// Only the disk entry survives — the plugin duplicate is not appended.
expect(ceWorkEntries).toHaveLength(1);
expect(ceWorkEntries[0]!.metadata.source).toBe("owner/repo");
});
it("is a no-op when no getPluginSkills callback is supplied", async () => {
const adapter = createSkillsAdapter({
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
getSettingsPath: () => "/tmp/does-not-exist-settings.json",
});
const skills = await adapter.discoverSkills("/tmp/project");
expect(skills).toEqual([]);
});
});
describe("bareSkillName", () => {
it("reduces every skill-name form to the same bare token", () => {
expect(bareSkillName("compound-engineering:ce-work")).toBe("ce-work");
expect(bareSkillName("ce-work/SKILL.md")).toBe("ce-work");
expect(bareSkillName("compound-engineering::skills/ce-work/SKILL.md")).toBe("ce-work");
expect(bareSkillName("ce-work")).toBe("ce-work");
expect(bareSkillName("")).toBe("");
});
});

View File

@@ -211,6 +211,21 @@ function normalizeStoredSkillPath(path: string): string {
return path.replaceAll("\\", "/").replace(/^skills\//, "");
}
/**
* Reduce any skill-name form to a single bare token (lowercased) for dedup:
* "ce-work/SKILL.md" / "<src>::skills/ce-work/SKILL.md" / "ce-work" → "ce-work"
* Mirrors the editor-side helper (app/components/nodes/node-summary.ts); kept
* local because the dashboard client bundle (app/) and server source (src/) are
* separate build roots that do not share value imports.
*/
export function bareSkillName(name: string): string {
if (!name) return "";
const withoutSkillMd = name.replace(/\/SKILL\.md$/i, "");
const lastPathSegment = withoutSkillMd.split("/").pop() ?? withoutSkillMd;
const afterNamespace = lastPathSegment.split(":").pop() ?? lastPathSegment;
return afterNamespace.toLowerCase();
}
function isValidInstallSource(source: string): boolean {
return /^[^/]+\/[^/]+$/.test(source);
}
@@ -302,6 +317,14 @@ export function createSkillsAdapter(options: {
};
/** Project settings path helper */
getSettingsPath: (rootDir: string) => string;
/**
* Optional source of plugin-contributed skills (e.g. compound-engineering
* ce-*). These are materialized for executor sessions by the engine but are
* NOT seen by the disk-scanning package manager, so without this the editor
* skill catalog omits them. Lazy thunk: plugins may load after the adapter is
* created, so it is invoked per discovery rather than captured eagerly.
*/
getPluginSkills?: () => Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }>;
/** Optional superviseSpawn seam for tests */
superviseSpawn?: typeof superviseSpawn;
}): SkillsAdapter {
@@ -350,6 +373,37 @@ export function createSkillsAdapter(options: {
});
}
// Merge plugin-contributed skills (e.g. compound-engineering ce-*). The
// package manager only scans disk (cwd/agentDir/configured packages), so
// plugin skills — which the engine materializes for sessions separately —
// would otherwise never appear in the editor catalog. Dedup against disk
// skills by bare name so a plugin skill that is also installed on disk is
// not listed twice.
const pluginSkills = options.getPluginSkills?.() ?? [];
if (pluginSkills.length > 0) {
const seenBareNames = new Set(discoveredSkills.map((s) => bareSkillName(s.name)));
for (const { pluginId, skill } of pluginSkills) {
const name = skill.name?.trim();
if (!name) continue;
const bare = bareSkillName(name);
if (seenBareNames.has(bare)) continue;
seenBareNames.add(bare);
const relativePath = `skills/${name}/SKILL.md`;
discoveredSkills.push({
id: computeSkillId(`plugin:${pluginId}`, relativePath),
name,
path: relativePath,
relativePath,
enabled: skill.enabled !== false,
metadata: {
source: `plugin:${pluginId}`,
scope: "user",
origin: "package",
},
});
}
}
return discoveredSkills;
},