231 lines
7.7 KiB
TypeScript
231 lines
7.7 KiB
TypeScript
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<string, IndexedSkill>();
|
|
|
|
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<string, readonly string[]>;
|
|
};
|
|
|
|
export class SkillCatalog {
|
|
readonly #cwd: string;
|
|
readonly #agentDir: string;
|
|
readonly #extraDirsValue: string | undefined;
|
|
readonly #resources: Record<string, readonly string[]>;
|
|
#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<void> {
|
|
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<string>();
|
|
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) }),
|
|
};
|
|
}
|