Skip to content
Closed
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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ AI coding assistants are powerful but unpredictable when requirements live only
- **Agree before you build** — human and AI align on specs before code gets written
- **Stay organized** — each change gets its own folder with proposal, specs, design, and tasks
- **Work fluidly** — update any artifact anytime, no rigid phase gates
- **Use your tools** — works with 20+ AI assistants via slash commands
- **Use your tools** — works with 20+ AI assistants via skills and slash commands

### How we compare

Expand All @@ -149,12 +149,17 @@ npm install -g @fission-ai/openspec@latest

**Refresh agent instructions**

Run this inside each project to regenerate AI guidance and ensure the latest slash commands are active:
Run this inside each project to regenerate AI guidance and ensure the latest skills and/or slash commands (tool-dependent) are active:

```bash
openspec update
```

After refresh, use the invocation style for your tool:

- Codex skills: `$openspec-propose "your idea"`
- Non-Codex slash-command tools: `/opsx:propose "your idea"`

## Usage Notes

**Model selection**: OpenSpec works best with high-reasoning models. We recommend Codex 5.5 and Opus 4.7 for both planning and implementation.
Expand Down
10 changes: 4 additions & 6 deletions docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ OpenSpec works with many AI coding assistants. When you run `openspec init`, Ope
For each selected tool, OpenSpec can install:

1. **Skills** (if delivery includes skills): `.../skills/openspec-*/SKILL.md`
2. **Commands** (if delivery includes commands): tool-specific `opsx-*` command files
2. **Commands** (if delivery includes commands): tool-specific `opsx-*` command files, except Codex where custom prompts are deprecated

