agent-ecosystem/src/main/services/extensions/skills/SkillPlanService.ts
iliya 51f8f3545c style: auto-fix import/export sorting and formatting
Ran pnpm lint:fix to resolve 220 auto-fixable lint issues.
All changes are import/export reordering — no logic changes.
2026-03-16 20:48:42 +02:00

411 lines
13 KiB
TypeScript

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<SkillExecutionPlan> {
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<SkillExecutionPlan> {
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<void> {
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<SkillExecutionPlan> {
const normalizedDesired = this.normalizeDesiredFiles(desiredFiles);
const [currentManagedFiles, allExistingFiles] = await Promise.all([
this.readCurrentManagedFiles(targetSkillDir),
this.listAllRelativeFiles(targetSkillDir),
]);
const changesByRelativePath = new Map<string, SkillExecutionChange>();
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<SkillReviewSummary>(
(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<string, SkillPlanInputFile>();
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<Map<string, ManagedCurrentFile>> {
const files = new Map<string, ManagedCurrentFile>();
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<string[]> {
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<string, ManagedCurrentFile>;
allExistingFiles: string[];
desiredFiles: Set<string>;
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<string | null> {
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<boolean> {
try {
await fs.stat(targetPath);
return true;
} catch {
return false;
}
}
private async cleanupManagedDirectories(targetSkillDir: string): Promise<void> {
await Promise.all(
MANAGED_SUBDIRECTORIES.map((directory) =>
this.cleanupManagedParents(path.join(targetSkillDir, directory), targetSkillDir)
)
);
}
private async cleanupManagedParents(currentDir: string, targetSkillDir: string): Promise<void> {
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);
}
}
}