agent-ecosystem/src/main/services/extensions/skills/SkillsMutationService.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

147 lines
5.5 KiB
TypeScript

import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation';
import { shell } from 'electron';
import { SkillImportService } from './SkillImportService';
import { SkillPlanService } from './SkillPlanService';
import { SkillRootsResolver } from './SkillRootsResolver';
import { SkillScaffoldService } from './SkillScaffoldService';
import { SkillsCatalogService } from './SkillsCatalogService';
import type {
SkillDeleteRequest,
SkillDetail,
SkillImportRequest,
SkillReviewPreview,
SkillUpsertRequest,
} from '@shared/types/extensions';
export class SkillsMutationService {
constructor(
private readonly rootsResolver = new SkillRootsResolver(),
private readonly catalogService = new SkillsCatalogService(),
private readonly scaffoldService = new SkillScaffoldService(rootsResolver),
private readonly importService = new SkillImportService(),
private readonly planService = new SkillPlanService()
) {}
async previewUpsert(request: SkillUpsertRequest): Promise<SkillReviewPreview> {
const targetSkillDir = await this.scaffoldService.resolveUpsertTarget(
request.scope,
request.rootKind,
request.projectPath,
request.folderName,
request.existingSkillId
);
const files = this.scaffoldService.normalizeDraftFiles(request.files);
const plan = await this.planService.buildUpsertPlan(targetSkillDir, files);
return plan.preview;
}
async applyUpsert(request: SkillUpsertRequest): Promise<SkillDetail | null> {
if (!request.reviewPlanId) {
throw new Error('Review the skill changes before saving.');
}
const targetSkillDir = await this.scaffoldService.resolveUpsertTarget(
request.scope,
request.rootKind,
request.projectPath,
request.folderName,
request.existingSkillId
);
const files = this.scaffoldService.normalizeDraftFiles(request.files);
const plan = await this.planService.buildUpsertPlan(targetSkillDir, files);
this.assertReviewedPlanMatches(request.reviewPlanId, plan.preview.planId);
await this.planService.applyPlan(plan);
return this.catalogService.getDetail(targetSkillDir, request.projectPath);
}
async previewImport(request: SkillImportRequest): Promise<SkillReviewPreview> {
const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request);
const inspection = await this.importService.inspectSourceDir(sourceDir);
const plan = await this.planService.buildImportPlan(targetSkillDir, inspection.files);
return {
...plan.preview,
warnings: [...new Set([...inspection.warnings, ...plan.preview.warnings])],
};
}
async applyImport(request: SkillImportRequest): Promise<SkillDetail | null> {
if (!request.reviewPlanId) {
throw new Error('Review the import changes before saving.');
}
const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request);
const inspection = await this.importService.inspectSourceDir(sourceDir);
const plan = await this.planService.buildImportPlan(targetSkillDir, inspection.files);
this.assertReviewedPlanMatches(request.reviewPlanId, plan.preview.planId);
await this.planService.applyPlan(plan);
return this.catalogService.getDetail(targetSkillDir, request.projectPath);
}
async deleteSkill(request: SkillDeleteRequest): Promise<void> {
const skillDir = this.resolveExistingSkill(request.skillId, request.projectPath);
await shell.trashItem(skillDir);
}
private async resolveImportTarget(
request: SkillImportRequest
): Promise<{ sourceDir: string; targetSkillDir: string }> {
const sourceDir = await this.importService.validateSourceDir(request.sourceDir);
const root = this.resolveWritableRoot(request.scope, request.rootKind, request.projectPath);
await fs.mkdir(root.rootPath, { recursive: true });
const folderName = request.folderName?.trim() || path.basename(sourceDir);
const folderValidation = validateFileName(folderName);
if (!folderValidation.valid) {
throw new Error(folderValidation.error ?? 'Invalid folder name');
}
const targetSkillDir = path.join(root.rootPath, folderName);
if (!isPathWithinRoot(targetSkillDir, root.rootPath)) {
throw new Error('Import destination is outside the allowed root');
}
return { sourceDir, targetSkillDir };
}
private resolveWritableRoot(
scope: SkillUpsertRequest['scope'],
rootKind: SkillUpsertRequest['rootKind'],
projectPath?: string
) {
const roots = this.rootsResolver.resolve(projectPath);
const match = roots.find((root) => root.scope === scope && root.rootKind === rootKind);
if (!match) {
throw new Error('Requested skill root is unavailable');
}
if (scope === 'project' && !projectPath) {
throw new Error('projectPath is required for project-scoped skills');
}
return match;
}
private resolveExistingSkill(skillId: string, projectPath?: string): string {
const normalizedSkillDir = path.resolve(skillId);
const roots = this.rootsResolver.resolve(projectPath);
const owningRoot = roots.find((root) => isPathWithinRoot(normalizedSkillDir, root.rootPath));
if (!owningRoot) {
throw new Error('Skill is outside the allowed roots');
}
return normalizedSkillDir;
}
private assertReviewedPlanMatches(reviewPlanId: string, currentPlanId: string): void {
if (reviewPlanId !== currentPlanId) {
throw new Error(
'The skill files changed after review. Review the latest changes and try again.'
);
}
}
}