By default, OpenSpec uses the `core` profile, which includes:
- `propose`
Expand All @@ -29,15 +29,15 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch
| Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/<id>.md` |
| Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-<id>.md` |
| CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/<id>.md` |
| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | `$CODEX_HOME/prompts/opsx-<id>.md`\* |
| Codex (`codex`) | `.codex/skills/openspec-*/SKILL.md` | Not generated (Codex custom prompts are deprecated; use `$openspec-*` skills) |
| ForgeCode (`forgecode`) | `.forge/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
| Continue (`continue`) | `.continue/skills/openspec-*/SKILL.md` | `.continue/prompts/opsx-<id>.prompt` |
| CoStrict (`costrict`) | `.cospec/skills/openspec-*/SKILL.md` | `.cospec/openspec/commands/opsx-<id>.md` |
| Crush (`crush`) | `.crush/skills/openspec-*/SKILL.md` | `.crush/commands/opsx/<id>.md` |
| Cursor (`cursor`) | `.cursor/skills/openspec-*/SKILL.md` | `.cursor/commands/opsx-<id>.md` |
| Factory Droid (`factory`) | `.factory/skills/openspec-*/SKILL.md` | `.factory/commands/opsx-<id>.md` |
| Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/<id>.toml` |
| GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-<id>.prompt.md`\*\* |
| GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-<id>.prompt.md`\* |
| 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` |
Expand All @@ -53,9 +53,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch
| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
| Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-<id>.md` |

\* Codex commands are installed in the global Codex home (`$CODEX_HOME/prompts/` if set, otherwise `~/.codex/prompts/`), not your project directory.

\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly.
\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly.

## Non-Interactive Setup

Expand Down
11 changes: 5 additions & 6 deletions src/core/command-generation/adapters/codex.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/**
* Codex Command Adapter
* Codex Legacy Prompt Adapter
*
* Formats commands for Codex following its frontmatter specification.
* Codex custom prompts live in the global home directory (~/.codex/prompts/)
* and are not shared through the repository. The CODEX_HOME env var can
* override the default ~/.codex location.
* Codex custom prompts are deprecated and are no longer generated for new
* setups. This adapter is kept so update/init can find and remove previously
* generated opsx prompt files from the global Codex home.
*/

import os from 'os';
Expand All @@ -21,7 +20,7 @@ function getCodexHome(): string {
}

/**
* Codex adapter for command generation.
* Codex adapter for legacy prompt cleanup.
* File path: <CODEX_HOME>/prompts/opsx-<id>.md (absolute, global)
* Frontmatter: description, argument-hint
*/
Expand Down
74 changes: 53 additions & 21 deletions src/core/init.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Init Command
*
* Sets up OpenSpec with Agent Skills and /opsx:* slash commands.
* Sets up OpenSpec with Agent Skills and supported tool command surfaces.
* This is the unified setup command that replaces both the old init and experimental commands.
*/

Expand All @@ -11,12 +11,16 @@ import ora from 'ora';
import * as fs from 'fs';
import { createRequire } from 'module';
import { FileSystemUtils } from '../utils/file-system.js';
import { transformToHyphenCommands } from '../utils/command-references.js';
import {
AI_TOOLS,
OPENSPEC_DIR_NAME,
AIToolOption,
} from './config.js';
import {
getSkillInstructionTransformer,
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
} from './tool-delivery.js';
import { PALETTE } from './styles/palette.js';
import { isInteractive } from '../utils/interactive.js';
import { serializeConfig } from './config-prompts.js';
Expand Down Expand Up @@ -516,18 +520,19 @@ export class InitCommand {
const workflows = getProfileWorkflows(profile, globalConfig.workflows);

// Get skill and command templates filtered by profile workflows
const shouldGenerateSkills = delivery !== 'commands';
const shouldGenerateCommands = delivery !== 'skills';
const skillTemplates = shouldGenerateSkills ? getSkillTemplates(workflows) : [];
const commandContents = shouldGenerateCommands ? getCommandContents(workflows) : [];
const skillTemplates = getSkillTemplates(workflows);
const commandContents = getCommandContents(workflows);

// Process each tool
for (const tool of tools) {
const spinner = ora(`Setting up ${tool.name}...`).start();

try {
const toolShouldGenerateSkills = shouldGenerateSkillsForTool(tool.value, delivery);
const toolShouldGenerateCommands = shouldGenerateCommandsForTool(tool.value, delivery);

// Generate skill files if delivery includes skills
if (shouldGenerateSkills) {
if (toolShouldGenerateSkills) {
// Use tool-specific skillsDir
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');

Expand All @@ -537,21 +542,20 @@ export class InitCommand {
const skillFile = path.join(skillDir, 'SKILL.md');

// Generate SKILL.md content with YAML frontmatter including generatedBy
// Use hyphen-based command references for tools where filename = command name
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const transformer = getSkillInstructionTransformer(tool.value);
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);

// Write the skill file
await FileSystemUtils.writeFile(skillFile, skillContent);
}
}
if (!shouldGenerateSkills) {
if (!toolShouldGenerateSkills) {
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
removedSkillCount += await this.removeSkillDirs(skillsDir);
}

// Generate commands if delivery includes commands
if (shouldGenerateCommands) {
if (toolShouldGenerateCommands) {
const adapter = CommandAdapterRegistry.get(tool.value);
if (adapter) {
const generatedCommands = generateCommands(commandContents, adapter);
Expand All @@ -564,7 +568,7 @@ export class InitCommand {
commandsSkipped.push(tool.value);
}
}
if (!shouldGenerateCommands) {
if (!toolShouldGenerateCommands) {
removedCommandCount += await this.removeCommandFiles(projectPath, tool.value);
}

Expand Down Expand Up @@ -656,8 +660,14 @@ export class InitCommand {
const delivery: Delivery = globalConfig.delivery ?? 'both';
const workflows = getProfileWorkflows(profile, globalConfig.workflows);
const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', ');
const skillCount = delivery !== 'commands' ? getSkillTemplates(workflows).length : 0;
const commandCount = delivery !== 'skills' ? getCommandContents(workflows).length : 0;
const hasSkillGeneratingTool = successfulTools.some((tool) =>
shouldGenerateSkillsForTool(tool.value, delivery)
);
const hasCommandGeneratingTool = successfulTools.some((tool) =>
shouldGenerateCommandsForTool(tool.value, delivery)
);
const skillCount = hasSkillGeneratingTool ? getSkillTemplates(workflows).length : 0;
const commandCount = hasCommandGeneratingTool ? getCommandContents(workflows).length : 0;
if (skillCount > 0 && commandCount > 0) {
console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`);
} else if (skillCount > 0) {
Expand All @@ -677,10 +687,10 @@ export class InitCommand {
console.log(chalk.dim(`Commands skipped for: ${results.commandsSkipped.join(', ')} (no adapter)`));
}
if (results.removedCommandCount > 0) {
console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (delivery: skills)`));
console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (commands disabled for selected tools)`));
}
if (results.removedSkillCount > 0) {
console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`));
console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (skills disabled for selected tools)`));
}

