import { access, mkdir, mkdtemp, readFile, realpath, stat, writeFile } from "node:fs/promises"; import { realpathSync } from "node:fs"; import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { SafeWorkspace } from "../workspace/safe-workspace.js"; import { DefaultResourceLoader, SettingsManager, type ResourceDiagnostic, type Skill, } from "@earendil-works/pi-coding-agent"; export type SkillDiagnostic = { level: "warning" | "error"; message: string; filePath?: string; }; export type SkillCatalogEntry = { name: string; description: string; source: string; filePath: string; diagnostics: SkillDiagnostic[]; }; export type IndexedSkill = { name: string; source: string; canonicalFilePath: string; }; export class SkillIndex { readonly #skillsByPath = new Map(); public constructor(skills: IndexedSkill[]) { for (const skill of skills) { this.#skillsByPath.set(canonicalPathSync(skill.canonicalFilePath), { ...skill, canonicalFilePath: canonicalPathSync(skill.canonicalFilePath), }); } } public find(canonicalFilePath: string): IndexedSkill | undefined { return this.#skillsByPath.get(canonicalFilePath); } } export type SkillCatalogOptions = { cwd: string; agentDir: string; extraDirsValue?: string; resources?: Record; }; export class SkillCatalog { readonly #cwd: string; readonly #agentDir: string; readonly #extraDirsValue: string | undefined; readonly #resources: Record; #entries: SkillCatalogEntry[] = []; #skills: Skill[] = []; #indexedSkills: IndexedSkill[] = []; #diagnostics: SkillDiagnostic[] = []; public constructor(options: SkillCatalogOptions) { this.#cwd = canonicalPathSync(options.cwd); this.#agentDir = canonicalPathSync(options.agentDir); this.#extraDirsValue = options.extraDirsValue; this.#resources = Object.fromEntries(Object.entries(options.resources ?? {}).map(([path, files]) => [canonicalPathSync(path), files])); } public async reload(): Promise { const { paths: additionalSkillPaths, diagnostics: pathDiagnostics } = await resolveExtraSkillPaths(this.#extraDirsValue); const loader = new DefaultResourceLoader({ cwd: this.#cwd, agentDir: this.#agentDir, settingsManager: SettingsManager.inMemory(), additionalSkillPaths, noExtensions: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, }); await loader.reload(); const loaded = loader.getSkills(); this.#skills = loaded.skills; const loaderDiagnostics = loaded.diagnostics.map((diagnostic) => mapDiagnostic(diagnostic, this.#cwd), ); this.#diagnostics = [...pathDiagnostics, ...loaderDiagnostics]; this.#indexedSkills = loaded.skills.map((skill) => ({ name: skill.name, source: displaySource(skill), canonicalFilePath: canonicalPathSync(skill.filePath), })); this.#entries = loaded.skills .map((skill) => this.#toEntry(skill, loaded.diagnostics)) .sort((left, right) => left.name.localeCompare(right.name)); } public list(): SkillCatalogEntry[] { return this.#entries.map((entry) => ({ ...entry, diagnostics: entry.diagnostics.map((diagnostic) => ({ ...diagnostic })), })); } public index(): SkillIndex { return new SkillIndex(this.#indexedSkills); } /** Copy only catalogued instruction bodies, never arbitrary project files or credentials. */ public async createSessionSnapshot(workspaceRoot: string): Promise<{ skills: Skill[]; index: SkillIndex }> { const root = await realpath(workspaceRoot); const snapshotRoot = await mkdtemp(join(root, ".skills-")); const skills = await Promise.all(this.#skills.map(async (skill, index) => { const baseDir = join(snapshotRoot, String(index)); await mkdir(baseDir, { mode: 0o700 }); const filePath = join(baseDir, "SKILL.md"); await writeFile(filePath, await readFile(skill.filePath), { flag: "wx", mode: 0o400 }); const source = await SafeWorkspace.create(skill.baseDir); for (const resource of this.#resources[canonicalPathSync(skill.filePath)] ?? []) { if (isAbsolute(resource) || resource.split(/[\\/]/).includes("..")) throw new Error("Unsafe Skill resource path"); const from = await source.resolveReadable(resource); if ((await stat(from)).size > 1_048_576) throw new Error("Skill resource exceeds size limit"); const target = join(baseDir, resource); await mkdir(dirname(target), { recursive: true, mode: 0o700 }); await writeFile(target, await readFile(from), { flag: "wx", mode: 0o400 }); } return { ...skill, baseDir, filePath }; })); return { skills, index: new SkillIndex(skills.map((skill) => ({ name: skill.name, source: displaySource(skill), canonicalFilePath: skill.filePath, }))), }; } public readonly diagnostics = (): SkillDiagnostic[] => { return this.#diagnostics.map((diagnostic) => ({ ...diagnostic })); }; #toEntry(skill: Skill, diagnostics: ResourceDiagnostic[]): SkillCatalogEntry { const canonicalFilePath = canonicalPathSync(skill.filePath); return { name: skill.name, description: skill.description, source: displaySource(skill), filePath: safeDisplayPath(canonicalFilePath, this.#cwd), diagnostics: diagnostics .filter( (diagnostic) => diagnostic.path !== undefined && canonicalPathSync(diagnostic.path) === canonicalFilePath, ) .map((diagnostic) => mapDiagnostic(diagnostic, this.#cwd)), }; } } async function resolveExtraSkillPaths(value?: string): Promise<{ paths: string[]; diagnostics: SkillDiagnostic[]; }> { if (value === undefined || value.trim() === "") { return { paths: [], diagnostics: [] }; } const paths: string[] = []; const diagnostics: SkillDiagnostic[] = []; const seen = new Set(); for (const rawPath of value.split(delimiter).map((entry) => entry.trim()).filter(Boolean)) { const resolved = resolve(rawPath); try { await access(resolved); const pathStat = await stat(resolved); if (!pathStat.isDirectory()) { diagnostics.push({ level: "error", message: `Extra skill root is not a directory: ${resolved}` }); continue; } const canonical = await realpath(resolved); if (!seen.has(canonical)) { seen.add(canonical); paths.push(canonical); } } catch { diagnostics.push({ level: "error", message: `Extra skill root is unavailable: ${resolved}` }); } } return { paths, diagnostics }; } function canonicalPathSync(filePath: string): string { try { return realpathSync(filePath); } catch { return resolve(filePath); } } function displaySource(skill: Skill): string { return skill.sourceInfo.scope === "temporary" ? skill.sourceInfo.source : skill.sourceInfo.scope; } function safeDisplayPath(filePath: string, cwd: string): string { const relativePath = relative(cwd, filePath); if (relativePath !== "" && !relativePath.startsWith(`..${sep}`) && relativePath !== ".." && !isAbsolute(relativePath)) { return relativePath; } return `external/${skillBasename(filePath)}/SKILL.md`; } function skillBasename(filePath: string): string { const segments = filePath.split(sep).filter(Boolean); return segments.at(-2) ?? "skill"; } function mapDiagnostic(diagnostic: ResourceDiagnostic, cwd: string): SkillDiagnostic { return { level: diagnostic.type === "error" ? "error" : "warning", message: diagnostic.message, ...(diagnostic.path === undefined ? {} : { filePath: safeDisplayPath(canonicalPathSync(diagnostic.path), cwd) }), }; }