FN-5853: persist skills toggle state in dashboard

Make skills enabled and disabled toggles persist across dashboard refreshes.

- normalize stored skill paths before matching or writing skill toggle entries
- preserve enabled and disabled state for both top-level and package-scoped skills
- add round-trip adapter tests covering settings persistence and rediscovery

Files changed:
 .changeset/fn-5853-skills-persistence.md           |   5 +
 .../dashboard/src/__tests__/skills-adapter.test.ts | 116 ++++++++++++++++++++-
 packages/dashboard/src/skills-adapter.ts           |  29 ++++--
 3 files changed, 140 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-5853

Fusion-Task-Lineage: 7b9df0ae-7db7-4c3b-93c6-3ceedfad4caa
This commit is contained in:
gsxdsm
2026-06-01 23:01:26 -07:00
parent 41c891d7d8
commit e6ce50033b
3 changed files with 140 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix the dashboard skills interface so enabled and disabled skill toggles persist across refreshes for both top-level and package-scoped skills. The adapter now normalizes stored skill paths consistently when writing settings and when rediscovering installed skills.

View File

@@ -1,9 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createSkillsAdapter, extractSkillName } from "../skills-adapter.js";
import { writeFile, mkdir, access } from "node:fs/promises";
import { createSkillsAdapter, extractSkillName, computeSkillId } 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";
import { rm } from "node:fs/promises";
describe("createSkillsAdapter - fetchCatalog fallback behavior", () => {
const originalFetch = globalThis.fetch;
@@ -532,6 +531,117 @@ describe("createSkillsAdapter - readSkillContent", () => {
});
});
describe("createSkillsAdapter - toggleExecutionSkill persistence", () => {
async function createRoundTripFixture(source: string) {
const rootDir = join(tmpdir(), `skills-adapter-${Date.now()}-${Math.random().toString(36).slice(2)}`);
const baseDir = source === "*" ? rootDir : join(rootDir, "packages", "example-package");
const relativePath = "skills/test-skill/SKILL.md";
const absolutePath = join(baseDir, relativePath);
const settingsPath = join(rootDir, ".fusion", "settings.json");
await mkdir(dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, "# Test Skill\n", "utf-8");
const adapter = createSkillsAdapter({
packageManager: {
resolve: vi.fn().mockResolvedValue({
skills: [
{
path: absolutePath,
enabled: false,
metadata: {
source,
scope: "project" as const,
origin: source === "*" ? "top-level" as const : "package" as const,
baseDir,
},
},
],
}),
},
getSettingsPath: vi.fn().mockReturnValue(settingsPath),
});
return {
adapter,
rootDir,
settingsPath,
skillId: computeSkillId(source, relativePath),
cleanup: () => rm(rootDir, { recursive: true, force: true }),
};
}
it.each([
{ source: "*", settingsKey: "skills", expectedPattern: "+test-skill/SKILL.md" },
{ source: "@scope/pkg", settingsKey: "packages[].skills", expectedPattern: "+test-skill/SKILL.md" },
])("round-trips enabled skills for source $source", async ({ source, settingsKey, expectedPattern }) => {
const fixture = await createRoundTripFixture(source);
try {
const result = await fixture.adapter.toggleExecutionSkill(fixture.rootDir, {
skillId: fixture.skillId,
enabled: true,
});
expect(result.pattern).toBe(expectedPattern);
expect(result.settingsPath).toBe(settingsKey);
const settings = JSON.parse(await readFile(fixture.settingsPath, "utf-8")) as {
skills?: string[];
packages?: Array<{ source: string; skills?: string[] }>;
};
if (source === "*") {
expect(settings.skills).toContain(expectedPattern);
} else {
expect(settings.packages).toContainEqual({ source, skills: [expectedPattern] });
}
const discovered = await fixture.adapter.discoverSkills(fixture.rootDir);
expect(discovered).toContainEqual(
expect.objectContaining({ id: fixture.skillId, enabled: true }),
);
} finally {
await fixture.cleanup();
}
});
it.each([
{ source: "*", settingsKey: "skills", expectedPattern: "-test-skill/SKILL.md" },
{ source: "@scope/pkg", settingsKey: "packages[].skills", expectedPattern: "-test-skill/SKILL.md" },
])("round-trips disabled skills for source $source", async ({ source, settingsKey, expectedPattern }) => {
const fixture = await createRoundTripFixture(source);
try {
const result = await fixture.adapter.toggleExecutionSkill(fixture.rootDir, {
skillId: fixture.skillId,
enabled: false,
});
expect(result.pattern).toBe(expectedPattern);
expect(result.settingsPath).toBe(settingsKey);
const settings = JSON.parse(await readFile(fixture.settingsPath, "utf-8")) as {
skills?: string[];
packages?: Array<{ source: string; skills?: string[] }>;
};
if (source === "*") {
expect(settings.skills).toContain(expectedPattern);
} else {
expect(settings.packages).toContainEqual({ source, skills: [expectedPattern] });
}
const discovered = await fixture.adapter.discoverSkills(fixture.rootDir);
expect(discovered).toContainEqual(
expect.objectContaining({ id: fixture.skillId, enabled: false }),
);
} finally {
await fixture.cleanup();
}
});
});
describe("extractSkillName", () => {
it("normalizes Windows separators before deriving the display name", () => {
expect(extractSkillName("skills\\tooling\\windows-fix", "npm")).toBe("tooling/windows-fix");

View File

@@ -189,6 +189,10 @@ export function parseSkillId(skillId: string): { source: string; relativePath: s
}
}
function normalizeStoredSkillPath(path: string): string {
return path.replaceAll("\\", "/").replace(/^skills\//, "");
}
/**
* Check if a skill path is enabled in the settings.
* Checks both top-level skills and package-scoped skills.
@@ -198,11 +202,20 @@ function isSkillEnabled(
settings: { skills?: string[]; packages?: Array<{ source: string; skills?: string[] }> },
): boolean {
// Check top-level skills
const parsedSkillId = parseSkillId(skillId);
if (!parsedSkillId) {
return false;
}
const normalizedSkillPath = normalizeStoredSkillPath(parsedSkillId.relativePath);
const skills = settings.skills ?? [];
for (const entry of skills) {
const entryPath = entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
const entryId = computeSkillId("*", entryPath);
if (entryId === skillId) {
const entryPath = normalizeStoredSkillPath(
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
);
const entryId = computeSkillId("*", `skills/${entryPath}`);
if (entryId === skillId || entryPath === normalizedSkillPath) {
return entry.startsWith("+");
}
}
@@ -215,9 +228,11 @@ function isSkillEnabled(
if (!pkgSkills) continue;
for (const entry of pkgSkills) {
const entryPath = entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
const entryId = computeSkillId(source, entryPath);
if (entryId === skillId) {
const entryPath = normalizeStoredSkillPath(
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
);
const entryId = computeSkillId(source, `skills/${entryPath}`);
if (entryId === skillId || entryPath === normalizedSkillPath) {
return entry.startsWith("+");
}
}
@@ -333,7 +348,7 @@ export function createSkillsAdapter(options: {
}
const isTopLevel = source === "*";
const skillPath = relativePath.replace(/^skills\//, "");
const skillPath = normalizeStoredSkillPath(relativePath);
if (isTopLevel) {
// Toggle in top-level skills