// Config status
Expand All @@ -700,13 +710,35 @@ export class InitCommand {
const globalCfg = getGlobalConfig();
const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core';
const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)];
const gettingStartedTools = [...results.createdTools, ...results.refreshedTools];
const hasCommandSurface = gettingStartedTools.some((tool) =>
shouldGenerateCommandsForTool(tool.value, globalCfg.delivery ?? 'both')
);
const hasSkillOnlySurface = gettingStartedTools.some((tool) =>
shouldGenerateSkillsForTool(tool.value, globalCfg.delivery ?? 'both') &&
!shouldGenerateCommandsForTool(tool.value, globalCfg.delivery ?? 'both')
);
console.log();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (activeWorkflows.includes('propose')) {
if (gettingStartedTools.length > 0 && activeWorkflows.includes('propose')) {
console.log(chalk.bold('Getting started:'));
console.log(' Start your first change: /opsx:propose "your idea"');
} else if (activeWorkflows.includes('new')) {
if (hasCommandSurface && hasSkillOnlySurface) {
console.log(` Start your first change: /opsx:propose "your idea"`);
console.log(` Or with skills: $openspec-propose "your idea"`);
} else if (hasCommandSurface) {
console.log(` Start your first change: /opsx:propose "your idea"`);
} else {
console.log(` Start your first change: $openspec-propose "your idea"`);
}
} else if (gettingStartedTools.length > 0 && activeWorkflows.includes('new')) {
console.log(chalk.bold('Getting started:'));
console.log(' Start your first change: /opsx:new "your idea"');
if (hasCommandSurface && hasSkillOnlySurface) {
console.log(` Start your first change: /opsx:new "your idea"`);
console.log(` Or with skills: $openspec-new-change "your idea"`);
} else if (hasCommandSurface) {
console.log(` Start your first change: /opsx:new "your idea"`);
} else {
console.log(` Start your first change: $openspec-new-change "your idea"`);
}
} else {
console.log("Done. Run 'openspec config profile' to configure your workflows.");
}
Expand All @@ -719,7 +751,7 @@ export class InitCommand {
// Restart instruction if any tools were configured
if (results.createdTools.length > 0 || results.refreshedTools.length > 0) {
console.log();
console.log(chalk.white('Restart your IDE for slash commands to take effect.'));
console.log(chalk.white('Restart your IDE or agent for generated guidance to take effect.'));
}

console.log();
Expand Down
43 changes: 42 additions & 1 deletion src/core/legacy-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import path from 'path';
import os from 'os';
import { promises as fs } from 'fs';
import chalk from 'chalk';
import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js';
Expand Down Expand Up @@ -202,9 +203,37 @@ export async function detectLegacySlashCommands(
}
}

files.push(...await findLegacyCodexPromptFiles());

return { directories, files };
}

/**
* Returns the Codex home directory.
* Respects the CODEX_HOME env var, defaulting to ~/.codex.
*/
function getCodexHome(): string {
const envHome = process.env.CODEX_HOME?.trim();
return path.resolve(envHome ? envHome : path.join(os.homedir(), '.codex'));
}

async function findLegacyCodexPromptFiles(): Promise<string[]> {
const promptsDir = path.join(getCodexHome(), 'prompts');

if (!(await FileSystemUtils.directoryExists(promptsDir))) {
return [];
}

try {
const entries = await fs.readdir(promptsDir);
return entries
.filter((entry) => /^opsx-.*\.md$/.test(entry))
.map((entry) => path.join(promptsDir, entry));
} catch {
return [];
}
}

