FN-7860: honor plugin skillFiles paths for skill body resolution
Plugin skills declared with PluginSkillContribution.skillFiles were silently ignored by the host, forcing plugin authors into a flat skills/<name>/SKILL.md layout instead of category subdirectories. - Add packages/core/src/plugin-skill-paths.ts with resolvePluginSkillBodyPath (honors skillFiles[0] relative to plugin root, falls back to skills/<name>/SKILL.md, rejects path traversal) and resolvePluginRootFromEntryPath - Track per-plugin absolute roots in PluginLoader and expose pluginRoot alongside each getPluginSkills() contribution - Thread pluginRoot/skillFiles through PluginRunner, dashboard server/chat structural types, and skills-adapter so discovered plugin skill path/relativePath resolve via the new traversal-guarded resolver when a pluginRoot is available, keeping the old name-derived path for backward compatibility otherwise - Export resolvePluginSkillBodyPath/resolvePluginRootFromEntryPath/PluginSkillBodyPath from @fusion/core - Update docs/PLUGIN_AUTHORING.md and add unit tests covering the new resolver and updated plugin-loader/skills-adapter/plugin-runner behavior - Add changeset (@runfusion/fusion: minor, category: fix) Files changed: .changeset/fn-7860-plugin-skillfiles.md | 7 ++ docs/PLUGIN_AUTHORING.md | 4 +- packages/core/src/__tests__/plugin-loader.test.ts | 23 +++++++ .../core/src/__tests__/plugin-skill-paths.test.ts | 75 ++++++++++++++++++++++ packages/core/src/index.ts | 5 ++ packages/core/src/plugin-loader.ts | 20 +++++- packages/core/src/plugin-skill-paths.ts | 58 +++++++++++++++++ .../dashboard/src/__tests__/skills-adapter.test.ts | 75 +++++++++++++++++++++- packages/dashboard/src/chat.ts | 2 +- packages/dashboard/src/server.ts | 2 +- packages/dashboard/src/skills-adapter.ts | 19 ++++-- .../engine/src/__tests__/plugin-runner.test.ts | 2 +- packages/engine/src/plugin-runner.ts | 4 +- 13 files changed, 280 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-7860 Fusion-Task-Lineage: 720cf527-9c6f-4877-838e-5fb64bd86556 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7860-plugin-skillfiles.md
Normal file
7
.changeset/fn-7860-plugin-skillfiles.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Honor plugin skillFiles paths so plugin skills can live in category subdirectories.
|
||||
category: fix
|
||||
dev: Adds traversal-guarded plugin skill body path resolution and carries pluginRoot through skill discovery.
|
||||
@@ -1496,14 +1496,14 @@ const skills: PluginSkillContribution[] = [
|
||||
skillId: "web-research",
|
||||
name: "Web Research",
|
||||
description: "Finds and summarizes web sources for a task",
|
||||
skillFiles: ["skills/web-research/SKILL.md"],
|
||||
skillFiles: ["skills/research/web-research/SKILL.md"],
|
||||
enabled: true,
|
||||
triggerPatterns: ["research", "search the web", "find sources"],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
`skillFiles` are relative to the plugin root. `skillId` must be kebab-case.
|
||||
`skillFiles` are relative to the plugin root. The first entry, `skillFiles[0]`, is the authoritative body file that Fusion resolves for the skill, so plugins can organize skill bodies in category subdirectories such as `skills/research/web-research/SKILL.md` while keeping a short `skillId`. When `skillFiles` is omitted or empty, Fusion falls back to the compatibility path `skills/<name>/SKILL.md`. `skillId` must be kebab-case.
|
||||
|
||||
Plugin skills are discovered per requesting project: the Skills view and workflow editor surface `plugin:<id>` skills only when that plugin is enabled for that project's plugin state, even if the daemon was started from a different directory.
|
||||
|
||||
|
||||
@@ -2756,15 +2756,38 @@ export default plugin;
|
||||
it("getPluginSkills returns skills with pluginId", async () => {
|
||||
await pluginStore.init();
|
||||
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const pluginRoot = join(rootDir, "plugins", "skills-plugin");
|
||||
(loader as any).plugins.set("skills-plugin", {
|
||||
manifest: makeManifest({ id: "skills-plugin" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
skills: [{ skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] }],
|
||||
} as FusionPlugin);
|
||||
(loader as any).pluginRoots.set("skills-plugin", pluginRoot);
|
||||
expect(loader.getPluginSkills()).toEqual([
|
||||
{
|
||||
pluginId: "skills-plugin",
|
||||
pluginRoot,
|
||||
skill: { skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("getPluginSkills carries the resolved absolute pluginRoot after load", async () => {
|
||||
await pluginStore.init();
|
||||
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const pluginDir = join(rootDir, "plugins", "skills-plugin");
|
||||
const plugin = makePlugin(makeManifest({ id: "skills-plugin" }));
|
||||
plugin.skills = [{ skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] }];
|
||||
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
|
||||
await pluginStore.registerPlugin({ manifest: plugin.manifest, path: pluginPath });
|
||||
|
||||
await loader.loadPlugin("skills-plugin");
|
||||
|
||||
expect(loader.getPluginSkills()).toEqual([
|
||||
{
|
||||
pluginId: "skills-plugin",
|
||||
pluginRoot: pluginDir,
|
||||
skill: { skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] },
|
||||
},
|
||||
]);
|
||||
|
||||
75
packages/core/src/__tests__/plugin-skill-paths.test.ts
Normal file
75
packages/core/src/__tests__/plugin-skill-paths.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { join, resolve } from "node:path";
|
||||
import { resolvePluginSkillBodyPath } from "../plugin-skill-paths.js";
|
||||
import type { PluginSkillContribution } from "../plugin-types.js";
|
||||
|
||||
function skill(overrides: Partial<PluginSkillContribution> = {}): PluginSkillContribution {
|
||||
return {
|
||||
skillId: "entity-framework-core",
|
||||
name: "entity-framework-core",
|
||||
description: "EF Core guidance",
|
||||
skillFiles: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolvePluginSkillBodyPath", () => {
|
||||
const pluginRoot = resolve("/tmp/fusion-plugin");
|
||||
|
||||
it("honors category-subdir skillFiles as the skill body path", () => {
|
||||
const result = resolvePluginSkillBodyPath(
|
||||
skill({ skillFiles: ["skills/data/entity-framework-core/SKILL.md"] }),
|
||||
pluginRoot,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
relativePath: "skills/data/entity-framework-core/SKILL.md",
|
||||
absolutePath: join(pluginRoot, "skills/data/entity-framework-core/SKILL.md"),
|
||||
});
|
||||
});
|
||||
|
||||
it("honors flat skillFiles without changing existing relative paths", () => {
|
||||
const result = resolvePluginSkillBodyPath(
|
||||
skill({ skillFiles: ["skills/entity-framework-core/SKILL.md"] }),
|
||||
pluginRoot,
|
||||
);
|
||||
|
||||
expect(result.relativePath).toBe("skills/entity-framework-core/SKILL.md");
|
||||
expect(result.absolutePath).toBe(join(pluginRoot, "skills/entity-framework-core/SKILL.md"));
|
||||
});
|
||||
|
||||
it("falls back to the name-derived path when skillFiles is empty or absent", () => {
|
||||
expect(resolvePluginSkillBodyPath(skill({ skillFiles: [] }), pluginRoot)).toEqual({
|
||||
relativePath: "skills/entity-framework-core/SKILL.md",
|
||||
absolutePath: join(pluginRoot, "skills/entity-framework-core/SKILL.md"),
|
||||
});
|
||||
expect(resolvePluginSkillBodyPath(skill({ skillFiles: undefined as unknown as string[] }), pluginRoot)).toEqual({
|
||||
relativePath: "skills/entity-framework-core/SKILL.md",
|
||||
absolutePath: join(pluginRoot, "skills/entity-framework-core/SKILL.md"),
|
||||
});
|
||||
});
|
||||
|
||||
it("guards traversal and falls back without resolving outside the plugin root", () => {
|
||||
const result = resolvePluginSkillBodyPath(
|
||||
skill({ skillFiles: ["../outside/SKILL.md"] }),
|
||||
pluginRoot,
|
||||
);
|
||||
|
||||
expect(result.relativePath).toBe("skills/entity-framework-core/SKILL.md");
|
||||
expect(result.absolutePath.startsWith(`${pluginRoot}/`)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses only skillFiles[0] as the authoritative body path", () => {
|
||||
const result = resolvePluginSkillBodyPath(
|
||||
skill({
|
||||
skillFiles: [
|
||||
"skills/first/SKILL.md",
|
||||
"skills/second/SKILL.md",
|
||||
],
|
||||
}),
|
||||
pluginRoot,
|
||||
);
|
||||
|
||||
expect(result.relativePath).toBe("skills/first/SKILL.md");
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,11 @@ export {
|
||||
resolvePluginSkillEnabled,
|
||||
} from "./skill-settings.js";
|
||||
export type { SkillSettingState, SkillSettingsScope } from "./skill-settings.js";
|
||||
export {
|
||||
resolvePluginRootFromEntryPath,
|
||||
resolvePluginSkillBodyPath,
|
||||
} from "./plugin-skill-paths.js";
|
||||
export type { PluginSkillBodyPath } from "./plugin-skill-paths.js";
|
||||
export { redactSecrets } from "./redact-secrets.js";
|
||||
export {
|
||||
evaluatePromptCondition,
|
||||
|
||||
@@ -45,6 +45,7 @@ import { normalizePluginUiContributionDefinition, validatePluginManifest } from
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getCreateAiSessionFactory, getCreateInteractiveAiSessionFactory } from "./ai-engine-loader.js";
|
||||
import { scanPluginSecurity } from "./plugin-security-scan.js";
|
||||
import { resolvePluginRootFromEntryPath } from "./plugin-skill-paths.js";
|
||||
|
||||
// Minimum Fusion version for plugin compatibility checks (can be expanded later)
|
||||
const MINIMUM_FUSION_VERSION = "0.1.0";
|
||||
@@ -203,6 +204,9 @@ export class PluginLoader extends EventEmitter<{
|
||||
/** Cache of dynamically imported modules */
|
||||
private loadedModules: Map<string, unknown> = new Map();
|
||||
|
||||
/** Absolute plugin package roots keyed by plugin id. */
|
||||
private pluginRoots: Map<string, string> = new Map();
|
||||
|
||||
private readonly log = createLogger("plugin-loader");
|
||||
|
||||
constructor(private options: PluginLoaderOptions) {
|
||||
@@ -379,6 +383,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
// Update plugin state locally and store
|
||||
plugin.state = "started";
|
||||
this.plugins.set(pluginId, plugin);
|
||||
this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath));
|
||||
|
||||
// Call onLoad hook
|
||||
const ctx = await this.createContext(plugin);
|
||||
@@ -387,6 +392,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
} catch (loadErr) {
|
||||
// onLoad failed - clean up and propagate error
|
||||
this.plugins.delete(pluginId);
|
||||
this.pluginRoots.delete(pluginId);
|
||||
const errorMsg = loadErr instanceof Error ? loadErr.message : String(loadErr);
|
||||
await this.options.pluginStore.updatePluginState(
|
||||
pluginId,
|
||||
@@ -407,6 +413,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
// Ensure plugin is removed from loaded map on any failure
|
||||
// (it may have been added above before the onLoad hook)
|
||||
this.plugins.delete(pluginId);
|
||||
this.pluginRoots.delete(pluginId);
|
||||
|
||||
// Error isolation: set error state but don't crash
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -600,6 +607,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
|
||||
// Replace in plugins map
|
||||
this.plugins.set(pluginId, newPlugin);
|
||||
this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath));
|
||||
|
||||
// Create fresh context and call onLoad
|
||||
const ctx = await this.createContext(newPlugin);
|
||||
@@ -626,6 +634,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
try {
|
||||
// Restore old plugin
|
||||
this.plugins.set(pluginId, snapshot);
|
||||
this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath));
|
||||
|
||||
// Attempt to reactivate old plugin
|
||||
const ctx = await this.createContext(snapshot);
|
||||
@@ -647,6 +656,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
);
|
||||
|
||||
this.plugins.delete(pluginId);
|
||||
this.pluginRoots.delete(pluginId);
|
||||
|
||||
const originalError = err instanceof Error ? err.message : String(err);
|
||||
const rollbackError = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
||||
@@ -866,6 +876,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
|
||||
// Remove from loaded plugins
|
||||
this.plugins.delete(pluginId);
|
||||
this.pluginRoots.delete(pluginId);
|
||||
|
||||
// Invalidate module cache for clean re-import
|
||||
this.invalidateModuleCache(pluginPath);
|
||||
@@ -1310,13 +1321,16 @@ export class PluginLoader extends EventEmitter<{
|
||||
|
||||
/**
|
||||
* Get all skill contributions from loaded plugins.
|
||||
*
|
||||
* FNXC:PluginSkills 2026-07-12-00:00:
|
||||
* Plugin skill body resolution must honor skillFiles relative to the plugin package, so each contribution exposes the absolute pluginRoot alongside the SDK skill data. This is additive for old consumers and lets dashboard/session callers use the shared traversal-guarded resolver instead of guessing from the skill name.
|
||||
*/
|
||||
getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution }> {
|
||||
const skills: Array<{ pluginId: string; skill: PluginSkillContribution }> = [];
|
||||
getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution; pluginRoot?: string }> {
|
||||
const skills: Array<{ pluginId: string; skill: PluginSkillContribution; pluginRoot?: string }> = [];
|
||||
for (const [pluginId, plugin] of this.plugins) {
|
||||
if (plugin.skills) {
|
||||
for (const skill of plugin.skills) {
|
||||
skills.push({ pluginId, skill });
|
||||
skills.push({ pluginId, skill, pluginRoot: this.pluginRoots.get(pluginId) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
packages/core/src/plugin-skill-paths.ts
Normal file
58
packages/core/src/plugin-skill-paths.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import type { PluginSkillContribution } from "./plugin-types.js";
|
||||
|
||||
export interface PluginSkillBodyPath {
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
function normalizeSkillRelativePath(path: string): string {
|
||||
return path.trim().replaceAll("\\", "/").replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function isWithinRoot(root: string, candidate: string): boolean {
|
||||
const rootPath = resolve(root);
|
||||
const candidatePath = resolve(candidate);
|
||||
const rel = relative(rootPath, candidatePath);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
function resolveInsidePluginRoot(pluginRoot: string, relativePath: string): PluginSkillBodyPath | null {
|
||||
const normalizedRoot = resolve(pluginRoot);
|
||||
const normalizedRelativePath = normalizeSkillRelativePath(relativePath);
|
||||
if (!normalizedRelativePath) return null;
|
||||
const absolutePath = resolve(normalizedRoot, normalizedRelativePath);
|
||||
if (!isWithinRoot(normalizedRoot, absolutePath)) return null;
|
||||
return {
|
||||
absolutePath,
|
||||
relativePath: relative(normalizedRoot, absolutePath).split(sep).join("/"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:PluginSkills 2026-07-12-00:00:
|
||||
* PluginSkillContribution.skillFiles was declared in the public SDK but the host ignored it (GitHub #2018), which forced plugin authors to mirror skill names in a flat skills/<name>/SKILL.md layout. This resolver makes skillFiles[0] the authoritative plugin-root-relative body path, preserves the name-derived fallback for existing plugins, and rejects traversal so plugin skill bodies never resolve outside the plugin package.
|
||||
*/
|
||||
export function resolvePluginSkillBodyPath(
|
||||
skill: Pick<PluginSkillContribution, "name" | "skillFiles">,
|
||||
pluginRoot: string,
|
||||
): PluginSkillBodyPath {
|
||||
const declaredPath = skill.skillFiles?.[0];
|
||||
if (typeof declaredPath === "string" && declaredPath.trim().length > 0) {
|
||||
const declared = resolveInsidePluginRoot(pluginRoot, declaredPath);
|
||||
if (declared) return declared;
|
||||
}
|
||||
|
||||
const fallbackPath = `skills/${skill.name}/SKILL.md`;
|
||||
const fallback = resolveInsidePluginRoot(pluginRoot, fallbackPath);
|
||||
if (!fallback) {
|
||||
throw new Error(`Plugin skill body path for "${skill.name}" escapes plugin root: ${fallbackPath}`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function resolvePluginRootFromEntryPath(pluginEntryPath: string): string {
|
||||
const entryDir = dirname(resolve(pluginEntryPath));
|
||||
const dirName = entryDir.split(sep).pop();
|
||||
return dirName && ["dist", "build", "lib", "src"].includes(dirName) ? dirname(entryDir) : entryDir;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createSkillsAdapter, extractSkillName, computeSkillId, bareSkillName } from "../skills-adapter.js";
|
||||
import { resolvePluginSkillEnabled } from "@fusion/core";
|
||||
import { writeFile, mkdir, access, readFile, rm } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
@@ -801,6 +801,79 @@ describe("createSkillsAdapter - plugin skill merge", () => {
|
||||
expect(byName.get("ce-plan")!.id).toContain("::");
|
||||
});
|
||||
|
||||
it("honors plugin skillFiles in category subdirectories when pluginRoot is present", async () => {
|
||||
const pluginRoot = resolve("/tmp/fusion-plugin-compound-engineering");
|
||||
const adapter = createSkillsAdapter({
|
||||
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
|
||||
getSettingsPath: () => "/tmp/does-not-exist-settings.json",
|
||||
getPluginSkills: () => [
|
||||
{
|
||||
pluginId: "fusion-plugin-compound-engineering",
|
||||
pluginRoot,
|
||||
skill: {
|
||||
skillId: "entity-framework-core",
|
||||
name: "entity-framework-core",
|
||||
description: "EF Core guidance",
|
||||
skillFiles: ["skills/data/entity-framework-core/SKILL.md"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const skills = await adapter.discoverSkills("/tmp/project");
|
||||
const skill = skills.find((entry) => entry.name === "entity-framework-core")!;
|
||||
|
||||
expect(skill.relativePath).toBe("skills/data/entity-framework-core/SKILL.md");
|
||||
expect(skill.path).toBe(join(pluginRoot, "skills/data/entity-framework-core/SKILL.md"));
|
||||
});
|
||||
|
||||
it("keeps CE-style flat skillFiles on the previous name-derived path and id", async () => {
|
||||
const pluginRoot = resolve("/tmp/fusion-plugin-compound-engineering");
|
||||
const adapter = createSkillsAdapter({
|
||||
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
|
||||
getSettingsPath: () => "/tmp/does-not-exist-settings.json",
|
||||
getPluginSkills: () => [
|
||||
{
|
||||
pluginId: "fusion-plugin-compound-engineering",
|
||||
pluginRoot,
|
||||
skill: {
|
||||
skillId: "ce-plan",
|
||||
name: "ce-plan",
|
||||
description: "Plan work",
|
||||
skillFiles: ["skills/ce-plan/SKILL.md"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const cePlan = (await adapter.discoverSkills("/tmp/project")).find((entry) => entry.name === "ce-plan")!;
|
||||
|
||||
expect(cePlan.relativePath).toBe("skills/ce-plan/SKILL.md");
|
||||
expect(cePlan.id).toBe(computeSkillId("plugin:fusion-plugin-compound-engineering", "skills/ce-plan/SKILL.md"));
|
||||
});
|
||||
|
||||
it("keeps the name-derived relative path when pluginRoot is missing", async () => {
|
||||
const adapter = createSkillsAdapter({
|
||||
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
|
||||
getSettingsPath: () => "/tmp/does-not-exist-settings.json",
|
||||
getPluginSkills: () => [
|
||||
{
|
||||
pluginId: "legacy-plugin",
|
||||
skill: {
|
||||
skillId: "entity-framework-core",
|
||||
name: "entity-framework-core",
|
||||
skillFiles: ["skills/data/entity-framework-core/SKILL.md"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const skill = (await adapter.discoverSkills("/tmp/project")).find((entry) => entry.name === "entity-framework-core")!;
|
||||
|
||||
expect(skill.relativePath).toBe("skills/entity-framework-core/SKILL.md");
|
||||
expect(skill.path).toBe("skills/entity-framework-core/SKILL.md");
|
||||
});
|
||||
|
||||
it("passes the requesting project root into async plugin-skill discovery", async () => {
|
||||
const daemonRoot = "/tmp/daemon-root";
|
||||
const projectRoot = "/tmp/managed-project";
|
||||
|
||||
@@ -1055,7 +1055,7 @@ export class ChatManager {
|
||||
FNXC:ChatSkills 2026-06-16-19:10:
|
||||
Agent chat receives the project plugin runner through this narrow structural type, so expose enabled plugin skill contributions here without requiring dashboard code to depend on the full engine runner class.
|
||||
*/
|
||||
getPluginSkills?(): Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }>;
|
||||
getPluginSkills?(): Array<{ pluginId: string; pluginRoot?: string; skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] } }>;
|
||||
},
|
||||
private getSettings?: () => Promise<Pick<Settings,
|
||||
| "fallbackProvider"
|
||||
|
||||
@@ -352,7 +352,7 @@ export interface ServerOptions {
|
||||
FNXC:ChatSkills 2026-06-16-19:10:
|
||||
The dashboard passes this structural runner into ChatManager, which needs optional plugin skill discovery so chat can load enabled plugin skills such as ce-debug.
|
||||
*/
|
||||
getPluginSkills?(): Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }>;
|
||||
getPluginSkills?(): Array<{ pluginId: string; pluginRoot?: string; skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] } }>;
|
||||
reloadPlugin?(pluginId: string): Promise<unknown>;
|
||||
checkPluginSetup?(pluginId: string): Promise<import("@fusion/core").PluginSetupCheckResult>;
|
||||
installPluginSetup?(pluginId: string): Promise<void | { success: boolean; error?: string }>;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getSkillSettingState,
|
||||
normalizeStoredSkillPath,
|
||||
parseSkillId,
|
||||
resolvePluginSkillBodyPath,
|
||||
resolvePluginSkillEnabled,
|
||||
superviseSpawn,
|
||||
} from "@fusion/core";
|
||||
@@ -284,11 +285,13 @@ export function createSkillsAdapter(options: {
|
||||
getPluginSkills?: (rootDir: string) =>
|
||||
| Array<{
|
||||
pluginId: string;
|
||||
skill: { name: string; description?: string; enabled?: boolean };
|
||||
pluginRoot?: string;
|
||||
skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] };
|
||||
}>
|
||||
| Promise<Array<{
|
||||
pluginId: string;
|
||||
skill: { name: string; description?: string; enabled?: boolean };
|
||||
pluginRoot?: string;
|
||||
skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] };
|
||||
}>>;
|
||||
/** Optional superviseSpawn seam for tests */
|
||||
superviseSpawn?: typeof superviseSpawn;
|
||||
@@ -347,17 +350,23 @@ export function createSkillsAdapter(options: {
|
||||
/*
|
||||
* FNXC:PluginSkills 2026-07-10-00:00:
|
||||
* Skill discovery is project-scoped: plugin contributions must be resolved for the requesting rootDir's project_plugin_states, not the daemon startup directory. This keeps /api/skills/discovered from leaking daemon-root plugin skills into unrelated projects while still surfacing skills enabled only for the requested managed project.
|
||||
*
|
||||
* FNXC:PluginSkills 2026-07-12-00:00:
|
||||
* Plugin skill body paths now come from skillFiles (GitHub #2018) through @fusion/core's traversal-guarded resolver when pluginRoot is available. Discovered plugin skill path is the absolute on-disk SKILL.md location for FN-7857 consumers, while missing pluginRoot keeps the old name-derived relative path for compatibility.
|
||||
*/
|
||||
const pluginSkills = await (options.getPluginSkills?.(rootDir) ?? []);
|
||||
if (pluginSkills.length > 0) {
|
||||
const seenBareNames = new Set(discoveredSkills.map((s) => bareSkillName(s.name)));
|
||||
for (const { pluginId, skill } of pluginSkills) {
|
||||
for (const { pluginId, pluginRoot, 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 resolvedBodyPath = pluginRoot
|
||||
? resolvePluginSkillBodyPath({ name, skillFiles: skill.skillFiles ?? [] }, pluginRoot)
|
||||
: null;
|
||||
const relativePath = resolvedBodyPath?.relativePath ?? `skills/${name}/SKILL.md`;
|
||||
const id = computeSkillId(`plugin:${pluginId}`, relativePath);
|
||||
const enabled = resolvePluginSkillEnabled(
|
||||
settings as Parameters<typeof resolvePluginSkillEnabled>[0],
|
||||
@@ -368,7 +377,7 @@ export function createSkillsAdapter(options: {
|
||||
discoveredSkills.push({
|
||||
id,
|
||||
name,
|
||||
path: relativePath,
|
||||
path: resolvedBodyPath?.absolutePath ?? relativePath,
|
||||
relativePath,
|
||||
enabled,
|
||||
description: skill.description,
|
||||
|
||||
@@ -894,7 +894,7 @@ describe("PluginRunner", () => {
|
||||
});
|
||||
|
||||
it("getPluginSkills returns cached skills after init", async () => {
|
||||
const skills = [{ pluginId: "test-plugin", skill: { skillId: "s1", name: "Skill", description: "d", skillFiles: ["./skill.md"] } }];
|
||||
const skills = [{ pluginId: "test-plugin", pluginRoot: "/tmp/test-plugin", skill: { skillId: "s1", name: "Skill", description: "d", skillFiles: ["./skill.md"] } }];
|
||||
mockPluginLoader.getPluginSkills.mockReturnValue(skills);
|
||||
await pluginRunner.init();
|
||||
const first = pluginRunner.getPluginSkills();
|
||||
|
||||
@@ -124,7 +124,7 @@ interface CachedCliProviderContributions {
|
||||
}
|
||||
|
||||
interface CachedSkills {
|
||||
skills: Array<{ pluginId: string; skill: PluginSkillContribution }>;
|
||||
skills: Array<{ pluginId: string; skill: PluginSkillContribution; pluginRoot?: string }>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ export class PluginRunner {
|
||||
return this.cachedCliProviderContributions.contributions;
|
||||
}
|
||||
|
||||
getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution }> {
|
||||
getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution; pluginRoot?: string }> {
|
||||
if (!this.cachedSkills || this.cachedSkills.version !== this.skillsCacheVersion) {
|
||||
this.cachedSkills = {
|
||||
skills: this.options.pluginLoader.getPluginSkills(),
|
||||
|
||||
Reference in New Issue
Block a user