diff --git a/.changeset/add-minimax-code-skills.md b/.changeset/add-minimax-code-skills.md new file mode 100644 index 0000000000..3302667550 --- /dev/null +++ b/.changeset/add-minimax-code-skills.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": minor +--- + +Add MiniMax Code as a global skills-only tool target. diff --git a/docs/cli.md b/docs/cli.md index c83bfd25e4..b90951c40a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -107,7 +107,7 @@ openspec init [path] [options] The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set (any value, including empty), when `NO_COLOR` is set to a non-empty value, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled). -**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode`, `agents` +**Supported tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode`, `agents` > This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths. @@ -123,6 +123,9 @@ openspec init ./my-project # Non-interactive: configure for Claude and Cursor openspec init --tools claude,cursor +# Non-interactive: configure global MiniMax Code skills +openspec init --tools minimax-code + # Configure for all supported tools openspec init --tools all diff --git a/docs/commands.md b/docs/commands.md index 4c15d4e9eb..473df68228 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -673,7 +673,7 @@ Different AI tools use slightly different command syntax. Use the format that ma |--------------------------|----------------|---------------| | `.../commands/opsx/.*` | `/opsx:propose`, `/opsx:apply` | Claude Code, Gemini CLI, Crush | | `.../opsx-.*` | `/opsx-propose`, `/opsx-apply` | Cursor, Devin Desktop, Copilot (IDE), Trae, Oh My Pi | -| none — skills only | `/openspec-propose`, `/openspec-apply-change` | CodeArts, ForgeCode, Hermes, Mistral Vibe, shared `.agents` | +| none — skills only | `/openspec-propose`, `/openspec-apply-change` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, shared `.agents` | | none — Kimi Code | `/skill:openspec-propose` | Kimi Code | | none — Codex CLI | `$openspec-propose` | Codex | diff --git a/docs/supported-tools.md b/docs/supported-tools.md index eedac149b3..992e1a3c18 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -33,7 +33,7 @@ way it loads the file OpenSpec wrote. Find your tool's command path in the | `.../opsx-.*` — the filename is the command | `/opsx-` | Every other tool with generated command files, except Amazon Q and Devin | | `.devin/workflows/opsx-.md` — read by only one of Devin's two agents | `/opsx-` on Devin Desktop, `/openspec-` on Devin Local | Devin Desktop\*\*\*\* | | `.amazonq/prompts/opsx-.md` — a prompt, not a command | `@opsx-` | Amazon Q Developer | -| none — skills only | `/openspec-` | CodeArts, ForgeCode, Hermes, Mistral Vibe, shared `.agents` | +| none — skills only | `/openspec-` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, shared `.agents` | | none — Kimi Code | `/skill:openspec-` | Kimi Code | | none — Codex CLI | `$openspec-` | Codex ([`/openspec-` is not recognized](https://github.com/openai/codex/issues/11817)) | @@ -89,6 +89,7 @@ to read the hint. | 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-.prompt.md` | | Lingma (`lingma`) | `.lingma/skills/openspec-*/SKILL.md` | `.lingma/commands/opsx/.md` | +| MiniMax Code (`minimax-code`) | `~/.minimax/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use MiniMax Code skills) | | Mistral Vibe (`vibe`) | `.vibe/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | Oh My Pi (`oh-my-pi`) | `.omp/skills/openspec-*/SKILL.md` | `.omp/commands/opsx-.md` | | OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-.md` | @@ -106,6 +107,12 @@ to read the hint. \*\*\*\* Windsurf was [rebranded to Devin Desktop](https://docs.devin.ai/desktop/devin-desktop-faq) on June 2, 2026, and its config directory moved: `.devin/` is the preferred read + write location, `.windsurf/` a legacy read-only fallback. OpenSpec follows the rename — the tool id is `devin`, and `--tools windsurf` still resolves to it so existing setup scripts keep working. A project still holding OpenSpec files in `.windsurf/` is offered the move on the next `openspec update`; declining leaves them in place, and files you wrote yourself are never touched. Workflows are invoked by filename, so `.devin/workflows/opsx-apply.md` is `/opsx-apply`. The [Devin Local agent does not support workflows](https://docs.devin.ai/desktop/devin-local) — only skills, and it does not read `.windsurf/` at all — so whenever OpenSpec writes Devin skills it keeps their bodies, and the getting-started hint, on `/openspec-*` skill invocations, which work on both agents. Under commands-only delivery no skills are written and both fall back to `/opsx-*`. +MiniMax Code is a global skills-only integration. OpenSpec writes only its +`openspec-*` directories under `~/.minimax/skills/`; it does not create +repo-local `.minimax` or `.mavis` directories. Commands-only delivery leaves +existing global MiniMax Code skills untouched so one project's delivery setting +cannot remove skills used by another project. + ### When to pick the shared `.agents` target `agents` is the vendor-neutral option: it writes skills to `.agents/skills/`, the @@ -160,7 +167,7 @@ openspec init --tools none openspec init --profile core ``` -**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode`, `agents` +**Available tool IDs (`--tools`)** — `windsurf` is also accepted, as an alias for `devin`: `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `devin`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `minimax-code`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `zcode`, `agents` ## Workflow-Dependent Installation diff --git a/src/cli/index.ts b/src/cli/index.ts index 902c46d0a1..a20a1b4893 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -146,7 +146,9 @@ program.hook('postAction', async () => { await shutdown(); }); -const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value); +const availableToolIds = AI_TOOLS + .filter((tool) => tool.skillsDir || tool.globalSkillsDir) + .map((tool) => tool.value); const toolAliasNote = Object.entries(TOOL_ID_ALIASES) .map(([retired, current]) => `${retired} (now ${current})`) .join(', '); diff --git a/src/core/available-tools.ts b/src/core/available-tools.ts index f3dabe97da..cd2f9c6cda 100644 --- a/src/core/available-tools.ts +++ b/src/core/available-tools.ts @@ -8,6 +8,8 @@ import path from 'path'; import * as fs from 'fs'; import { AI_TOOLS, type AIToolOption } from './config.js'; +import { SKILL_NAMES } from './shared/tool-detection.js'; +import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js'; /** * Scans the project path for AI tool configuration directories and returns @@ -19,6 +21,15 @@ import { AI_TOOLS, type AIToolOption } from './config.js'; */ export function getAvailableTools(projectPath: string): AIToolOption[] { return AI_TOOLS.filter((tool) => { + if (!toolSupportsSkills(tool)) return false; + + if (tool.globalSkillsDir) { + const skillsDir = resolveToolSkillsDir(projectPath, tool); + return SKILL_NAMES.some((skillName) => + fs.existsSync(path.join(skillsDir, skillName, 'SKILL.md')) + ); + } + if (!tool.skillsDir) return false; if (tool.detectionPaths && tool.detectionPaths.length > 0) { diff --git a/src/core/config.ts b/src/core/config.ts index 8473ee5546..22f64139f5 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -15,6 +15,7 @@ export interface AIToolOption { available: boolean; successLabel?: string; skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec + globalSkillsDir?: string; // e.g., '.minimax' - /skills suffix, resolved from the user's home directory detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection setupNote?: string; // Manual setup required before the tool picks up generated files; shown after init/update } @@ -45,6 +46,7 @@ export const AI_TOOLS: AIToolOption[] = [ { 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: 'MiniMax Code', value: 'minimax-code', available: true, successLabel: 'MiniMax Code', globalSkillsDir: '.minimax' }, { name: 'Mistral Vibe', value: 'vibe', available: true, successLabel: 'Mistral Vibe', skillsDir: '.vibe' }, { name: 'Oh My Pi', value: 'oh-my-pi', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' }, { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' }, diff --git a/src/core/init.ts b/src/core/init.ts index de451d0dea..6ec4ea1c98 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -46,6 +46,9 @@ import { getSkillTemplates, getCommandContents, generateSkillContent, + hasGlobalSkillTarget, + resolveToolSkillsDir, + toolSupportsSkills, type ToolSkillStatus, } from './shared/index.js'; import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; @@ -104,6 +107,16 @@ type InitCommandOptions = { animation?: boolean; }; +type ValidatedInitTool = { + value: string; + name: string; + skillsDir?: string; + skillsPath: string; + skillsRoot: string; + isGlobalSkillTarget: boolean; + wasConfigured: boolean; +}; + /** * Holds the global Codex prompt matches that must wait until replacement skills * are generated before cleanup can continue. @@ -201,7 +214,7 @@ export class InitCommand { const selectedToolIds = await this.getSelectedTools(toolStates, extendMode, detectedTools, projectPath); // Validate selected tools - const validatedTools = this.validateTools(selectedToolIds, toolStates); + const validatedTools = this.validateTools(selectedToolIds, toolStates, projectPath); // Selecting a renamed tool is consent to leave its former directory: // init is about to write the current one, and leaving OpenSpec content @@ -596,9 +609,10 @@ export class InitCommand { private validateTools( toolIds: string[], - toolStates: Map - ): Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> { - const validatedTools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> = []; + toolStates: Map, + projectPath: string + ): ValidatedInitTool[] { + const validatedTools: ValidatedInitTool[] = []; for (const toolId of toolIds) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -609,7 +623,7 @@ export class InitCommand { ); } - if (!tool.skillsDir) { + if (!toolSupportsSkills(tool)) { const validToolsWithSkills = getToolsWithSkillsDir(); throw new Error( `Tool '${toolId}' does not support skill generation.\nTools with skill generation support:\n ${validToolsWithSkills.join('\n ')}` @@ -617,10 +631,15 @@ export class InitCommand { } const preState = toolStates.get(tool.value); + const skillsPath = resolveToolSkillsDir(projectPath, tool); + const isGlobalSkillTarget = hasGlobalSkillTarget(tool); validatedTools.push({ value: tool.value, name: tool.name, skillsDir: tool.skillsDir, + skillsPath, + skillsRoot: isGlobalSkillTarget ? skillsPath : projectPath, + isGlobalSkillTarget, wasConfigured: preState?.configured ?? false, }); } @@ -683,7 +702,7 @@ export class InitCommand { */ private async generateSkillsAndCommands( projectPath: string, - tools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> + tools: ValidatedInitTool[] ): Promise<{ createdTools: typeof tools; refreshedTools: typeof tools; @@ -722,12 +741,9 @@ export class InitCommand { // Generate skill files if the selected delivery and tool capability allow skills if (shouldGenerateSkills) { - // Use tool-specific skillsDir - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - // Create skill directories and SKILL.md files for (const { template, dirName } of skillTemplates) { - const skillDir = path.join(skillsDir, dirName); + const skillDir = path.join(tool.skillsPath, dirName); const skillFile = path.join(skillDir, 'SKILL.md'); // Generate SKILL.md content with YAML frontmatter including generatedBy @@ -740,13 +756,12 @@ export class InitCommand { const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); // Write the skill file - FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile); + FileSystemUtils.assertPathWithin(tool.skillsRoot, skillFile); await FileSystemUtils.writeFile(skillFile, skillContent); } } - if (shouldRemoveSkillsForTool(tool.value, delivery)) { - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - removedSkillCount += await this.removeSkillDirs(projectPath, skillsDir); + if (shouldRemoveSkillsForTool(tool.value, delivery) && !tool.isGlobalSkillTarget) { + removedSkillCount += await this.removeSkillDirs(tool.skillsRoot, tool.skillsPath); } // Generate commands if delivery includes commands @@ -829,7 +844,7 @@ export class InitCommand { private displaySuccessMessage( projectPath: string, - tools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }>, + tools: ValidatedInitTool[], results: { createdTools: typeof tools; refreshedTools: typeof tools; @@ -864,19 +879,66 @@ export class InitCommand { const profile: Profile = (this.profileOverride as Profile) ?? globalConfig.profile ?? 'core'; const delivery: Delivery = globalConfig.delivery ?? 'both'; const workflows = getProfileWorkflows(profile, globalConfig.workflows); - const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', '); - const skillCount = successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, delivery)) - ? getSkillTemplates(workflows).length - : 0; - const commandCount = successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, delivery)) - ? getCommandContents(workflows).length - : 0; - if (skillCount > 0 && commandCount > 0) { - console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`); - } else if (skillCount > 0) { - console.log(`${skillCount} skills in ${toolDirs}/`); - } else if (commandCount > 0) { - console.log(`${commandCount} commands in ${toolDirs}/`); + const usesGlobalSkillTarget = successfulTools.some((tool) => tool.isGlobalSkillTarget); + + if (!usesGlobalSkillTarget) { + const toolDirs = [ + ...new Set( + successfulTools + .map((tool) => tool.skillsDir) + .filter((skillsDir): skillsDir is string => Boolean(skillsDir)) + ), + ].join(', '); + const skillCount = successfulTools.some((tool) => + shouldGenerateSkillsForTool(tool.value, delivery) + ) + ? getSkillTemplates(workflows).length + : 0; + const commandCount = successfulTools.some((tool) => + shouldGenerateCommandsForTool(tool.value, delivery) + ) + ? getCommandContents(workflows).length + : 0; + if (skillCount > 0 && commandCount > 0) { + console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`); + } else if (skillCount > 0) { + console.log(`${skillCount} skills in ${toolDirs}/`); + } else if (commandCount > 0) { + console.log(`${commandCount} commands in ${toolDirs}/`); + } + } else { + const skillTools = successfulTools.filter((tool) => + shouldGenerateSkillsForTool(tool.value, delivery) + ); + const skillCount = skillTools.length * getSkillTemplates(workflows).length; + if (skillCount > 0) { + const skillDirs = [...new Set(skillTools.map((tool) => tool.skillsPath))]; + console.log(`${skillCount} skills in ${skillDirs.join(', ')}`); + } + + const commandContents = getCommandContents(workflows); + const commandTools = successfulTools.filter((tool) => + shouldGenerateCommandsForTool(tool.value, delivery) + ); + const commandCount = commandTools.length * commandContents.length; + if (commandCount > 0) { + const commandDirs = [ + ...new Set( + commandTools.flatMap((tool) => { + const adapter = CommandAdapterRegistry.get(tool.value); + if (!adapter) return []; + return commandContents.map((command) => { + const commandPath = adapter.getFilePath(command.id); + const absolutePath = path.isAbsolute(commandPath) + ? commandPath + : path.join(projectPath, commandPath); + return path.dirname(absolutePath); + }); + }) + ), + ]; + console.log(`${commandCount} commands in ${commandDirs.join(', ')}`); + } } } @@ -1034,7 +1096,7 @@ export class InitCommand { }).start(); } - private async removeSkillDirs(projectPath: string, skillsDir: string): Promise { + private async removeSkillDirs(skillsRoot: string, skillsDir: string): Promise { let removed = 0; for (const workflow of ALL_WORKFLOWS) { @@ -1043,7 +1105,7 @@ export class InitCommand { const skillDir = path.join(skillsDir, dirName); if (!fs.existsSync(skillDir)) continue; - FileSystemUtils.assertProjectArtifactPath(projectPath, skillDir); + FileSystemUtils.assertPathWithin(skillsRoot, skillDir); try { await fs.promises.rm(skillDir, { recursive: true, force: true }); removed++; diff --git a/src/core/migration.ts b/src/core/migration.ts index 186af7f0d4..74a92eefad 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -19,6 +19,7 @@ import { ALL_WORKFLOWS } from './profiles.js'; import { getSkillReferenceTransformer, getTransformerForTool } from '../utils/command-references.js'; import path from 'path'; import * as fs from 'fs'; +import { resolveToolSkillsDir, toolSupportsSkills } from './shared/skill-paths.js'; export interface LegacyToolRoot { /** Former tool root, e.g. '.kimi' */ @@ -377,8 +378,8 @@ function scanInstalledWorkflowArtifacts( let hasCommands = false; for (const tool of tools) { - if (!tool.skillsDir) continue; - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + if (!toolSupportsSkills(tool)) continue; + const skillsDir = resolveToolSkillsDir(projectPath, tool); for (const workflowId of ALL_WORKFLOWS) { const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId]; diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 65fa539b0f..5f5d260959 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -11,6 +11,11 @@ import { shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js'; +import { + hasGlobalSkillTarget, + resolveToolSkillsDir, + toolSupportsSkills, +} from './shared/skill-paths.js'; type WorkflowId = (typeof ALL_WORKFLOWS)[number]; @@ -61,11 +66,11 @@ export function hasToolProfileOrDeliveryDrift( delivery: Delivery ): boolean { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) return false; + if (!tool || !toolSupportsSkills(tool)) return false; const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows); const desiredWorkflowSet = new Set(knownDesiredWorkflows); - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectPath, tool); const adapter = CommandAdapterRegistry.get(toolId); const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery); const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery); @@ -88,7 +93,7 @@ export function hasToolProfileOrDeliveryDrift( return true; } } - } else if (shouldRemoveSkillsForTool(toolId, delivery)) { + } else if (shouldRemoveSkillsForTool(toolId, delivery) && !hasGlobalSkillTarget(tool)) { for (const workflow of ALL_WORKFLOWS) { const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; const skillDir = path.join(skillsDir, dirName); @@ -150,10 +155,10 @@ function getInstalledWorkflowsForTool( options: { includeSkills: boolean; includeCommands: boolean } ): WorkflowId[] { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) return []; + if (!tool || !toolSupportsSkills(tool)) return []; const installed = new Set(); - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectPath, tool); if (options.includeSkills) { for (const workflow of ALL_WORKFLOWS) { diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 32b965696a..53533b6907 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -28,3 +28,11 @@ export { getCommandContents, generateSkillContent, } from './skill-generation.js'; + +export { + type SkillCapableTool, + toolSupportsSkills, + getSkillCapableTools, + hasGlobalSkillTarget, + resolveToolSkillsDir, +} from './skill-paths.js'; diff --git a/src/core/shared/skill-paths.ts b/src/core/shared/skill-paths.ts new file mode 100644 index 0000000000..ceca0a8294 --- /dev/null +++ b/src/core/shared/skill-paths.ts @@ -0,0 +1,38 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { AI_TOOLS, type AIToolOption } from '../config.js'; + +export type SkillCapableTool = AIToolOption & ( + | { skillsDir: string } + | { globalSkillsDir: string } +); + +export function toolSupportsSkills(tool: AIToolOption): tool is SkillCapableTool { + return Boolean(tool.skillsDir || tool.globalSkillsDir); +} + +export function getSkillCapableTools(): SkillCapableTool[] { + return AI_TOOLS.filter(toolSupportsSkills); +} + +export function hasGlobalSkillTarget(tool: AIToolOption): boolean { + return Boolean(tool.globalSkillsDir); +} + +export function resolveToolSkillsDir( + projectRoot: string, + tool: SkillCapableTool, + options: { homeDir?: string } = {} +): string { + if (tool.globalSkillsDir) { + const homeDir = options.homeDir ?? process.env.USERPROFILE ?? process.env.HOME ?? os.homedir(); + return path.join(homeDir, tool.globalSkillsDir, 'skills'); + } + + if (tool.skillsDir) { + return path.join(projectRoot, tool.skillsDir, 'skills'); + } + + throw new Error(`Tool '${tool.value}' does not support skill generation.`); +} diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index b6efdc3790..8945068456 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -11,6 +11,11 @@ import { CommandAdapterRegistry, generateCommands } from '../command-generation/ import { getCommandContents } from './skill-generation.js'; import { getGlobalConfig } from '../global-config.js'; import { getProfileWorkflows, ALL_WORKFLOWS } from '../profiles.js'; +import { + getSkillCapableTools, + resolveToolSkillsDir, + toolSupportsSkills, +} from './skill-paths.js'; /** * Names of skill directories created by openspec init. @@ -88,7 +93,7 @@ export interface ToolVersionStatus { * Gets the list of tools with skillsDir configured. */ export function getToolsWithSkillsDir(): string[] { - return AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value); + return getSkillCapableTools().map((tool) => tool.value); } /** @@ -96,11 +101,11 @@ export function getToolsWithSkillsDir(): string[] { */ export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSkillStatus { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) { + if (!tool || !toolSupportsSkills(tool)) { return { configured: false, fullyConfigured: false, skillCount: 0 }; } - const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectRoot, tool); let skillCount = 0; for (const skillName of SKILL_NAMES) { @@ -218,7 +223,7 @@ export function areCommandFilesUpToDate( */ export function getToolStates(projectRoot: string): Map { const states = new Map(); - const toolIds = AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value); + const toolIds = getToolsWithSkillsDir(); for (const toolId of toolIds) { states.set(toolId, getToolSkillStatus(projectRoot, toolId)); @@ -273,7 +278,7 @@ export function getToolVersionStatus( } ): ToolVersionStatus { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) { + if (!tool || !toolSupportsSkills(tool)) { return { toolId, toolName: toolId, @@ -283,7 +288,7 @@ export function getToolVersionStatus( }; } - const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectRoot, tool); let generatedByVersion: string | null = null; // 1. Find the first skill file that exists and read its version @@ -323,7 +328,7 @@ export function getToolVersionStatus( export function getConfiguredTools(projectRoot: string): string[] { return AI_TOOLS .filter((t) => { - if (!t.skillsDir) return false; + if (!toolSupportsSkills(t)) return false; return getToolSkillStatus(projectRoot, t.value).configured || toolHasAnyConfiguredCommand(projectRoot, t.value); }) .map((t) => t.value); diff --git a/src/core/update.ts b/src/core/update.ts index e1c9fdf758..dda898fb13 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -23,6 +23,9 @@ import { getCommandContents, generateSkillContent, getToolsWithSkillsDir, + hasGlobalSkillTarget, + resolveToolSkillsDir, + toolSupportsSkills, type ToolVersionStatus, } from './shared/index.js'; import { @@ -204,7 +207,14 @@ export class UpdateCommand { // 7. Smart update detection const toolsNeedingVersionUpdate = toolStatuses - .filter((s) => s.needsUpdate) + .filter((s) => { + if (!s.needsUpdate || delivery !== 'commands') { + return s.needsUpdate; + } + + const tool = AI_TOOLS.find((candidate) => candidate.value === s.toolId); + return !tool || !hasGlobalSkillTarget(tool); + }) .map((s) => s.toolId); const toolsNeedingConfigSync = getToolsNeedingProfileSync( resolvedProjectPath, @@ -259,12 +269,13 @@ export class UpdateCommand { for (const toolId of toolsToUpdate) { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) continue; + if (!tool || !toolSupportsSkills(tool)) continue; const spinner = ora(`Updating ${tool.name}...`).start(); try { - const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(resolvedProjectPath, tool); + const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : resolvedProjectPath; const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery); const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery); const toolWorkflows = legacyWorkflowOverrides[tool.value] ?? desiredWorkflows; @@ -284,20 +295,20 @@ export class UpdateCommand { resolveCommandInvocation(tool.value) ); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); - FileSystemUtils.assertProjectArtifactPath(resolvedProjectPath, skillFile); + FileSystemUtils.assertPathWithin(skillsRoot, skillFile); await FileSystemUtils.writeFile(skillFile, skillContent); } removedDeselectedSkillCount += await this.removeUnselectedSkillDirs( - resolvedProjectPath, + skillsRoot, skillsDir, toolWorkflows ); } // Delete skill directories if delivery is commands-only - if (shouldRemoveSkillsForTool(tool.value, delivery)) { - removedSkillCount += await this.removeSkillDirs(resolvedProjectPath, skillsDir); + if (shouldRemoveSkillsForTool(tool.value, delivery) && !hasGlobalSkillTarget(tool)) { + removedSkillCount += await this.removeSkillDirs(skillsRoot, skillsDir); // A tool with no command adapter now has zero OpenSpec artifacts; // say so like init does, rather than deleting its skills silently // and letting tool detection re-suggest an init that would also @@ -590,7 +601,7 @@ export class UpdateCommand { * Removes skill directories for workflows when delivery changed to commands-only. * Returns the number of directories removed. */ - private async removeSkillDirs(projectPath: string, skillsDir: string): Promise { + private async removeSkillDirs(skillsRoot: string, skillsDir: string): Promise { let removed = 0; for (const workflow of ALL_WORKFLOWS) { @@ -599,7 +610,7 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); if (!fs.existsSync(skillDir)) continue; - FileSystemUtils.assertProjectArtifactPath(projectPath, skillDir); + FileSystemUtils.assertPathWithin(skillsRoot, skillDir); try { await fs.promises.rm(skillDir, { recursive: true, force: true }); removed++; @@ -616,7 +627,7 @@ export class UpdateCommand { * Returns the number of directories removed. */ private async removeUnselectedSkillDirs( - projectPath: string, + skillsRoot: string, skillsDir: string, desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][] ): Promise { @@ -630,7 +641,7 @@ export class UpdateCommand { const skillDir = path.join(skillsDir, dirName); if (!fs.existsSync(skillDir)) continue; - FileSystemUtils.assertProjectArtifactPath(projectPath, skillDir); + FileSystemUtils.assertPathWithin(skillsRoot, skillDir); try { await fs.promises.rm(skillDir, { recursive: true, force: true }); removed++; @@ -1025,12 +1036,13 @@ export class UpdateCommand { for (const toolId of selectedTools) { const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) continue; + if (!tool || !toolSupportsSkills(tool)) continue; const spinner = ora(`Setting up ${tool.name}...`).start(); try { - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveToolSkillsDir(projectPath, tool); + const skillsRoot = hasGlobalSkillTarget(tool) ? skillsDir : projectPath; const shouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery); const shouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery); const toolWorkflows = ( @@ -1057,7 +1069,7 @@ export class UpdateCommand { resolveCommandInvocation(tool.value) ); const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); - FileSystemUtils.assertProjectArtifactPath(projectPath, skillFile); + FileSystemUtils.assertPathWithin(skillsRoot, skillFile); await FileSystemUtils.writeFile(skillFile, skillContent); } } diff --git a/test/cli-e2e/basic.test.ts b/test/cli-e2e/basic.test.ts index ca841d7f16..81eff84388 100644 --- a/test/cli-e2e/basic.test.ts +++ b/test/cli-e2e/basic.test.ts @@ -133,9 +133,10 @@ describe('openspec CLI e2e basics', () => { await fs.mkdir(emptyProjectDir, { recursive: true }); const codexHome = path.join(emptyProjectDir, '.codex'); + const testHome = path.join(emptyProjectDir, 'home'); const result = await runCLI(['init', '--tools', 'all'], { cwd: emptyProjectDir, - env: { CODEX_HOME: codexHome }, + env: { CODEX_HOME: codexHome, HOME: testHome, USERPROFILE: testHome }, timeoutMs: 20000, }); expect(result.timedOut).toBe(false); @@ -145,8 +146,13 @@ describe('openspec CLI e2e basics', () => { // Check that skills were created for multiple tools const claudeSkillPath = path.join(emptyProjectDir, '.claude/skills/openspec-explore/SKILL.md'); const cursorSkillPath = path.join(emptyProjectDir, '.cursor/skills/openspec-explore/SKILL.md'); + const minimaxSkillPath = path.join( + testHome, + '.minimax/skills/openspec-explore/SKILL.md' + ); expect(await fileExists(claudeSkillPath)).toBe(true); expect(await fileExists(cursorSkillPath)).toBe(true); + expect(await fileExists(minimaxSkillPath)).toBe(true); }, 25000); it('initializes with --tools list option', async () => { diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index e071148dd6..7deb1ddd90 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; @@ -9,9 +9,12 @@ describe('available-tools', () => { beforeEach(async () => { testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); + vi.stubEnv('HOME', path.join(testDir, 'home')); + vi.stubEnv('USERPROFILE', path.join(testDir, 'home')); }); afterEach(async () => { + vi.unstubAllEnvs(); await fs.rm(testDir, { recursive: true, force: true }); }); @@ -31,6 +34,34 @@ describe('available-tools', () => { expect(tools[0].skillsDir).toBe('.claude'); }); + it('should detect MiniMax Code only from managed skills in the user-home target', async () => { + const globalSkill = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(globalSkill), { recursive: true }); + await fs.writeFile(globalSkill, 'content'); + + expect(getAvailableTools(testDir).map((tool) => tool.value)).toContain('minimax-code'); + + await fs.rm(path.join(testDir, 'home'), { recursive: true, force: true }); + const localSkill = path.join( + testDir, + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(localSkill), { recursive: true }); + await fs.writeFile(localSkill, 'content'); + + expect(getAvailableTools(testDir).map((tool) => tool.value)).not.toContain('minimax-code'); + }); + it('should detect multiple tool directories', async () => { await fs.mkdir(path.join(testDir, '.claude'), { recursive: true }); await fs.mkdir(path.join(testDir, '.cursor'), { recursive: true }); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 8a873ba142..18cf2e83f7 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -35,6 +35,8 @@ describe('InitCommand', () => { configTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-config-init-')); process.env.XDG_CONFIG_HOME = configTempDir; process.env.CODEX_HOME = path.join(testDir, 'codex-home'); + process.env.HOME = path.join(testDir, 'home'); + process.env.USERPROFILE = path.join(testDir, 'home'); // Mock console.log to suppress output during tests vi.spyOn(console, 'log').mockImplementation(() => { }); @@ -216,6 +218,30 @@ describe('InitCommand', () => { expect((await fs.lstat(skillFile)).isSymbolicLink()).toBe(true); }); + it('should not write MiniMax skills through a linked directory outside the global skills root', async () => { + const outsideDir = path.join(configTempDir, 'outside-minimax'); + const skillsRoot = path.join(testDir, 'home', '.minimax', 'skills'); + const linkedSkillDir = path.join(skillsRoot, 'openspec-propose'); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.mkdir(skillsRoot, { recursive: true }); + await fs.symlink( + outsideDir, + linkedSkillDir, + process.platform === 'win32' ? 'junction' : 'dir' + ); + + const initCommand = new InitCommand({ tools: 'minimax-code', force: true }); + await expect(initCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec setup failed for: MiniMax Code' + ); + + expect(await fs.readdir(outsideDir)).toEqual([]); + expect((await fs.lstat(linkedSkillDir)).isSymbolicLink()).toBe(true); + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'OpenSpec Setup Incomplete' + ); + }); + it('should generate safe Claude workflow guidance (#1493)', async () => { const initCommand = new InitCommand({ tools: 'claude', force: true }); @@ -387,6 +413,68 @@ describe('InitCommand', () => { ).toBe(true); }); + it('should install MiniMax Code skills only in the user-home target', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const initCommand = new InitCommand({ tools: 'minimax-code', force: true }); + await initCommand.execute(testDir); + + const skillFile = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + expect(await fileExists(skillFile)).toBe(true); + expect(await directoryExists(path.join(testDir, '.minimax'))).toBe(false); + expect(await directoryExists(path.join(testDir, '.mavis'))).toBe(false); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls + .flat() + .map(String); + expect( + logCalls.some( + (entry) => + entry.includes('Commands skipped for: minimax-code') && + entry.includes('(no adapter)') + ) + ).toBe(true); + expect( + logCalls.some((entry) => entry.includes('commands in') && entry.includes('.minimax')) + ).toBe(false); + }); + + it('should preserve global MiniMax Code skills for commands-only delivery', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillFile = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, 'existing global skill'); + + const initCommand = new InitCommand({ tools: 'minimax-code', force: true }); + await initCommand.execute(testDir); + + expect(await fs.readFile(skillFile, 'utf-8')).toBe('existing global skill'); + expect(await directoryExists(path.join(testDir, '.minimax'))).toBe(false); + }); + it('should support Kimi Code as an adapterless skills-only tool', async () => { saveGlobalConfig({ featureFlags: {}, @@ -932,6 +1020,8 @@ describe('InitCommand - profile and detection features', () => { configTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-config-test-')); process.env.XDG_CONFIG_HOME = configTempDir; process.env.CODEX_HOME = path.join(testDir, 'codex-home'); + process.env.HOME = path.join(testDir, 'home'); + process.env.USERPROFILE = path.join(testDir, 'home'); vi.spyOn(console, 'log').mockImplementation(() => {}); confirmMock.mockReset(); confirmMock.mockResolvedValue(true); diff --git a/test/core/profile-sync-drift.test.ts b/test/core/profile-sync-drift.test.ts index 39f41ca99b..f1da9a5399 100644 --- a/test/core/profile-sync-drift.test.ts +++ b/test/core/profile-sync-drift.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -43,9 +43,12 @@ describe('profile sync drift detection', () => { beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-profile-sync-drift-test-')); fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + vi.stubEnv('HOME', path.join(tempDir, 'home')); + vi.stubEnv('USERPROFILE', path.join(tempDir, 'home')); }); afterEach(() => { + vi.unstubAllEnvs(); fs.rmSync(tempDir, { recursive: true, force: true }); }); @@ -65,6 +68,21 @@ describe('profile sync drift detection', () => { expect(hasDrift).toBe(true); }); + it('does not remove global MiniMax Code skills for commands-only delivery', () => { + const skillPath = path.join( + tempDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + fs.mkdirSync(path.dirname(skillPath), { recursive: true }); + fs.writeFileSync(skillPath, 'name: openspec-explore\n'); + + expect(hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'commands')).toBe(false); + }); + it('detects drift when required profile workflow files are missing', () => { writeSkill(tempDir, 'explore'); diff --git a/test/core/shared/skill-paths.test.ts b/test/core/shared/skill-paths.test.ts new file mode 100644 index 0000000000..a2a1157a3a --- /dev/null +++ b/test/core/shared/skill-paths.test.ts @@ -0,0 +1,37 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { AI_TOOLS } from '../../../src/core/config.js'; +import { + getSkillCapableTools, + resolveToolSkillsDir, + toolSupportsSkills, +} from '../../../src/core/shared/skill-paths.js'; + +describe('skill-paths', () => { + it('includes project-local and global skill targets', () => { + const toolIds = getSkillCapableTools().map((tool) => tool.value); + expect(toolIds).toContain('claude'); + expect(toolIds).toContain('minimax-code'); + }); + + it('resolves project-local skills under the project root', () => { + const claude = AI_TOOLS.find((tool) => tool.value === 'claude'); + expect(claude && toolSupportsSkills(claude)).toBe(true); + if (!claude || !toolSupportsSkills(claude)) return; + + expect(resolveToolSkillsDir('/repo/app', claude)).toBe( + path.join('/repo/app', '.claude', 'skills') + ); + }); + + it('resolves MiniMax Code skills under the supplied user home', () => { + const minimax = AI_TOOLS.find((tool) => tool.value === 'minimax-code'); + expect(minimax && toolSupportsSkills(minimax)).toBe(true); + if (!minimax || !toolSupportsSkills(minimax)) return; + + expect(resolveToolSkillsDir('/repo/app', minimax, { homeDir: '/home/alex' })).toBe( + path.join('/home/alex', '.minimax', 'skills') + ); + }); +}); diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index c4f955f5c8..9e1f0ffbd6 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -19,6 +19,8 @@ describe('tool-detection', () => { beforeEach(async () => { testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); vi.stubEnv('XDG_CONFIG_HOME', path.join(testDir, 'config')); + vi.stubEnv('HOME', path.join(testDir, 'home')); + vi.stubEnv('USERPROFILE', path.join(testDir, 'home')); }); afterEach(async () => { @@ -54,6 +56,7 @@ describe('tool-detection', () => { // `--tools all` resolves to exactly this list, so `agents` being here is what // puts the shared target in an `--tools all` run. expect(tools).toContain('agents'); + expect(tools).toContain('minimax-code'); expect(tools.length).toBeGreaterThan(0); }); }); @@ -96,6 +99,38 @@ describe('tool-detection', () => { expect(status.fullyConfigured).toBe(true); expect(status.skillCount).toBe(SKILL_NAMES.length); }); + + it('should detect MiniMax Code only from its global OpenSpec skill target', async () => { + const globalSkill = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(globalSkill), { recursive: true }); + await fs.writeFile(globalSkill, 'test content'); + + expect(getToolSkillStatus(testDir, 'minimax-code')).toMatchObject({ + configured: true, + fullyConfigured: false, + skillCount: 1, + }); + + await fs.rm(path.join(testDir, 'home'), { recursive: true, force: true }); + const localSkill = path.join( + testDir, + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(localSkill), { recursive: true }); + await fs.writeFile(localSkill, 'test content'); + + expect(getToolSkillStatus(testDir, 'minimax-code').configured).toBe(false); + }); }); describe('getToolStates', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index d8a8b57a4c..ed99a42647 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -48,6 +48,8 @@ describe('UpdateCommand', () => { // Create a temporary test directory testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-test-')); process.env.CODEX_HOME = path.join(testDir, 'codex-home'); + process.env.HOME = path.join(testDir, 'home'); + process.env.USERPROFILE = path.join(testDir, 'home'); // Create openspec directory const openspecDir = path.join(testDir, 'openspec'); @@ -157,6 +159,97 @@ Old instructions content consoleSpy.mockRestore(); }); + it('should update MiniMax Code skills without touching unrelated global skills', async () => { + const skillsDir = path.join(testDir, 'home', '.minimax', 'skills'); + const exploreSkill = path.join(skillsDir, 'openspec-explore', 'SKILL.md'); + const customSkill = path.join(skillsDir, 'my-custom-skill', 'SKILL.md'); + await fs.mkdir(path.dirname(exploreSkill), { recursive: true }); + await fs.writeFile(exploreSkill, 'old content'); + await fs.mkdir(path.dirname(customSkill), { recursive: true }); + await fs.writeFile(customSkill, 'custom content'); + + await updateCommand.execute(testDir); + + expect(await fs.readFile(exploreSkill, 'utf-8')).toContain('name: openspec-explore'); + expect(await fs.readFile(customSkill, 'utf-8')).toBe('custom content'); + expect(await FileSystemUtils.directoryExists(path.join(testDir, '.minimax'))).toBe(false); + expect(await FileSystemUtils.directoryExists(path.join(testDir, '.mavis'))).toBe(false); + }); + + it('should not update MiniMax skills through a linked directory outside the global skills root', async () => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-minimax-outside-')); + const skillsRoot = path.join(testDir, 'home', '.minimax', 'skills'); + const linkedSkillDir = path.join(skillsRoot, 'openspec-explore'); + const skillFile = path.join(outsideDir, 'SKILL.md'); + const oldSkillContent = `--- +name: openspec-explore +metadata: + author: openspec + version: "0.9" +--- + +Outside content +`; + await fs.mkdir(skillsRoot, { recursive: true }); + await fs.writeFile(skillFile, oldSkillContent); + + try { + await fs.symlink( + outsideDir, + linkedSkillDir, + process.platform === 'win32' ? 'junction' : 'dir' + ); + + await expect(updateCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: MiniMax Code' + ); + + expect(await fs.readFile(skillFile, 'utf-8')).toBe(oldSkillContent); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('should not delete MiniMax skills through a linked directory outside the global skills root', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + workflows: ['propose'], + delivery: 'skills', + }); + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-minimax-outside-')); + const skillsRoot = path.join(testDir, 'home', '.minimax', 'skills'); + const linkedSkillDir = path.join(skillsRoot, 'openspec-explore'); + const skillFile = path.join(outsideDir, 'SKILL.md'); + const oldSkillContent = `--- +name: openspec-explore +metadata: + author: openspec + version: "0.9" +--- + +Outside content +`; + await fs.mkdir(skillsRoot, { recursive: true }); + await fs.writeFile(skillFile, oldSkillContent); + + try { + await fs.symlink( + outsideDir, + linkedSkillDir, + process.platform === 'win32' ? 'junction' : 'dir' + ); + + await expect(updateCommand.execute(testDir)).rejects.toThrow( + 'OpenSpec update failed for: MiniMax Code' + ); + + expect(await fs.readFile(skillFile, 'utf-8')).toBe(oldSkillContent); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + it('should not update generated artifacts through a linked tool directory outside the project', async () => { const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-update-outside-')); const skillFile = path.join( @@ -2557,6 +2650,35 @@ More user content after markers. )).toBe(true); }); + it('should preserve global MiniMax Code skills in commands-only delivery', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillFile = path.join( + testDir, + 'home', + '.minimax', + 'skills', + 'openspec-explore', + 'SKILL.md' + ); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, 'existing global skill'); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await updateCommand.execute(testDir); + + expect(await fs.readFile(skillFile, 'utf-8')).toBe('existing global skill'); + expect(await FileSystemUtils.directoryExists(path.join(testDir, '.minimax'))).toBe(false); + const output = consoleSpy.mock.calls.flat().join('\n'); + expect(output).toContain('up to date'); + expect(output).not.toContain('Updated: MiniMax Code'); + consoleSpy.mockRestore(); + }); + it('should remove skills for configured tools without command adapters in commands-only delivery', async () => { setMockConfig({ featureFlags: {},