Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@ Different AI tools use slightly different command syntax. Use the format that ma
| Windsurf | `/opsx-propose`, `/opsx-apply` |
| Copilot (IDE) | `/opsx-propose`, `/opsx-apply` |
| Oh My Pi | `/opsx-propose`, `/opsx-apply` |
| Kimi CLI | Skill-based invocations such as `/skill:openspec-propose`, `/skill:openspec-apply-change` (no generated `opsx-*` command files) |
| Kimi Code | Skill-based invocations such as `/skill:openspec-propose`, `/skill:openspec-apply-change` (no generated `opsx-*` command files) |
| Trae | `/opsx-propose`, `/opsx-apply` |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

The intent is the same across tools, but how commands are surfaced can differ by integration.
Expand Down
2 changes: 1 addition & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch
| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |
| Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-<id>.md` |
| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-<id>.md` |
| Kimi CLI (`kimi`) | `.kimi/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/skill:openspec-*` invocations) |
| Kimi Code (`kimi`) | `.kimi-code/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/skill:openspec-*` invocations) |
| Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-<id>.prompt.md` |
| Lingma (`lingma`) | `.lingma/skills/openspec-*/SKILL.md` | `.lingma/commands/opsx/<id>.md` |
| Mistral Vibe (`vibe`) | `.vibe/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
Expand Down
5 changes: 3 additions & 2 deletions openspec/specs/ai-tool-paths/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent
- **WHEN** looking up the `windsurf` tool
- **THEN** `skillsDir` SHALL be `.windsurf`

#### Scenario: Kimi CLI paths defined
#### Scenario: Kimi Code paths defined

- **WHEN** looking up the `kimi` tool
- **THEN** `skillsDir` SHALL be `.kimi`
- **THEN** `skillsDir` SHALL be `.kimi-code`
- **AND** OpenSpec-managed skills remaining under the legacy `.kimi/skills` directory SHALL be migrated to `.kimi-code/skills` during init and update, preserving user files

#### Scenario: Tools without skillsDir

Expand Down
6 changes: 3 additions & 3 deletions openspec/specs/cli-init/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,10 @@ The command SHALL generate opsx slash commands only for selected tools that have
- **AND** command-file generation SHALL be skipped for that tool
- **AND** the command output SHALL include `Commands skipped for: <tool-id> (no adapter)`

#### Scenario: Kimi CLI skips command-file generation
#### Scenario: Kimi Code skips command-file generation

- **WHEN** the user selects Kimi CLI during initialization
- **THEN** OpenSpec SHALL treat it as a supported tool with `skillsDir: '.kimi'`
- **WHEN** the user selects Kimi Code during initialization
- **THEN** OpenSpec SHALL treat it as a supported tool with `skillsDir: '.kimi-code'`
- **AND** command-file generation SHALL be skipped because no Kimi adapter is registered

