import { createHash } from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { SkillScanner } from './SkillScanner'; import type { ImportedSkillSourceFile } from './SkillImportService'; import type { SkillDraftFile, SkillReviewFileChange, SkillReviewPreview, SkillReviewSummary, } from '@shared/types/extensions'; type SkillPlanInputFile = | { relativePath: string; isBinary: false; content: string } | { relativePath: string; isBinary: true; sourceAbsolutePath: string }; interface ManagedCurrentFile { relativePath: string; absolutePath: string; } interface SkillExecutionChange extends SkillReviewFileChange { sourceAbsolutePath?: string; } export interface SkillExecutionPlan { preview: SkillReviewPreview; changes: SkillExecutionChange[]; } const MANAGED_SUBDIRECTORIES = ['scripts', 'references', 'assets'] as const; export class SkillPlanService { constructor(private readonly scanner = new SkillScanner()) {} async buildUpsertPlan( targetSkillDir: string, files: SkillDraftFile[] ): Promise { const desiredFiles: SkillPlanInputFile[] = files.map((file) => ({ relativePath: file.relativePath, isBinary: false, content: file.content, })); return this.buildPlan(targetSkillDir, desiredFiles, 'upsert'); } async buildImportPlan( targetSkillDir: string, files: ImportedSkillSourceFile[] ): Promise { const desiredFiles: SkillPlanInputFile[] = files.map((file) => file.isBinary ? { relativePath: file.relativePath, isBinary: true, sourceAbsolutePath: file.absolutePath, } : { relativePath: file.relativePath, isBinary: false, content: file.content ?? '', } ); return this.buildPlan(targetSkillDir, desiredFiles, 'import'); } async applyPlan(plan: SkillExecutionPlan): Promise { const backupRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-plan-backup-')); const createdPaths: string[] = []; const backups: { absolutePath: string; backupPath: string }[] = []; try { for (const [index, change] of plan.changes.entries()) { if (change.action !== 'create' && (await this.pathExists(change.absolutePath))) { const backupPath = path.join(backupRoot, String(index)); await fs.mkdir(path.dirname(backupPath), { recursive: true }); await fs.copyFile(change.absolutePath, backupPath); backups.push({ absolutePath: change.absolutePath, backupPath }); } if (change.action === 'delete') { await fs.rm(change.absolutePath, { force: true }); await this.cleanupManagedParents( path.dirname(change.absolutePath), plan.preview.targetSkillDir ); continue; } await fs.mkdir(path.dirname(change.absolutePath), { recursive: true }); if (change.isBinary) { if (!change.sourceAbsolutePath) { throw new Error(`Missing binary source for ${change.relativePath}`); } await fs.copyFile(change.sourceAbsolutePath, change.absolutePath); } else { await fs.writeFile(change.absolutePath, change.newContent ?? '', 'utf8'); } if (change.action === 'create') { createdPaths.push(change.absolutePath); } } await this.cleanupManagedDirectories(plan.preview.targetSkillDir); } catch (error) { await Promise.all( createdPaths .slice() .reverse() .map(async (absolutePath) => { await fs.rm(absolutePath, { force: true }); await this.cleanupManagedParents( path.dirname(absolutePath), plan.preview.targetSkillDir ); }) ); await Promise.all( backups .slice() .reverse() .map(async ({ absolutePath, backupPath }) => { await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.copyFile(backupPath, absolutePath); }) ); throw error; } finally { await fs.rm(backupRoot, { recursive: true, force: true }); } } private async buildPlan( targetSkillDir: string, desiredFiles: SkillPlanInputFile[], mode: 'upsert' | 'import' ): Promise { const normalizedDesired = this.normalizeDesiredFiles(desiredFiles); const [currentManagedFiles, allExistingFiles] = await Promise.all([ this.readCurrentManagedFiles(targetSkillDir), this.listAllRelativeFiles(targetSkillDir), ]); const changesByRelativePath = new Map(); await Promise.all( normalizedDesired.map(async (file) => { const absolutePath = path.join(targetSkillDir, file.relativePath); const existingTextContent = file.isBinary ? null : await this.readUtf8IfExists(absolutePath); const action = (await this.pathExists(absolutePath)) ? 'update' : 'create'; changesByRelativePath.set(file.relativePath, { relativePath: file.relativePath, absolutePath, action, oldContent: existingTextContent, newContent: file.isBinary ? null : file.content, isBinary: file.isBinary, sourceAbsolutePath: file.isBinary ? file.sourceAbsolutePath : undefined, }); }) ); for (const currentFile of currentManagedFiles.values()) { if (changesByRelativePath.has(currentFile.relativePath)) { continue; } const existingTextContent = await this.readUtf8IfExists(currentFile.absolutePath); changesByRelativePath.set(currentFile.relativePath, { relativePath: currentFile.relativePath, absolutePath: currentFile.absolutePath, action: 'delete', oldContent: existingTextContent, newContent: null, isBinary: false, }); } const changes = [...changesByRelativePath.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath) ); const warnings = this.buildWarnings({ changes, currentManagedFiles, allExistingFiles, desiredFiles: new Set(normalizedDesired.map((file) => file.relativePath)), mode, }); const summary = changes.reduce( (acc, change) => { acc[`${change.action}d`] += 1; if (change.isBinary) { acc.binary += 1; } return acc; }, { created: 0, updated: 0, deleted: 0, binary: 0 } ); const preview: SkillReviewPreview = { planId: this.buildPlanId(targetSkillDir, changes, warnings), targetSkillDir, changes: changes.map(({ sourceAbsolutePath: _sourceAbsolutePath, ...change }) => change), warnings, summary, }; return { preview, changes }; } private normalizeDesiredFiles(files: SkillPlanInputFile[]): SkillPlanInputFile[] { const map = new Map(); for (const file of files) { const normalizedPath = path.normalize(file.relativePath).replace(/\\/g, '/'); map.set(normalizedPath, { ...file, relativePath: normalizedPath }); } return [...map.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath)); } private async readCurrentManagedFiles( targetSkillDir: string ): Promise> { const files = new Map(); const detectedSkillFile = await this.scanner.detectSkillFile(targetSkillDir); if (detectedSkillFile) { files.set(path.basename(detectedSkillFile), { relativePath: path.basename(detectedSkillFile), absolutePath: detectedSkillFile, }); } for (const directory of MANAGED_SUBDIRECTORIES) { const fullDirectoryPath = path.join(targetSkillDir, directory); const relativeFiles = await this.listAllRelativeFiles(fullDirectoryPath); for (const relativePath of relativeFiles) { const managedRelativePath = `${directory}/${relativePath}`; files.set(managedRelativePath, { relativePath: managedRelativePath, absolutePath: path.join(fullDirectoryPath, relativePath), }); } } return files; } private async listAllRelativeFiles(rootDir: string): Promise { try { const rootStat = await fs.stat(rootDir); if (!rootStat.isDirectory()) { return []; } } catch { return []; } const dirEntries = await fs.readdir(rootDir, { withFileTypes: true }); const entries = await Promise.all( dirEntries.map(async (entry) => { const fullPath = path.join(rootDir, entry.name); if (entry.isDirectory()) { const children = await this.listAllRelativeFiles(fullPath); return children.map((child) => path.join(entry.name, child).replace(/\\/g, '/')); } return [entry.name]; }) ); return entries.flat().sort((a, b) => a.localeCompare(b)); } private buildWarnings({ changes, currentManagedFiles, allExistingFiles, desiredFiles, mode, }: { changes: SkillExecutionChange[]; currentManagedFiles: Map; allExistingFiles: string[]; desiredFiles: Set; mode: 'upsert' | 'import'; }): string[] { const warnings: string[] = []; const deleteCount = changes.filter((change) => change.action === 'delete').length; const updateCount = changes.filter((change) => change.action === 'update').length; const binaryCount = changes.filter((change) => change.isBinary).length; if (deleteCount > 0) { warnings.push( deleteCount === 1 ? '1 managed file will be removed to match this reviewed plan.' : `${deleteCount} managed files will be removed to match this reviewed plan.` ); } if (updateCount > 0) { warnings.push( updateCount === 1 ? '1 existing file will be overwritten.' : `${updateCount} existing files will be overwritten.` ); } if (binaryCount > 0) { warnings.push( binaryCount === 1 ? '1 binary file will be copied as-is.' : `${binaryCount} binary files will be copied as-is.` ); } const managedPaths = new Set(currentManagedFiles.keys()); const unmanagedFiles = allExistingFiles.filter( (relativePath) => !managedPaths.has(relativePath) && !desiredFiles.has(relativePath) ); if (unmanagedFiles.length > 0) { warnings.push( mode === 'import' ? 'Existing files outside the imported plan will be kept as-is.' : 'Existing files outside the managed skill set will be kept as-is.' ); } return warnings; } private buildPlanId( targetSkillDir: string, changes: SkillExecutionChange[], warnings: string[] ): string { const hash = createHash('sha256'); hash.update(targetSkillDir); hash.update('\n'); for (const change of changes) { hash.update( JSON.stringify({ relativePath: change.relativePath, action: change.action, oldContent: change.oldContent, newContent: change.newContent, isBinary: change.isBinary, sourceAbsolutePath: change.sourceAbsolutePath ?? null, }) ); hash.update('\n'); } for (const warning of warnings) { hash.update(warning); hash.update('\n'); } return hash.digest('hex'); } private async readUtf8IfExists(filePath: string): Promise { try { return await fs.readFile(filePath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return null; } return null; } } private async pathExists(targetPath: string): Promise { try { await fs.stat(targetPath); return true; } catch { return false; } } private async cleanupManagedDirectories(targetSkillDir: string): Promise { await Promise.all( MANAGED_SUBDIRECTORIES.map((directory) => this.cleanupManagedParents(path.join(targetSkillDir, directory), targetSkillDir) ) ); } private async cleanupManagedParents(currentDir: string, targetSkillDir: string): Promise { let nextDir = currentDir; while (nextDir.startsWith(targetSkillDir) && nextDir !== targetSkillDir) { try { const entries = await fs.readdir(nextDir); if (entries.length > 0) { return; } await fs.rmdir(nextDir); } catch { return; } nextDir = path.dirname(nextDir); } } }