/**
* Finds legacy slash command files matching a glob pattern.
*
Expand Down Expand Up @@ -401,7 +430,9 @@ export async function cleanupLegacyArtifacts(

// Delete legacy slash command files (these are 100% OpenSpec-managed)
for (const filePath of detection.slashCommandFiles) {
const fullPath = FileSystemUtils.joinPath(projectPath, filePath);
const fullPath = path.isAbsolute(filePath)
? filePath
: FileSystemUtils.joinPath(projectPath, filePath);
try {
await fs.unlink(fullPath);
result.deletedFiles.push(filePath);
Expand Down Expand Up @@ -590,6 +621,7 @@ export function formatDetectionSummary(detection: LegacyDetectionResult): string
*/
export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): string[] {
const tools = new Set<string>();
const codexPromptsDir = path.join(getCodexHome(), 'prompts').replace(/\\/g, '/');

// Match directories to tool IDs
for (const dir of detection.slashCommandDirs) {
Expand All @@ -605,6 +637,15 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s
for (const file of detection.slashCommandFiles) {
// Normalize file path to use forward slashes for consistent matching (Windows compatibility)
const normalizedFile = file.replace(/\\/g, '/');
if (
path.isAbsolute(file) &&
normalizedFile.startsWith(`${codexPromptsDir}/`) &&
/^opsx-.*\.md$/.test(path.basename(file))
) {
tools.add('codex');
continue;
}

for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
if (pattern.type === 'files' && pattern.pattern) {
// Convert glob pattern to regex for matching
Expand Down
15 changes: 10 additions & 5 deletions src/core/profile-sync-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import type { Delivery } from './global-config.js';
import { ALL_WORKFLOWS } from './profiles.js';
import { CommandAdapterRegistry } from './command-generation/index.js';
import { COMMAND_IDS, getConfiguredTools } from './shared/index.js';
import {
shouldGenerateCommandsForTool,
shouldGenerateSkillsForTool,
} from './tool-delivery.js';

type WorkflowId = (typeof ALL_WORKFLOWS)[number];

Expand Down Expand Up @@ -98,8 +102,8 @@ export function hasToolProfileOrDeliveryDrift(
const desiredWorkflowSet = new Set<WorkflowId>(knownDesiredWorkflows);
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
const adapter = CommandAdapterRegistry.get(toolId);
const shouldGenerateSkills = delivery !== 'commands';
const shouldGenerateCommands = delivery !== 'skills';
const shouldGenerateSkills = shouldGenerateSkillsForTool(toolId, delivery);
const shouldGenerateCommands = shouldGenerateCommandsForTool(toolId, delivery);

if (shouldGenerateSkills) {
for (const workflow of knownDesiredWorkflows) {
Expand Down Expand Up @@ -226,11 +230,12 @@ export function hasProjectConfigDrift(
}

const desiredSet = new Set(toKnownWorkflows(desiredWorkflows));
const includeSkills = delivery !== 'commands';
const includeCommands = delivery !== 'skills';

for (const toolId of configuredTools) {
const installed = getInstalledWorkflowsForTool(projectPath, toolId, { includeSkills, includeCommands });
const installed = getInstalledWorkflowsForTool(projectPath, toolId, {
includeSkills: shouldGenerateSkillsForTool(toolId, delivery),
includeCommands: shouldGenerateCommandsForTool(toolId, delivery),
});
if (installed.some((workflow) => !desiredSet.has(workflow))) {
return true;
}
Expand Down
21 changes: 21 additions & 0 deletions src/core/tool-delivery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Delivery } from './global-config.js';
import {
transformToCodexSkillReferences,
transformToHyphenCommands,
} from '../utils/command-references.js';

export function shouldGenerateSkillsForTool(toolId: string, delivery: Delivery): boolean {
return toolId === 'codex' || delivery !== 'commands';
}

export function shouldGenerateCommandsForTool(toolId: string, delivery: Delivery): boolean {
return toolId !== 'codex' && delivery !== 'skills';
}

export function getSkillInstructionTransformer(
toolId: string
): ((instructions: string) => string) | undefined {
if (toolId === 'codex') return transformToCodexSkillReferences;
if (toolId === 'opencode' || toolId === 'pi') return transformToHyphenCommands;
return undefined;
}
Loading