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
5 changes: 5 additions & 0 deletions .changeset/add-minimax-code-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": minor
---

Add MiniMax Code as a global skills-only tool target.
5 changes: 4 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,7 @@ Different AI tools use slightly different command syntax. Use the format that ma
|--------------------------|----------------|---------------|
| `.../commands/opsx/<id>.*` | `/opsx:propose`, `/opsx:apply` | Claude Code, Gemini CLI, Crush |
| `.../opsx-<id>.*` | `/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 |

Expand Down
11 changes: 9 additions & 2 deletions docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ way it loads the file OpenSpec wrote. Find your tool's command path in the
| `.../opsx-<id>.*` — the filename is the command | `/opsx-<id>` | Every other tool with generated command files, except Amazon Q and Devin |
| `.devin/workflows/opsx-<id>.md` — read by only one of Devin's two agents | `/opsx-<id>` on Devin Desktop, `/openspec-<skill>` on Devin Local | Devin Desktop\*\*\*\* |
| `.amazonq/prompts/opsx-<id>.md` — a prompt, not a command | `@opsx-<id>` | Amazon Q Developer |
| none — skills only | `/openspec-<skill>` | CodeArts, ForgeCode, Hermes, Mistral Vibe, shared `.agents` |
| none — skills only | `/openspec-<skill>` | CodeArts, ForgeCode, Hermes, MiniMax Code, Mistral Vibe, shared `.agents` |
| none — Kimi Code | `/skill:openspec-<skill>` | Kimi Code |
| none — Codex CLI | `$openspec-<skill>` | Codex ([`/openspec-<skill>` is not recognized](https://github.com/openai/codex/issues/11817)) |

Expand Down Expand Up @@ -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-<id>.prompt.md` |
| Lingma (`lingma`) | `.lingma/skills/openspec-*/SKILL.md` | `.lingma/commands/opsx/<id>.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-<id>.md` |
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(', ');
Expand Down
11 changes: 11 additions & 0 deletions src/core/available-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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' },
Expand Down
122 changes: 92 additions & 30 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -596,9 +609,10 @@ export class InitCommand {

private validateTools(
toolIds: string[],
toolStates: Map<string, ToolSkillStatus>
): Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> {
const validatedTools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> = [];
toolStates: Map<string, ToolSkillStatus>,
projectPath: string
): ValidatedInitTool[] {
const validatedTools: ValidatedInitTool[] = [];

for (const toolId of toolIds) {
const tool = AI_TOOLS.find((t) => t.value === toolId);
Expand All @@ -609,18 +623,23 @@ 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 ')}`
);
}

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,
});
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(', ')}`);
}
}
}

Expand Down Expand Up @@ -1034,7 +1096,7 @@ export class InitCommand {
}).start();
}

private async removeSkillDirs(projectPath: string, skillsDir: string): Promise<number> {
private async removeSkillDirs(skillsRoot: string, skillsDir: string): Promise<number> {
let removed = 0;

for (const workflow of ALL_WORKFLOWS) {
Expand All @@ -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++;
Expand Down
5 changes: 3 additions & 2 deletions src/core/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' */
Expand Down Expand Up @@ -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];
Expand Down
Loading
Loading