Files
fusion/packages/dashboard/src/skills-adapter.ts
gsxdsm cecdf48c07 Update packages/dashboard/src/skills-adapter.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-04 07:14:37 -07:00

840 lines
24 KiB
TypeScript

/**
* Skills runtime adapter for fn dashboard.
*
* Provides skills discovery, execution toggling, and catalog fetching capabilities
* by integrating with the pi-coding-agent package manager and skills.sh API.
*/
import { access, readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
import { join, relative, dirname } from "node:path";
/**
* Check if a path exists asynchronously using access().
* Preferred over existsSync() to avoid blocking the Node event loop.
*/
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
/**
* Minimal interface matching pi-coding-agent's PathMetadata.
* Duplicated here to avoid direct dependency on the pi-coding-agent package in the dashboard.
*/
interface PathMetadata {
source: string;
scope: "user" | "project" | "temporary";
origin: "package" | "top-level";
baseDir?: string;
}
/**
* Minimal interface matching pi-coding-agent's ResolvedResource.
* Duplicated here to avoid direct dependency on the pi-coding-agent package in the dashboard.
*/
interface ResolvedResource {
path: string;
enabled: boolean;
metadata: PathMetadata;
}
/**
* Discovered skill with computed metadata.
*/
export interface DiscoveredSkill {
id: string;
name: string;
path: string;
relativePath: string;
enabled: boolean;
metadata: {
source: string;
scope: "user" | "project" | "temporary";
origin: "package" | "top-level";
baseDir?: string;
};
}
/**
* Catalog entry from skills.sh.
*/
export interface CatalogEntry {
id: string;
slug: string;
name: string;
description?: string;
repo?: string;
npmPackage?: string;
tags?: string[];
installs?: number;
installation: {
installed: boolean;
matchingSkillIds: string[];
matchingPaths: string[];
};
}
/**
* Result of fetching the skills catalog.
*/
export interface CatalogFetchResult {
entries: CatalogEntry[];
auth: {
mode: "authenticated" | "unauthenticated" | "fallback-unauthenticated";
tokenPresent: boolean;
fallbackUsed: boolean;
};
}
/**
* Toggle execution skill result.
*/
export interface ToggleSkillResult {
settingsPath: "skills" | "packages[].skills";
pattern: string;
targetFile: string;
}
/**
* A file entry in a skill directory.
*/
export interface SkillFileEntry {
name: string;
relativePath: string;
type: "file" | "directory";
}
/**
* Content of a skill including its SKILL.md and supplementary files.
*/
export interface SkillContent {
name: string;
skillMd: string;
files: SkillFileEntry[];
}
/**
* Upstream error codes for catalog fetch failures.
*/
export type UpstreamErrorCode = "upstream_timeout" | "upstream_http_error" | "upstream_invalid_payload";
/**
* Upstream error with code.
*/
export interface UpstreamError {
error: string;
code: UpstreamErrorCode;
}
/**
* Skills adapter interface exposed via ServerOptions.
*/
export interface SkillsAdapter {
/**
* Discover all skills available in the project.
* Combines top-level skills and package-scoped skills.
*/
discoverSkills(rootDir: string): Promise<DiscoveredSkill[]>;
/**
* Toggle a skill's enabled/disabled state.
* Updates project settings and returns persistence info.
*/
toggleExecutionSkill(
rootDir: string,
input: { skillId: string; enabled: boolean },
): Promise<ToggleSkillResult>;
/**
* Fetch the skills.sh catalog with optional authentication.
*/
fetchCatalog(input: { limit: number; query?: string }): Promise<CatalogFetchResult | UpstreamError>;
/**
* Read the contents of a skill's SKILL.md file and list supplementary files.
*/
readSkillContent(rootDir: string, skillId: string): Promise<SkillContent>;
}
/**
* 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;
}
}
/**
* Check if a skill path is enabled in the settings.
* Checks both top-level skills and package-scoped skills.
*/
function isSkillEnabled(
skillId: string,
settings: { skills?: string[]; packages?: Array<{ source: string; skills?: string[] }> },
): boolean {
// Check top-level skills
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) {
return entry.startsWith("+");
}
}
// Check package-scoped skills
const packages = settings.packages ?? [];
for (const pkg of packages) {
const source = typeof pkg === "string" ? pkg : pkg.source;
const pkgSkills = typeof pkg === "object" ? pkg.skills : undefined;
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) {
return entry.startsWith("+");
}
}
}
// Default to disabled if not found
return false;
}
/**
* Create the skills adapter implementation.
*/
export function createSkillsAdapter(options: {
/** Package manager for skill resolution */
packageManager: {
resolve(onMissing?: (source: string) => Promise<unknown>): Promise<{
skills: ResolvedResource[];
[key: string]: ResolvedResource[];
}>;
};
/** Project settings path helper */
getSettingsPath: (rootDir: string) => string;
}): SkillsAdapter {
return {
async discoverSkills(rootDir: string): Promise<DiscoveredSkill[]> {
// Resolve all resources including skills
const resolved = await options.packageManager.resolve();
const skillResources = resolved.skills ?? [];
// Load current settings to check enabled state
const settingsPath = options.getSettingsPath(rootDir);
let settings: { skills?: string[]; packages?: unknown[] } = {};
if (await pathExists(settingsPath)) {
try {
settings = JSON.parse(await readFile(settingsPath, "utf-8")) as typeof settings;
} catch {
// Ignore parse errors
}
}
const discoveredSkills: DiscoveredSkill[] = [];
for (const resource of skillResources) {
// Compute relative path for the skill.
// Guard against baseDir being the parent of the skills directory,
// which causes relative() to already include "skills/" in the result.
const relPath = relative(resource.metadata.baseDir ?? "", resource.path)
.replaceAll("\\", "/");
const skillRelativePath = relPath.startsWith("skills/") ? relPath : `skills/${relPath}`;
const skillId = computeSkillId(resource.metadata.source, skillRelativePath);
const skillName = extractSkillName(skillRelativePath, resource.metadata.source);
discoveredSkills.push({
id: skillId,
name: skillName,
path: resource.path,
relativePath: skillRelativePath,
enabled: isSkillEnabled(skillId, settings as Parameters<typeof isSkillEnabled>[1]),
metadata: {
source: resource.metadata.source,
scope: resource.metadata.scope,
origin: resource.metadata.origin,
baseDir: resource.metadata.baseDir,
},
});
}
return discoveredSkills;
},
async toggleExecutionSkill(
rootDir: string,
input: { skillId: string; enabled: boolean },
): Promise<ToggleSkillResult> {
const { skillId, enabled } = input;
const parsed = parseSkillId(skillId);
if (!parsed) {
throw new Error(`Invalid skill ID format: ${skillId}`);
}
const { source, relativePath } = parsed;
// Validate that the skill exists in discovered skills
const discovered = await this.discoverSkills(rootDir);
const skillExists = discovered.some((s) => s.id === skillId);
if (!skillExists) {
throw new Error(`Skill not found: ${skillId}`);
}
// Load settings
const settingsPath = options.getSettingsPath(rootDir);
const settingsDir = dirname(settingsPath);
if (!await pathExists(settingsDir)) {
await mkdir(settingsDir, { recursive: true });
}
let settings: Record<string, unknown> = {};
if (await pathExists(settingsPath)) {
try {
settings = JSON.parse(await readFile(settingsPath, "utf-8")) as Record<string, unknown>;
} catch {
// Start fresh on parse error
}
}
// Ensure skills and packages arrays exist
if (!Array.isArray(settings.skills)) {
settings.skills = [];
}
if (!Array.isArray(settings.packages)) {
settings.packages = [];
}
const isTopLevel = source === "*";
const skillPath = relativePath.replace(/^skills\//, "");
if (isTopLevel) {
// Toggle in top-level skills
const skills = settings.skills as string[];
const prefix = enabled ? "+" : "-";
// Remove any existing entry for this path (both + and -)
const existingIdx = skills.findIndex((s) => {
const p = s.startsWith("+") || s.startsWith("-") ? s.slice(1) : s;
return p === skillPath;
});
if (existingIdx !== -1) {
skills.splice(existingIdx, 1);
}
// Add the new entry
skills.push(`${prefix}${skillPath}`);
settings.skills = skills;
await writeFile(settingsPath, JSON.stringify(settings, null, 2));
return {
settingsPath: "skills",
pattern: `${prefix}${skillPath}`,
targetFile: settingsPath,
};
} else {
// Toggle in package-scoped skills
const packages = settings.packages as Array<{ source: string; skills?: string[] }>;
const prefix = enabled ? "+" : "-";
// Find or create the package entry
let pkgEntry = packages.find((p) => {
const pkgSource = typeof p === "string" ? p : p.source;
return pkgSource === source;
});
if (!pkgEntry) {
// Create new package entry as object
pkgEntry = { source, skills: [] };
packages.push(pkgEntry);
} else if (typeof pkgEntry === "string") {
// Convert string entry to object, preserving the source string value
const idx = packages.indexOf(pkgEntry);
pkgEntry = { source, skills: [] };
packages[idx] = pkgEntry;
} else {
// pkgEntry is already an object - ensure skills array exists
// and preserve other fields like extensions, prompts, themes
if (!Array.isArray(pkgEntry.skills)) {
pkgEntry.skills = [];
}
}
// Ensure skills array exists
if (!Array.isArray(pkgEntry.skills)) {
pkgEntry.skills = [];
}
// Remove any existing entry for this path
const existingIdx = pkgEntry.skills.findIndex((s) => {
const p = s.startsWith("+") || s.startsWith("-") ? s.slice(1) : s;
return p === skillPath;
});
if (existingIdx !== -1) {
pkgEntry.skills.splice(existingIdx, 1);
}
// Add the new entry
pkgEntry.skills.push(`${prefix}${skillPath}`);
settings.packages = packages;
await writeFile(settingsPath, JSON.stringify(settings, null, 2));
return {
settingsPath: "packages[].skills",
pattern: `${prefix}${skillPath}`,
targetFile: settingsPath,
};
}
},
async fetchCatalog(input: { limit: number; query?: string }): Promise<CatalogFetchResult | UpstreamError> {
const { limit, query } = input;
const boundedLimit = Math.min(Math.max(1, limit), 100);
// Get skills.sh token if available
const token = process.env.SKILLS_SH_TOKEN;
const catalogUrl = buildCatalogUrl(boundedLimit, query);
const publicSearchQuery = getPublicSearchQuery(query);
// No token available - use public search endpoint for valid search queries only.
// Empty/short queries return a deterministic empty catalog result.
if (!token) {
if (!publicSearchQuery) {
return buildEmptyCatalogResult({
mode: "unauthenticated",
tokenPresent: false,
fallbackUsed: false,
});
}
return fetchPublicCatalog(boundedLimit, publicSearchQuery, {
mode: "unauthenticated",
tokenPresent: false,
fallbackUsed: false,
});
}
// Try authenticated v1 catalog endpoint first
try {
const authResponse = await fetch(catalogUrl, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(10_000),
});
if (authResponse.ok) {
const data = await authResponse.json().catch(() => null);
if (data) {
return normalizeCatalogResponse(data, false);
}
return {
error: "Invalid upstream response format",
code: "upstream_invalid_payload",
};
}
// 400/401/403 from authenticated request - fall back to public search endpoint
if (authResponse.status === 400 || authResponse.status === 401 || authResponse.status === 403) {
if (!publicSearchQuery) {
return buildEmptyCatalogResult({
mode: "fallback-unauthenticated",
tokenPresent: true,
fallbackUsed: true,
});
}
return fetchPublicCatalog(boundedLimit, publicSearchQuery, {
mode: "fallback-unauthenticated",
tokenPresent: true,
fallbackUsed: true,
});
}
// Upstream error
return {
error: `Upstream returned ${authResponse.status}: ${authResponse.statusText}`,
code: "upstream_http_error",
};
} catch (err) {
const error = err as Error;
if (isTimeoutError(error)) {
return { error: "Upstream request timed out", code: "upstream_timeout" };
}
return {
error: error.message || "Upstream request failed",
code: "upstream_http_error",
};
}
},
async readSkillContent(rootDir: string, skillId: string): Promise<SkillContent> {
const parsed = parseSkillId(skillId);
if (!parsed) {
throw new Error(`Invalid skill ID format: ${skillId}`);
}
const discovered = await this.discoverSkills(rootDir);
const skill = discovered.find((entry) => entry.id === skillId);
if (!skill) {
throw new Error(`Skill not found: ${skillId}`);
}
let skillDir = skill.path;
try {
const skillPathStat = await stat(skill.path);
skillDir = skillPathStat.isFile() ? dirname(skill.path) : skill.path;
} catch {
skillDir = dirname(skill.path);
}
const skillMdPath = join(skillDir, "SKILL.md");
let skillMd = "";
try {
skillMd = await readFile(skillMdPath, "utf-8");
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== "ENOENT") {
throw error;
}
}
const entries = await readdir(skillDir, { withFileTypes: true }).catch(() => []);
const files: SkillFileEntry[] = entries
.filter((entry) => entry.name !== "SKILL.md")
.map((entry) => ({
name: entry.name,
relativePath: entry.name,
type: entry.isDirectory() ? "directory" : "file",
}));
return {
name: skill.name,
skillMd,
files,
};
},
};
}
/**
* Extract skill name from path and source.
*/
export function extractSkillName(skillPath: string, source: string): string {
// Get the last two path components (category/name or just name)
const parts = skillPath.replace(/\\/g, "/").split("/").filter(Boolean);
if (parts.length >= 2) {
// Return last two parts joined
return parts.slice(-2).join("/");
}
if (parts.length === 1) {
return parts[0]!;
}
// Fallback to source
return source;
}
/**
* Build the authenticated catalog URL.
*/
function buildCatalogUrl(limit: number, query?: string): string {
const params = new URLSearchParams();
params.set("limit", String(limit));
const normalizedQuery = query?.trim();
if (normalizedQuery) {
params.set("q", normalizedQuery);
}
return `https://skills.sh/api/v1/skills?${params.toString()}`;
}
/**
* Build the public search URL.
*
* The search API requires a non-empty query that meets minimum length.
*/
function buildSearchUrl(limit: number, query: string): string {
const params = new URLSearchParams();
params.set("q", query);
params.set("limit", String(limit));
return `https://skills.sh/api/search?${params.toString()}`;
}
/**
* Fetch and normalize catalog data from the public /api/search endpoint.
*/
const MIN_PUBLIC_SEARCH_QUERY_LENGTH = 2;
function getPublicSearchQuery(query?: string): string | null {
const normalized = query?.trim() ?? "";
return normalized.length >= MIN_PUBLIC_SEARCH_QUERY_LENGTH ? normalized : null;
}
function buildEmptyCatalogResult(auth: CatalogFetchResult["auth"]): CatalogFetchResult {
return {
entries: [],
auth,
};
}
async function fetchPublicCatalog(
limit: number,
query: string,
auth: CatalogFetchResult["auth"],
): Promise<CatalogFetchResult | UpstreamError> {
const url = buildSearchUrl(limit, query);
try {
const response = await fetch(url, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
return {
error: `Upstream returned ${response.status}: ${response.statusText}`,
code: "upstream_http_error",
};
}
const data = await response.json().catch(() => null);
if (!data) {
return {
error: "Invalid upstream response format",
code: "upstream_invalid_payload",
};
}
return normalizeSearchResponse(data, auth);
} catch (err) {
const error = err as Error;
if (isTimeoutError(error)) {
return { error: "Upstream request timed out", code: "upstream_timeout" };
}
return {
error: error.message || "Upstream request failed",
code: "upstream_http_error",
};
}
}
/**
* Normalize public search endpoint response shape to CatalogFetchResult.
*/
function normalizeSearchResponse(
data: unknown,
auth: CatalogFetchResult["auth"],
): CatalogFetchResult | UpstreamError {
if (!data || typeof data !== "object") {
return {
error: "Invalid upstream response format",
code: "upstream_invalid_payload",
};
}
const record = data as Record<string, unknown>;
if (!Array.isArray(record.skills)) {
return {
error: "Invalid upstream response format: expected { skills: [...] }",
code: "upstream_invalid_payload",
};
}
return {
entries: record.skills.map(normalizeSearchEntry),
auth,
};
}
/**
* Normalize a single /api/search skill entry to CatalogEntry.
*/
function normalizeSearchEntry(entry: unknown): CatalogEntry {
if (!entry || typeof entry !== "object") {
return {
id: "",
slug: "",
name: "Unknown",
installation: { installed: false, matchingSkillIds: [], matchingPaths: [] },
};
}
const record = entry as Record<string, unknown>;
const id = String(record.id ?? record.skillId ?? "");
const nameCandidate = String(record.name ?? record.skillId ?? id);
return {
id,
slug: id,
name: nameCandidate || "Unknown",
repo: record.source ? String(record.source) : undefined,
installs: typeof record.installs === "number" ? record.installs : undefined,
installation: {
installed: false,
matchingSkillIds: [],
matchingPaths: [],
},
};
}
function isTimeoutError(error: Error): boolean {
const message = error.message?.toLowerCase() ?? "";
return error.name === "TimeoutError" || message.includes("timeout");
}
/**
* Normalize catalog response to handle both array and wrapped formats.
*/
function normalizeCatalogResponse(
data: unknown,
fallbackUsed: boolean,
): CatalogFetchResult | UpstreamError {
if (!data || typeof data !== "object") {
return {
error: "Invalid upstream response format",
code: "upstream_invalid_payload",
};
}
// Handle array format
if (Array.isArray(data)) {
return {
entries: data.map(normalizeEntry),
auth: {
mode: fallbackUsed ? "fallback-unauthenticated" : "authenticated",
tokenPresent: !fallbackUsed,
fallbackUsed,
},
};
}
// Handle wrapped format { skills: [...] }
const record = data as Record<string, unknown>;
const skills = record.skills;
if (Array.isArray(skills)) {
return {
entries: skills.map(normalizeEntry),
auth: {
mode: fallbackUsed ? "fallback-unauthenticated" : "authenticated",
tokenPresent: !fallbackUsed,
fallbackUsed,
},
};
}
return {
error: "Invalid upstream response format: expected array or { skills: [...] }",
code: "upstream_invalid_payload",
};
}
/**
* Normalize a single catalog entry.
*/
function normalizeEntry(entry: unknown): CatalogEntry {
if (!entry || typeof entry !== "object") {
return {
id: "",
slug: "",
name: "Unknown",
installation: { installed: false, matchingSkillIds: [], matchingPaths: [] },
};
}
const record = entry as Record<string, unknown>;
const id = String(record.id ?? record.slug ?? "");
const slug = String(record.slug ?? record.name ?? id);
const name = String(record.name ?? record.title ?? slug);
const description = record.description ? String(record.description) : undefined;
const repo = record.repo ? String(record.repo) : undefined;
const npmPackage = record.npmPackage ? String(record.npmPackage) : undefined;
const tags = Array.isArray(record.tags) ? record.tags.map(String) : undefined;
const installs = typeof record.installs === "number" ? record.installs : undefined;
return {
id,
slug,
name,
description,
repo,
npmPackage,
tags,
installs,
installation: {
installed: false,
matchingSkillIds: [],
matchingPaths: [],
},
};
}
/**
* Read project settings from .fusion/settings.json.
*/
export async function readProjectSettings(projectPath: string): Promise<Record<string, unknown>> {
const fusionSettings = join(projectPath, ".fusion", "settings.json");
if (await pathExists(fusionSettings)) {
try {
return JSON.parse(await readFile(fusionSettings, "utf-8")) as Record<string, unknown>;
} catch {
// Return empty on parse error
}
}
return {};
}
/**
* Write project settings to .fusion/settings.json atomically.
*/
export async function writeProjectSettings(projectPath: string, settings: Record<string, unknown>): Promise<void> {
const settingsDir = join(projectPath, ".fusion");
const settingsPath = join(settingsDir, "settings.json");
if (!await pathExists(settingsDir)) {
await mkdir(settingsDir, { recursive: true });
}
await writeFile(settingsPath, JSON.stringify(settings, null, 2));
}
/**
* Get the settings file path for a project.
*/
export function getProjectSettingsPath(rootDir: string): string {
return join(rootDir, ".fusion", "settings.json");
}