import { copyFile, lstat, mkdir, readFile, rm } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import { dirname, join } from "node:path"; import writeFileAtomic from "write-file-atomic"; import { PRODUCT_IDENTITY } from "../identity.js"; import { NonRegularFileReadError } from "../filesystem/RegularFile.js"; import { readRegularFileText } from "./RegularFileRead.js"; import { clientSkillDirectories } from "./SupportedClients.js"; export const MANAGED_SKILL_FILES = [ "SKILL.md", "references/connection-and-recovery.md", "references/native-and-artifacts.md", "references/javascript-applications.md", "references/android-applications.md", "references/runtime-observation.md", "references/evidence-workflows.md", ] as const; export const isManagedSkillManifest = (content: string): boolean => content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) || content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`); export const readSkillFile = (path: string): Promise => readRegularFileText(path, { symlinks: "reject" }); interface CanonicalSkillFile { readonly content: string; readonly destination: string; readonly original: string | undefined; } /** Managed skill location selected for a client family. */ export interface SkillDestination { readonly client: string; readonly path: string; } /** Metadata and byte alignment observed in selected skill installations. */ export interface InstalledSkillIdentity { readonly version: string | null; readonly toolCount: number | null; readonly canonical: boolean; } /** Resolve the skill roots required by a client selection, or all owned roots. */ export const skillDestinations = ( home: string, clientIds: readonly string[] | undefined, environment: Readonly = {}, platform: NodeJS.Platform = process.platform, ): readonly SkillDestination[] => clientSkillDirectories(home, clientIds, environment, platform).map( ({ client, directory }) => ({ client, path: join(directory, PRODUCT_IDENTITY.skillName), }), ); /** Resolve existing REA-owned skill roots using setup's canonical destinations. */ export const existingSkillDestinations = async ( home: string, environment: Readonly = {}, platform: NodeJS.Platform = process.platform, ): Promise => { const existing: SkillDestination[] = []; for (const destination of skillDestinations( home, undefined, environment, platform, )) { try { const root = await lstat(destination.path); if (root.isSymbolicLink() || !root.isDirectory()) continue; const manifest = join(destination.path, "SKILL.md"); const content = await readSkillFile(manifest); if (isManagedSkillManifest(content)) existing.push(destination); } catch (cause: unknown) { if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") continue; if (cause instanceof NonRegularFileReadError) continue; throw cause; } } return existing; }; const readOptionalText = async (path: string): Promise => { try { return await readSkillFile(path); } catch (cause: unknown) { if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return undefined; throw cause; } }; const canonicalSkillFiles = async ( home: string, clientIds: readonly string[], environment: Readonly = {}, platform: NodeJS.Platform = process.platform, ): Promise => Promise.all( skillDestinations(home, clientIds, environment, platform).flatMap( ({ path: root }) => MANAGED_SKILL_FILES.map(async (relativePath) => { const destination = join(root, relativePath); return { destination, content: await readFile( new URL( `../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`, import.meta.url, ), "utf8", ), original: await readOptionalText(destination), }; }), ), ); /** Report whether setup would change any file in the managed REA skill bundle. */ export const canonicalSkillNeedsInstall = async ( home: string, clientIds: readonly string[] = [], environment: Readonly = {}, platform: NodeJS.Platform = process.platform, ): Promise => { try { return ( await canonicalSkillFiles(home, clientIds, environment, platform) ).some(({ content, original }) => original !== content); } catch (cause: unknown) { // Unreadable skill state fails open to install so setup can repair it. void cause; return true; } }; const writeText = (path: string, content: string): Promise => writeFileAtomic(path, content, { encoding: "utf8", mode: 0o600 }); const preserveSkillBackup = async ( source: string, destination: string, ): Promise => { try { await copyFile(source, destination, fsConstants.COPYFILE_EXCL); } catch (cause: unknown) { if (cause instanceof Error && "code" in cause && cause.code === "EEXIST") return; throw cause; } }; const restoreSkillFiles = async ( changed: readonly CanonicalSkillFile[], ): Promise => { for (const { destination, original } of [...changed].reverse()) { if (original === undefined) await rm(destination, { force: true }); else await writeText(destination, original); } }; /** Transactionally install or upgrade the canonical REA skill and references. */ export const installCanonicalSkill = async ( home: string, clientIds: readonly string[] = [], environment: Readonly = {}, platform: NodeJS.Platform = process.platform, ): Promise<"installed" | "unchanged" | "failed"> => { let changed: readonly CanonicalSkillFile[] = []; try { const canonical = await canonicalSkillFiles( home, clientIds, environment, platform, ); changed = canonical.filter(({ content, original }) => original !== content); if (changed.length === 0) return "unchanged"; for (const { destination, original } of changed) { await mkdir(dirname(destination), { recursive: true }); if (original !== undefined) await preserveSkillBackup(destination, `${destination}.rea.backup`); } for (const { destination, content } of changed) await writeText(destination, content); for (const { destination, content } of changed) if ((await readSkillFile(destination)) !== content) throw new Error(`skill readback mismatch: ${destination}`); return "installed"; } catch (cause: unknown) { // Install failure preserves the original error outcome; report cause inline. void cause; try { await restoreSkillFiles(changed); } catch (restoreCause: unknown) { // Per-file backups remain beside changed files for operator recovery. void restoreCause; } return "failed"; } }; /** Read installed skill metadata and compare every managed location. */ export const readInstalledSkillIdentity = async ( home: string, clientIds: readonly string[], environment: Readonly = {}, platform: NodeJS.Platform = process.platform, ): Promise => { const destinations = skillDestinations( home, clientIds, environment, platform, ); let content: string | undefined; for (const destination of destinations) { try { content = await readSkillFile(join(destination.path, "SKILL.md")); break; } catch { // Another selected copy may provide metadata; canonical validation below // still marks missing or unreadable copies as stale. } } if (content === undefined) return undefined; const toolCount = /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1]; return { canonical: !(await canonicalSkillNeedsInstall( home, clientIds, environment, platform, )), version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null, toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10), }; };