|
5 | 5 | * Called by both init and update commands before profile resolution. |
6 | 6 | */ |
7 | 7 |
|
8 | | -import type { AIToolOption } from './config.js'; |
| 8 | +import { AI_TOOLS, type AIToolOption } from './config.js'; |
9 | 9 | import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js'; |
10 | 10 | import { CommandAdapterRegistry } from './command-generation/index.js'; |
11 | 11 | import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; |
12 | 12 | import { ALL_WORKFLOWS } from './profiles.js'; |
13 | 13 | import path from 'path'; |
14 | 14 | import * as fs from 'fs'; |
15 | 15 |
|
| 16 | +/** |
| 17 | + * Former skillsDir locations for tools whose directory was renamed. |
| 18 | + * OpenSpec-managed skill directories left in these locations are migrated |
| 19 | + * to the tool's current skillsDir; user files are never touched. |
| 20 | + */ |
| 21 | +export const LEGACY_SKILLS_DIRS: Record<string, string[]> = { |
| 22 | + // Kimi CLI became Kimi Code and moved from .kimi to .kimi-code |
| 23 | + kimi: ['.kimi'], |
| 24 | +}; |
| 25 | + |
| 26 | +export interface LegacySkillsMigration { |
| 27 | + toolId: string; |
| 28 | + /** Legacy tool root, e.g. '.kimi' */ |
| 29 | + from: string; |
| 30 | + /** Current tool root, e.g. '.kimi-code' */ |
| 31 | + to: string; |
| 32 | + /** Number of skill directories moved or removed */ |
| 33 | + movedSkillDirs: number; |
| 34 | +} |
| 35 | + |
| 36 | +/** |
| 37 | + * Moves OpenSpec-managed skill directories (openspec-*) from a tool's legacy |
| 38 | + * skillsDir to its current one. When the destination already exists the legacy |
| 39 | + * copy is removed instead. Legacy directories are deleted only when left empty, |
| 40 | + * so user files under the old location are preserved. |
| 41 | + */ |
| 42 | +export function migrateLegacySkillDirs(projectPath: string): LegacySkillsMigration[] { |
| 43 | + const migrations: LegacySkillsMigration[] = []; |
| 44 | + |
| 45 | + for (const tool of AI_TOOLS) { |
| 46 | + if (!tool.skillsDir) continue; |
| 47 | + |
| 48 | + for (const legacyRoot of LEGACY_SKILLS_DIRS[tool.value] ?? []) { |
| 49 | + if (legacyRoot === tool.skillsDir) continue; |
| 50 | + const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills'); |
| 51 | + if (!fs.existsSync(legacySkillsDir)) continue; |
| 52 | + const currentSkillsDir = path.join(projectPath, tool.skillsDir, 'skills'); |
| 53 | + let movedSkillDirs = 0; |
| 54 | + |
| 55 | + for (const workflowId of ALL_WORKFLOWS) { |
| 56 | + const dirName = WORKFLOW_TO_SKILL_DIR[workflowId]; |
| 57 | + const source = path.join(legacySkillsDir, dirName); |
| 58 | + if (!fs.existsSync(path.join(source, 'SKILL.md'))) continue; |
| 59 | + |
| 60 | + try { |
| 61 | + const destination = path.join(currentSkillsDir, dirName); |
| 62 | + if (fs.existsSync(destination)) { |
| 63 | + fs.rmSync(source, { recursive: true, force: true }); |
| 64 | + } else { |
| 65 | + fs.mkdirSync(currentSkillsDir, { recursive: true }); |
| 66 | + fs.renameSync(source, destination); |
| 67 | + } |
| 68 | + movedSkillDirs++; |
| 69 | + } catch { |
| 70 | + // Leave the legacy directory in place if it cannot be moved |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + removeDirIfEmpty(legacySkillsDir); |
| 75 | + removeDirIfEmpty(path.join(projectPath, legacyRoot)); |
| 76 | + |
| 77 | + if (movedSkillDirs > 0) { |
| 78 | + migrations.push({ toolId: tool.value, from: legacyRoot, to: tool.skillsDir, movedSkillDirs }); |
| 79 | + } |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + return migrations; |
| 84 | +} |
| 85 | + |
| 86 | +function removeDirIfEmpty(dirPath: string): void { |
| 87 | + try { |
| 88 | + if (fs.readdirSync(dirPath).length === 0) { |
| 89 | + fs.rmdirSync(dirPath); |
| 90 | + } |
| 91 | + } catch { |
| 92 | + // Missing or non-empty directory — nothing to do |
| 93 | + } |
| 94 | +} |
| 95 | + |
16 | 96 | interface InstalledWorkflowArtifacts { |
17 | 97 | workflows: string[]; |
18 | 98 | hasSkills: boolean; |
|
0 commit comments