Merge pull request #1701 from Runfusion/gsxdsm/compound-improve
fix: show plugin-contributed skills in the workflow editor
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
|
||||
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;
|
||||
|
||||
|
||||
@@ -1551,6 +1551,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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" && (
|
||||
<>
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,137 @@ 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([]);
|
||||
});
|
||||
|
||||
it("lets a project-settings toggle override a plugin skill's default enabled", async () => {
|
||||
const dir = join(tmpdir(), `skills-adapter-plugin-toggle-${process.pid}-${Date.now()}`);
|
||||
const settingsPath = join(dir, "settings.json");
|
||||
await mkdir(dir, { recursive: true });
|
||||
// ce-plan defaults to enabled, but a "-" entry under its plugin package
|
||||
// source must disable it; without the settings lookup the toggle is lost.
|
||||
const relativePath = "skills/ce-plan/SKILL.md";
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
packages: [{ source: "plugin:fusion-plugin-compound-engineering", skills: [`-${relativePath}`] }],
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const adapter = createSkillsAdapter({
|
||||
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
|
||||
getSettingsPath: () => settingsPath,
|
||||
getPluginSkills: () => [
|
||||
{ pluginId: "fusion-plugin-compound-engineering", skill: { name: "ce-plan", enabled: true } },
|
||||
],
|
||||
});
|
||||
|
||||
const skills = await adapter.discoverSkills(dir);
|
||||
const cePlan = skills.find((s) => s.name === "ce-plan");
|
||||
expect(cePlan).toBeDefined();
|
||||
expect(cePlan!.enabled).toBe(false);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSkillsAdapter - readSkillContent for plugin skills", () => {
|
||||
it("returns synthesized content (not a blank panel) for plugin-contributed skills", 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", description: "Create structured plans." },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const skills = await adapter.discoverSkills("/tmp/project");
|
||||
const cePlan = skills.find((s) => s.name === "ce-plan")!;
|
||||
|
||||
const content = await adapter.readSkillContent("/tmp/project", cePlan.id);
|
||||
expect(content.name).toBe("ce-plan");
|
||||
expect(content.skillMd).toContain("ce-plan");
|
||||
expect(content.skillMd).toContain("Create structured plans.");
|
||||
expect(content.skillMd).toContain("fusion-plugin-compound-engineering");
|
||||
expect(content.skillMd).not.toBe("");
|
||||
expect(content.files).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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface DiscoveredSkill {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
enabled: boolean;
|
||||
/** Optional human-readable description (currently set for plugin skills). */
|
||||
description?: string;
|
||||
metadata: {
|
||||
source: string;
|
||||
scope: "user" | "project" | "temporary";
|
||||
@@ -211,6 +213,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);
|
||||
}
|
||||
@@ -241,17 +258,18 @@ async function waitForSupervisedExit(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a skill path is enabled in the settings.
|
||||
* Checks both top-level skills and package-scoped skills.
|
||||
* Resolve a skill's explicit enable/disable state from settings.
|
||||
* Checks both top-level skills and package-scoped skills. Returns "enabled" or
|
||||
* "disabled" when a settings entry matches, or undefined when the settings file
|
||||
* says nothing about this skill -- so callers can apply their own default.
|
||||
*/
|
||||
function isSkillEnabled(
|
||||
function getSkillSettingState(
|
||||
skillId: string,
|
||||
settings: { skills?: string[]; packages?: Array<{ source: string; skills?: string[] }> },
|
||||
): boolean {
|
||||
// Check top-level skills
|
||||
): "enabled" | "disabled" | undefined {
|
||||
const parsedSkillId = parseSkillId(skillId);
|
||||
if (!parsedSkillId) {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedSkillPath = normalizeStoredSkillPath(parsedSkillId.relativePath);
|
||||
@@ -263,7 +281,7 @@ function isSkillEnabled(
|
||||
);
|
||||
const entryId = computeSkillId("*", `skills/${entryPath}`);
|
||||
if (entryId === skillId || entryPath === normalizedSkillPath) {
|
||||
return entry.startsWith("+");
|
||||
return entry.startsWith("+") ? "enabled" : "disabled";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,13 +298,24 @@ function isSkillEnabled(
|
||||
);
|
||||
const entryId = computeSkillId(source, `skills/${entryPath}`);
|
||||
if (entryId === skillId || entryPath === normalizedSkillPath) {
|
||||
return entry.startsWith("+");
|
||||
return entry.startsWith("+") ? "enabled" : "disabled";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to disabled if not found
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a skill path is enabled in the settings.
|
||||
* Checks both top-level skills and package-scoped skills.
|
||||
* Defaults to disabled when the settings file says nothing about the skill.
|
||||
*/
|
||||
function isSkillEnabled(
|
||||
skillId: string,
|
||||
settings: { skills?: string[]; packages?: Array<{ source: string; skills?: string[] }> },
|
||||
): boolean {
|
||||
return getSkillSettingState(skillId, settings) === "enabled";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,6 +331,17 @@ 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; description?: string; enabled?: boolean };
|
||||
}>;
|
||||
/** Optional superviseSpawn seam for tests */
|
||||
superviseSpawn?: typeof superviseSpawn;
|
||||
}): SkillsAdapter {
|
||||
@@ -350,6 +390,50 @@ 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`;
|
||||
const id = computeSkillId(`plugin:${pluginId}`, relativePath);
|
||||
// Respect an explicit enable/disable written to project settings by
|
||||
// toggleExecutionSkill, falling back to the plugin's declared default.
|
||||
// Without consulting settings here, a user toggle on a plugin skill
|
||||
// would be silently reverted on the very next discovery.
|
||||
const settingState = getSkillSettingState(
|
||||
id,
|
||||
settings as Parameters<typeof getSkillSettingState>[1],
|
||||
);
|
||||
const enabled = settingState === undefined
|
||||
? skill.enabled !== false
|
||||
: settingState === "enabled";
|
||||
discoveredSkills.push({
|
||||
id,
|
||||
name,
|
||||
path: relativePath,
|
||||
relativePath,
|
||||
enabled,
|
||||
description: skill.description,
|
||||
metadata: {
|
||||
source: `plugin:${pluginId}`,
|
||||
scope: "user",
|
||||
origin: "package",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return discoveredSkills;
|
||||
},
|
||||
|
||||
@@ -628,6 +712,27 @@ export function createSkillsAdapter(options: {
|
||||
throw new Error(`Skill not found: ${skillId}`);
|
||||
}
|
||||
|
||||
// Plugin-contributed skills have no on-disk representation in this
|
||||
// catalog: the engine materializes them for executor sessions at runtime,
|
||||
// so `path` is a virtual relative path with no filesystem backing. Reading
|
||||
// it would silently return a blank panel, so surface what we know (name +
|
||||
// description) and explain where the definition lives instead.
|
||||
if (skill.metadata.source.startsWith("plugin:")) {
|
||||
const pluginId = skill.metadata.source.slice("plugin:".length);
|
||||
const lines = [`# ${skill.name}`, ""];
|
||||
if (skill.description) {
|
||||
lines.push(skill.description, "");
|
||||
}
|
||||
lines.push(
|
||||
`_Contributed by the \`${pluginId}\` plugin. Its definition is materialized at runtime and has no editable file in this project._`,
|
||||
);
|
||||
return {
|
||||
name: skill.name,
|
||||
skillMd: lines.join("\n"),
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
|
||||
let skillDir = skill.path;
|
||||
try {
|
||||
const skillPathStat = await stat(skill.path);
|
||||
|
||||
Reference in New Issue
Block a user