### Requirement: Config File Generation
Expand Down
2 changes: 1 addition & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },
{ name: 'Kimi CLI', value: 'kimi', available: true, successLabel: 'Kimi CLI', skillsDir: '.kimi' },
{ name: 'Kimi Code', value: 'kimi', available: true, successLabel: 'Kimi Code', skillsDir: '.kimi-code', detectionPaths: ['.kimi-code', '.kimi'] },
{ name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },
{ name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' },
{ name: 'Mistral Vibe', value: 'vibe', available: true, successLabel: 'Mistral Vibe', skillsDir: '.vibe' },
Expand Down
6 changes: 5 additions & 1 deletion src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
import { getGlobalConfig, type Delivery, type Profile } from './global-config.js';
import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js';
import { getAvailableTools } from './available-tools.js';
import { migrateIfNeeded } from './migration.js';
import { migrateIfNeeded, migrateLegacySkillDirs } from './migration.js';

const require = createRequire(import.meta.url);
const { version: OPENSPEC_VERSION } = require('../../package.json');
Expand Down Expand Up @@ -143,6 +143,10 @@ export class InitCommand {
// Check for legacy artifacts and handle cleanup
await this.handleLegacyCleanup(projectPath, extendMode);

// Migrate OpenSpec-managed skills left in renamed tool directories
// (e.g. .kimi -> .kimi-code) before detection so they stay recognized.
migrateLegacySkillDirs(projectPath);

// Detect available tools in the project (task 7.1)
const detectedTools = getAvailableTools(projectPath);

Expand Down
82 changes: 81 additions & 1 deletion src/core/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,94 @@
* Called by both init and update commands before profile resolution.
*/

import type { AIToolOption } from './config.js';
import { AI_TOOLS, type AIToolOption } from './config.js';
import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js';
import { CommandAdapterRegistry } from './command-generation/index.js';
import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js';
import { ALL_WORKFLOWS } from './profiles.js';
import path from 'path';
import * as fs from 'fs';

/**
* Former skillsDir locations for tools whose directory was renamed.
* OpenSpec-managed skill directories left in these locations are migrated
* to the tool's current skillsDir; user files are never touched.
*/
export const LEGACY_SKILLS_DIRS: Record<string, string[]> = {
// Kimi CLI became Kimi Code and moved from .kimi to .kimi-code
kimi: ['.kimi'],
};

export interface LegacySkillsMigration {
toolId: string;
/** Legacy tool root, e.g. '.kimi' */
from: string;
/** Current tool root, e.g. '.kimi-code' */
to: string;
/** Number of skill directories moved or removed */
movedSkillDirs: number;
}

/**
* Moves OpenSpec-managed skill directories (openspec-*) from a tool's legacy
* skillsDir to its current one. When the destination already exists the legacy
* copy is removed instead. Legacy directories are deleted only when left empty,
* so user files under the old location are preserved.
*/
export function migrateLegacySkillDirs(projectPath: string): LegacySkillsMigration[] {
const migrations: LegacySkillsMigration[] = [];

for (const tool of AI_TOOLS) {
if (!tool.skillsDir) continue;

for (const legacyRoot of LEGACY_SKILLS_DIRS[tool.value] ?? []) {
if (legacyRoot === tool.skillsDir) continue;
const legacySkillsDir = path.join(projectPath, legacyRoot, 'skills');
if (!fs.existsSync(legacySkillsDir)) continue;
const currentSkillsDir = path.join(projectPath, tool.skillsDir, 'skills');
let movedSkillDirs = 0;

for (const workflowId of ALL_WORKFLOWS) {
const dirName = WORKFLOW_TO_SKILL_DIR[workflowId];
const source = path.join(legacySkillsDir, dirName);
if (!fs.existsSync(path.join(source, 'SKILL.md'))) continue;

try {
const destination = path.join(currentSkillsDir, dirName);
if (fs.existsSync(destination)) {
fs.rmSync(source, { recursive: true, force: true });
} else {
fs.mkdirSync(currentSkillsDir, { recursive: true });
fs.renameSync(source, destination);
}
movedSkillDirs++;
} catch {
// Leave the legacy directory in place if it cannot be moved
}
}

removeDirIfEmpty(legacySkillsDir);
removeDirIfEmpty(path.join(projectPath, legacyRoot));

if (movedSkillDirs > 0) {
migrations.push({ toolId: tool.value, from: legacyRoot, to: tool.skillsDir, movedSkillDirs });
}
}
}

return migrations;
}

function removeDirIfEmpty(dirPath: string): void {
try {
if (fs.readdirSync(dirPath).length === 0) {
fs.rmdirSync(dirPath);
}
} catch {
// Missing or non-empty directory — nothing to do
}
}

interface InstalledWorkflowArtifacts {
workflows: string[];
hasSkills: boolean;
Expand Down
10 changes: 9 additions & 1 deletion src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
import {
scanInstalledWorkflows as scanInstalledWorkflowsShared,
migrateIfNeeded as migrateIfNeededShared,
migrateLegacySkillDirs,
} from './migration.js';

const require = createRequire(import.meta.url);
Expand Down Expand Up @@ -89,7 +90,14 @@ export class UpdateCommand {
throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);
}

// 2. Perform one-time migration if needed before any legacy upgrade generation.
// 2. Migrate OpenSpec-managed skills left in renamed tool directories
// (e.g. .kimi -> .kimi-code) so they stay detected and get refreshed,
// then perform the one-time profile migration if needed before any
// legacy upgrade generation.
for (const migration of migrateLegacySkillDirs(resolvedProjectPath)) {
console.log(chalk.dim(`Migrated ${migration.movedSkillDirs} skill director${migration.movedSkillDirs === 1 ? 'y' : 'ies'}: ${migration.from}/skills → ${migration.to}/skills`));
}

// Use detected tool directories to preserve existing opsx skills/commands.
const detectedTools = getAvailableTools(resolvedProjectPath);
migrateIfNeededShared(resolvedProjectPath, detectedTools);
Expand Down
27 changes: 24 additions & 3 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ describe('InitCommand', () => {
expect(await fileExists(skillFile)).toBe(true);
});

it('should support Kimi CLI as an adapterless skills-only tool', async () => {
it('should support Kimi Code as an adapterless skills-only tool', async () => {
saveGlobalConfig({
featureFlags: {},
profile: 'core',
Expand All @@ -180,10 +180,10 @@ describe('InitCommand', () => {
const initCommand = new InitCommand({ tools: 'kimi', force: true });
await initCommand.execute(testDir);

const skillFile = path.join(testDir, '.kimi', 'skills', 'openspec-explore', 'SKILL.md');
const skillFile = path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md');
expect(await fileExists(skillFile)).toBe(true);

const commandsDir = path.join(testDir, '.kimi', 'commands');
const commandsDir = path.join(testDir, '.kimi-code', 'commands');
expect(await directoryExists(commandsDir)).toBe(false);

const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
Expand All @@ -194,6 +194,27 @@ describe('InitCommand', () => {
).toBe(true);
});

it('should migrate OpenSpec skills from legacy .kimi to .kimi-code during init', async () => {
const legacySkillDir = path.join(testDir, '.kimi', 'skills', 'openspec-explore');
await fs.mkdir(legacySkillDir, { recursive: true });
await fs.writeFile(
path.join(legacySkillDir, 'SKILL.md'),
`---\nname: openspec-explore\nmetadata:\n author: openspec\n version: "0.9"\n---\n\nOld instructions content\n`
);
await fs.writeFile(path.join(testDir, '.kimi', 'config.toml'), 'user config');

const initCommand = new InitCommand({ tools: 'kimi', force: true });
await initCommand.execute(testDir);

// Regenerated in the new location, legacy managed skill removed
const newSkill = path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md');
expect(await fileExists(newSkill)).toBe(true);
expect(await directoryExists(legacySkillDir)).toBe(false);

// User files under .kimi are preserved
expect(await fileExists(path.join(testDir, '.kimi', 'config.toml'))).toBe(true);
});

it('should create both skills and commands for Trae with adapter', async () => {
saveGlobalConfig({
configuredTools: [],
Expand Down
53 changes: 53 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,59 @@ Old instructions content
consoleSpy.mockRestore();
});

it('should migrate OpenSpec skills from legacy .kimi to .kimi-code, preserving user files', async () => {
// Managed skill in the legacy Kimi CLI location
const legacySkillDir = path.join(testDir, '.kimi', 'skills', 'openspec-explore');
await fs.mkdir(legacySkillDir, { recursive: true });
await fs.writeFile(
path.join(legacySkillDir, 'SKILL.md'),
`---\nname: openspec-explore\nmetadata:\n author: openspec\n version: "0.9"\n---\n\nOld instructions content\n`
);

// User-owned files in the legacy location that must be preserved
const userSkillDir = path.join(testDir, '.kimi', 'skills', 'my-custom-skill');
await fs.mkdir(userSkillDir, { recursive: true });
await fs.writeFile(path.join(userSkillDir, 'SKILL.md'), 'user skill');
await fs.writeFile(path.join(testDir, '.kimi', 'config.toml'), 'user config');

const consoleSpy = vi.spyOn(console, 'log');

await updateCommand.execute(testDir);

// Managed skill migrated to .kimi-code and refreshed by the update
const migratedSkill = await fs.readFile(
path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md'),
'utf-8'
);
expect(migratedSkill).toContain('name: openspec-explore');
expect(migratedSkill).not.toContain('Old instructions content');

// Legacy managed skill is gone; user files stay where they were
await expect(fs.access(legacySkillDir)).rejects.toThrow();
expect(await fs.readFile(path.join(userSkillDir, 'SKILL.md'), 'utf-8')).toBe('user skill');
expect(await fs.readFile(path.join(testDir, '.kimi', 'config.toml'), 'utf-8')).toBe('user config');

const logCalls = consoleSpy.mock.calls.flat().map(String);
expect(logCalls.some((entry) => entry.includes('.kimi/skills') && entry.includes('.kimi-code/skills'))).toBe(true);

consoleSpy.mockRestore();
});

it('should remove the legacy .kimi directory entirely when it only held OpenSpec skills', async () => {
const legacySkillDir = path.join(testDir, '.kimi', 'skills', 'openspec-explore');
await fs.mkdir(legacySkillDir, { recursive: true });
await fs.writeFile(
path.join(legacySkillDir, 'SKILL.md'),
`---\nname: openspec-explore\nmetadata:\n author: openspec\n version: "0.9"\n---\n\nOld instructions content\n`
);

await updateCommand.execute(testDir);

await expect(fs.access(path.join(testDir, '.kimi'))).rejects.toThrow();
const migratedSkill = path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md');
await expect(fs.access(migratedSkill)).resolves.toBeUndefined();
});

it('should update core profile skill files when tool is configured', async () => {
// Set up a configured tool with one skill directory
const skillsDir = path.join(testDir, '.claude', 'skills');
Expand Down
Loading