Files
fusion/packages/core/src/skill-settings.ts
gsxdsm 49a459a869 FN-7954: fix plugin skill toggle keys for custom skillFiles paths
Align plugin skill enable/disable reads with the resolved skillFiles path so Skills-view toggles persist and sessions honor them.

- Accept optional skillRelativePath in resolvePluginSkillEnabled for custom skillFiles keys
- Pass resolved relativePath from dashboard skills adapter when merging plugin skills
- Reuse resolved body path in session-skill-context for enable checks and additionalSkillPaths
- Add unit coverage for custom-path round-trip, enable, and disable behavior
- Ship patch changeset for @runfusion/fusion

Files changed:
 .changeset/fn-7954-plugin-skill-toggle-key-fix.md  |  7 +++
 packages/core/src/__tests__/skill-settings.test.ts | 15 ++++++
 packages/core/src/skill-settings.ts                |  8 +++-
 .../dashboard/src/__tests__/skills-adapter.test.ts | 55 ++++++++++++++++++++++
 packages/dashboard/src/skills-adapter.ts           |  1 +
 .../src/__tests__/session-skill-context.test.ts    | 45 ++++++++++++++++++
 packages/engine/src/session-skill-context.ts       | 29 +++++++-----
 7 files changed, 147 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-7954

Fusion-Task-Lineage: 3399186b-7325-4ed7-8900-85eb2ef98c7e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-15 10:38:04 -07:00

109 lines
3.9 KiB
TypeScript

export type SkillSettingState = "enabled" | "disabled";
export interface SkillSettingsScope {
skills?: string[];
packages?: Array<string | { source: string; skills?: string[] }>;
}
/**
* Compute deterministic skill ID from metadata.
* Format: encodeURIComponent(metadata.source) + "::" + relativePath
*
* @param source - The package source identifier
* @param relativePath - Path relative to the skill directory
* @returns Deterministic skill ID
*/
export function computeSkillId(source: string, relativePath: string): string {
const normalizedPath = relativePath.replaceAll("\\", "/");
return `${encodeURIComponent(source)}::${normalizedPath}`;
}
/**
* Parse a skill ID back into source and relativePath components.
*/
export function parseSkillId(skillId: string): { source: string; relativePath: string } | null {
const parts = skillId.split("::");
if (parts.length !== 2) return null;
try {
return {
source: decodeURIComponent(parts[0]!),
relativePath: parts[1]!,
};
} catch {
return null;
}
}
export function normalizeStoredSkillPath(path: string): string {
return path.replaceAll("\\", "/").replace(/^skills\//, "");
}
function settingEntryState(entry: string): SkillSettingState {
return entry.startsWith("+") ? "enabled" : "disabled";
}
/**
* FNXC:PluginSkills 2026-07-12-00:00:
* Plugin-skill effective enablement must have one resolver shared by dashboard discovery and engine session assembly. FN-7858 fixed drift where Skills view honored per-project toggles but collectPluginSkillNames only used static plugin defaults.
*/
export function getSkillSettingState(
skillId: string,
settings: SkillSettingsScope,
): SkillSettingState | undefined {
const parsedSkillId = parseSkillId(skillId);
if (!parsedSkillId) {
return undefined;
}
const normalizedSkillPath = normalizeStoredSkillPath(parsedSkillId.relativePath);
const skills = settings.skills ?? [];
for (const entry of skills) {
if (typeof entry !== "string") continue;
const entryPath = normalizeStoredSkillPath(
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
);
const entryId = computeSkillId("*", `skills/${entryPath}`);
if (entryId === skillId || entryPath === normalizedSkillPath) {
return settingEntryState(entry);
}
}
const packages = settings.packages ?? [];
for (const pkg of packages) {
const source = typeof pkg === "string" ? pkg : pkg.source;
const pkgSkills = typeof pkg === "object" && pkg !== null ? pkg.skills : undefined;
if (!pkgSkills) continue;
for (const entry of pkgSkills) {
if (typeof entry !== "string") continue;
const entryPath = normalizeStoredSkillPath(
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
);
const entryId = computeSkillId(source, `skills/${entryPath}`);
if (entryId === skillId || entryPath === normalizedSkillPath) {
return settingEntryState(entry);
}
}
}
return undefined;
}
/**
* FNXC:PluginSkills 2026-07-14-00:00:
* FN-7954 closes the plugin-skill toggle write/read key-schema mismatch for custom skillFiles paths. Dashboard toggles persist entries under the resolved plugin-root-relative SKILL.md path, so read paths must pass that same relative path when it is known; omitting skillRelativePath intentionally preserves the legacy name-derived skills/<name>/SKILL.md lookup for callers without a pluginRoot.
*/
export function resolvePluginSkillEnabled(
settings: SkillSettingsScope,
pluginId: string,
skillName: string,
staticEnabled: boolean | undefined,
skillRelativePath?: string,
): boolean {
const relativePath = skillRelativePath ?? `skills/${skillName}/SKILL.md`;
const skillId = computeSkillId(`plugin:${pluginId}`, relativePath);
const settingState = getSkillSettingState(skillId, settings);
return settingState === undefined ? staticEnabled !== false : settingState === "enabled";
}