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:
5
.changeset/fix-editor-plugin-skill-catalog.md
Normal file
5
.changeset/fix-editor-plugin-skill-catalog.md
Normal 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.
|
||||||
@@ -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
|
// 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,
|
packageManager: packageManager as any,
|
||||||
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
|
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;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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,
|
packageManager: packageManager as any,
|
||||||
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
|
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;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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,
|
packageManager: packageManager as any,
|
||||||
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
|
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;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import { useAppSettings } from "../hooks/useAppSettings";
|
|||||||
import { isMobileViewport, useViewportMode } from "../hooks/useViewportMode";
|
import { isMobileViewport, useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
||||||
import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext";
|
import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext";
|
||||||
import type { NodeSummaryCatalogs } from "./nodes/node-summary";
|
import { bareSkillName, type NodeSummaryCatalogs } from "./nodes/node-summary";
|
||||||
import {
|
import {
|
||||||
irToFlow,
|
irToFlow,
|
||||||
flowToIr,
|
flowToIr,
|
||||||
@@ -3419,20 +3419,32 @@ function InnerEditor({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{currentExecutor === "skill" && (
|
{currentExecutor === "skill" && (() => {
|
||||||
<label className="wf-field">
|
// The stored skillName may be namespaced (e.g.
|
||||||
<span>{t("workflowEditor.skill", "Skill")}</span>
|
// "compound-engineering:ce-work") while the <option> values are
|
||||||
<select
|
// the catalog's two-segment names ("ce-work/SKILL.md"). Resolve
|
||||||
value={String(selectedNode.data.config?.skillName ?? "")}
|
// through bareSkillName so the matching option shows as selected
|
||||||
onChange={(e) => updateSelectedData({ config: { skillName: e.target.value || undefined } })}
|
// instead of falling back to "— select skill —".
|
||||||
>
|
const rawSkill = String(selectedNode.data.config?.skillName ?? "");
|
||||||
<option value="">{t("workflowEditor.selectSkill", "— select skill —")}</option>
|
const matchedSkill = rawSkill
|
||||||
{skills.map((s) => (
|
? skills.find((s) => bareSkillName(s.name) === bareSkillName(rawSkill))
|
||||||
<option key={s.id} value={s.name}>{s.name}</option>
|
: undefined;
|
||||||
))}
|
const selectedSkillValue = matchedSkill ? matchedSkill.name : rawSkill;
|
||||||
</select>
|
return (
|
||||||
</label>
|
<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" && (
|
{currentExecutor === "cli" && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "../WorkflowNodeTypes";
|
||||||
|
|
||||||
function node(kind: WorkflowEditorNodeKind, config: Record<string, unknown> = {}): WorkflowFlowNodeData {
|
function node(kind: WorkflowEditorNodeKind, config: Record<string, unknown> = {}): WorkflowFlowNodeData {
|
||||||
@@ -50,6 +50,35 @@ describe("nodeConfigSummary", () => {
|
|||||||
expect(summary).toBe("deep-research");
|
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", () => {
|
it("cli command executor → truncated command", () => {
|
||||||
const long = "npm run test -- --runInBand --reporter verbose --bail --watch=false";
|
const long = "npm run test -- --runInBand --reporter verbose --bail --watch=false";
|
||||||
const summary = nodeConfigSummary(node("prompt", { executor: "cli", cliMode: "command", cliCommand: long }));
|
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");
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -70,6 +70,26 @@ function joinModeSummary(config: Record<string, unknown>): string {
|
|||||||
return typeof m === "string" ? m : "all";
|
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
|
* 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
|
* by the card-style node's summary row. Returns "" for kinds with no meaningful
|
||||||
@@ -116,8 +136,18 @@ export function nodeConfigSummary(
|
|||||||
if (executor === "skill") {
|
if (executor === "skill") {
|
||||||
const skillName = str(config.skillName);
|
const skillName = str(config.skillName);
|
||||||
if (!skillName) return t("workflowNodes.summaryNotConfigured", "Not configured");
|
if (!skillName) return t("workflowNodes.summaryNotConfigured", "Not configured");
|
||||||
// skillName is stored as the skill's name; resolve by name or id.
|
// skillName may be stored namespaced (e.g. "compound-engineering:ce-work")
|
||||||
const match = catalogs.skills?.find((s) => s.name === skillName || s.id === skillName);
|
// 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;
|
return match?.name || skillName;
|
||||||
}
|
}
|
||||||
if (executor === "cli") {
|
if (executor === "cli") {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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 { writeFile, mkdir, access, readFile, rm } from "node:fs/promises";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -761,3 +761,79 @@ describe("extractSkillName", () => {
|
|||||||
expect(extractSkillName("windows-fix", "npm")).toBe("windows-fix");
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -211,6 +211,21 @@ function normalizeStoredSkillPath(path: string): string {
|
|||||||
return path.replaceAll("\\", "/").replace(/^skills\//, "");
|
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 {
|
function isValidInstallSource(source: string): boolean {
|
||||||
return /^[^/]+\/[^/]+$/.test(source);
|
return /^[^/]+\/[^/]+$/.test(source);
|
||||||
}
|
}
|
||||||
@@ -302,6 +317,14 @@ export function createSkillsAdapter(options: {
|
|||||||
};
|
};
|
||||||
/** Project settings path helper */
|
/** Project settings path helper */
|
||||||
getSettingsPath: (rootDir: string) => string;
|
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 */
|
/** Optional superviseSpawn seam for tests */
|
||||||
superviseSpawn?: typeof superviseSpawn;
|
superviseSpawn?: typeof superviseSpawn;
|
||||||
}): SkillsAdapter {
|
}): 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;
|
return discoveredSkills;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user