@fission-ai/openspec
Version:
AI-native system for spec-driven development
436 lines • 21.7 kB
JavaScript
/**
* Init Command
*
* Sets up OpenSpec with Agent Skills and /opsx:* slash commands.
* This is the unified setup command that replaces both the old init and experimental commands.
*/
import path from 'path';
import chalk from 'chalk';
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, } from './config.js';
import { PALETTE } from './styles/palette.js';
import { isInteractive } from '../utils/interactive.js';
import { serializeConfig } from './config-prompts.js';
import { generateCommands, CommandAdapterRegistry, } from './command-generation/index.js';
import { detectLegacyArtifacts, cleanupLegacyArtifacts, formatCleanupSummary, formatDetectionSummary, } from './legacy-cleanup.js';
import { getToolsWithSkillsDir, getToolStates, getSkillTemplates, getCommandContents, generateSkillContent, } from './shared/index.js';
const require = createRequire(import.meta.url);
const { version: OPENSPEC_VERSION } = require('../../package.json');
// -----------------------------------------------------------------------------
// Constants
// -----------------------------------------------------------------------------
const DEFAULT_SCHEMA = 'spec-driven';
const PROGRESS_SPINNER = {
interval: 80,
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
};
// -----------------------------------------------------------------------------
// Init Command Class
// -----------------------------------------------------------------------------
export class InitCommand {
toolsArg;
force;
interactiveOption;
constructor(options = {}) {
this.toolsArg = options.tools;
this.force = options.force ?? false;
this.interactiveOption = options.interactive;
}
async execute(targetPath) {
const projectPath = path.resolve(targetPath);
const openspecDir = OPENSPEC_DIR_NAME;
const openspecPath = path.join(projectPath, openspecDir);
// Validation happens silently in the background
const extendMode = await this.validate(projectPath, openspecPath);
// Check for legacy artifacts and handle cleanup
await this.handleLegacyCleanup(projectPath, extendMode);
// Show animated welcome screen (interactive mode only)
const canPrompt = this.canPromptInteractively();
if (canPrompt) {
const { showWelcomeScreen } = await import('../ui/welcome-screen.js');
await showWelcomeScreen();
}
// Get tool states before processing
const toolStates = getToolStates(projectPath);
// Get tool selection
const selectedToolIds = await this.getSelectedTools(toolStates, extendMode);
// Validate selected tools
const validatedTools = this.validateTools(selectedToolIds, toolStates);
// Create directory structure and config
await this.createDirectoryStructure(openspecPath, extendMode);
// Generate skills and commands for each tool
const results = await this.generateSkillsAndCommands(projectPath, validatedTools);
// Create config.yaml if needed
const configStatus = await this.createConfig(openspecPath, extendMode);
// Display success message
this.displaySuccessMessage(projectPath, validatedTools, results, configStatus);
}
// ═══════════════════════════════════════════════════════════
// VALIDATION & SETUP
// ═══════════════════════════════════════════════════════════
async validate(projectPath, openspecPath) {
const extendMode = await FileSystemUtils.directoryExists(openspecPath);
// Check write permissions
if (!(await FileSystemUtils.ensureWritePermissions(projectPath))) {
throw new Error(`Insufficient permissions to write to ${projectPath}`);
}
return extendMode;
}
canPromptInteractively() {
if (this.interactiveOption === false)
return false;
if (this.toolsArg !== undefined)
return false;
return isInteractive({ interactive: this.interactiveOption });
}
// ═══════════════════════════════════════════════════════════
// LEGACY CLEANUP
// ═══════════════════════════════════════════════════════════
async handleLegacyCleanup(projectPath, extendMode) {
// Detect legacy artifacts
const detection = await detectLegacyArtifacts(projectPath);
if (!detection.hasLegacyArtifacts) {
return; // No legacy artifacts found
}
// Show what was detected
console.log();
console.log(formatDetectionSummary(detection));
console.log();
const canPrompt = this.canPromptInteractively();
if (this.force) {
// --force flag: proceed with cleanup automatically
await this.performLegacyCleanup(projectPath, detection);
return;
}
if (!canPrompt) {
// Non-interactive mode without --force: abort
console.log(chalk.red('Legacy files detected in non-interactive mode.'));
console.log(chalk.dim('Run interactively to upgrade, or use --force to auto-cleanup.'));
process.exit(1);
}
// Interactive mode: prompt for confirmation
const { confirm } = await import('@inquirer/prompts');
const shouldCleanup = await confirm({
message: 'Upgrade and clean up legacy files?',
default: true,
});
if (!shouldCleanup) {
console.log(chalk.dim('Initialization cancelled.'));
console.log(chalk.dim('Run with --force to skip this prompt, or manually remove legacy files.'));
process.exit(0);
}
await this.performLegacyCleanup(projectPath, detection);
}
async performLegacyCleanup(projectPath, detection) {
const spinner = ora('Cleaning up legacy files...').start();
const result = await cleanupLegacyArtifacts(projectPath, detection);
spinner.succeed('Legacy files cleaned up');
const summary = formatCleanupSummary(result);
if (summary) {
console.log();
console.log(summary);
}
console.log();
}
// ═══════════════════════════════════════════════════════════
// TOOL SELECTION
// ═══════════════════════════════════════════════════════════
async getSelectedTools(toolStates, extendMode) {
// Check for --tools flag first
const nonInteractiveSelection = this.resolveToolsArg();
if (nonInteractiveSelection !== null) {
return nonInteractiveSelection;
}
const validTools = getToolsWithSkillsDir();
const canPrompt = this.canPromptInteractively();
if (!canPrompt || validTools.length === 0) {
throw new Error(`Missing required option --tools. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...`);
}
// Interactive mode: show searchable multi-select
const { searchableMultiSelect } = await import('../prompts/searchable-multi-select.js');
// Build choices with configured status and sort configured tools first
const sortedChoices = validTools
.map((toolId) => {
const tool = AI_TOOLS.find((t) => t.value === toolId);
const status = toolStates.get(toolId);
const configured = status?.configured ?? false;
return {
name: tool?.name || toolId,
value: toolId,
configured,
preSelected: configured, // Pre-select configured tools for easy refresh
};
})
.sort((a, b) => {
// Configured tools first
if (a.configured && !b.configured)
return -1;
if (!a.configured && b.configured)
return 1;
return 0;
});
const selectedTools = await searchableMultiSelect({
message: `Select tools to set up (${validTools.length} available)`,
pageSize: 15,
choices: sortedChoices,
validate: (selected) => selected.length > 0 || 'Select at least one tool',
});
if (selectedTools.length === 0) {
throw new Error('At least one tool must be selected');
}
return selectedTools;
}
resolveToolsArg() {
if (typeof this.toolsArg === 'undefined') {
return null;
}
const raw = this.toolsArg.trim();
if (raw.length === 0) {
throw new Error('The --tools option requires a value. Use "all", "none", or a comma-separated list of tool IDs.');
}
const availableTools = getToolsWithSkillsDir();
const availableSet = new Set(availableTools);
const availableList = ['all', 'none', ...availableTools].join(', ');
const lowerRaw = raw.toLowerCase();
if (lowerRaw === 'all') {
return availableTools;
}
if (lowerRaw === 'none') {
return [];
}
const tokens = raw
.split(',')
.map((token) => token.trim())
.filter((token) => token.length > 0);
if (tokens.length === 0) {
throw new Error('The --tools option requires at least one tool ID when not using "all" or "none".');
}
const normalizedTokens = tokens.map((token) => token.toLowerCase());
if (normalizedTokens.some((token) => token === 'all' || token === 'none')) {
throw new Error('Cannot combine reserved values "all" or "none" with specific tool IDs.');
}
const invalidTokens = tokens.filter((_token, index) => !availableSet.has(normalizedTokens[index]));
if (invalidTokens.length > 0) {
throw new Error(`Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}`);
}
// Deduplicate while preserving order
const deduped = [];
for (const token of normalizedTokens) {
if (!deduped.includes(token)) {
deduped.push(token);
}
}
return deduped;
}
validateTools(toolIds, toolStates) {
const validatedTools = [];
for (const toolId of toolIds) {
const tool = AI_TOOLS.find((t) => t.value === toolId);
if (!tool) {
const validToolIds = getToolsWithSkillsDir();
throw new Error(`Unknown tool '${toolId}'. Valid tools:\n ${validToolIds.join('\n ')}`);
}
if (!tool.skillsDir) {
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);
validatedTools.push({
value: tool.value,
name: tool.name,
skillsDir: tool.skillsDir,
wasConfigured: preState?.configured ?? false,
});
}
return validatedTools;
}
// ═══════════════════════════════════════════════════════════
// DIRECTORY STRUCTURE
// ═══════════════════════════════════════════════════════════
async createDirectoryStructure(openspecPath, extendMode) {
if (extendMode) {
// In extend mode, just ensure directories exist without spinner
const directories = [
openspecPath,
path.join(openspecPath, 'specs'),
path.join(openspecPath, 'changes'),
path.join(openspecPath, 'changes', 'archive'),
];
for (const dir of directories) {
await FileSystemUtils.createDirectory(dir);
}
return;
}
const spinner = this.startSpinner('Creating OpenSpec structure...');
const directories = [
openspecPath,
path.join(openspecPath, 'specs'),
path.join(openspecPath, 'changes'),
path.join(openspecPath, 'changes', 'archive'),
];
for (const dir of directories) {
await FileSystemUtils.createDirectory(dir);
}
spinner.stopAndPersist({
symbol: PALETTE.white('▌'),
text: PALETTE.white('OpenSpec structure created'),
});
}
// ═══════════════════════════════════════════════════════════
// SKILL & COMMAND GENERATION
// ═══════════════════════════════════════════════════════════
async generateSkillsAndCommands(projectPath, tools) {
const createdTools = [];
const refreshedTools = [];
const failedTools = [];
const commandsSkipped = [];
// Get skill and command templates once (shared across all tools)
const skillTemplates = getSkillTemplates();
const commandContents = getCommandContents();
// Process each tool
for (const tool of tools) {
const spinner = ora(`Setting up ${tool.name}...`).start();
try {
// 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 skillFile = path.join(skillDir, 'SKILL.md');
// Generate SKILL.md content with YAML frontmatter including generatedBy
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
// Write the skill file
await FileSystemUtils.writeFile(skillFile, skillContent);
}
// Generate commands using the adapter system
const adapter = CommandAdapterRegistry.get(tool.value);
if (adapter) {
const generatedCommands = generateCommands(commandContents, adapter);
for (const cmd of generatedCommands) {
const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
}
}
else {
commandsSkipped.push(tool.value);
}
spinner.succeed(`Setup complete for ${tool.name}`);
if (tool.wasConfigured) {
refreshedTools.push(tool);
}
else {
createdTools.push(tool);
}
}
catch (error) {
spinner.fail(`Failed for ${tool.name}`);
failedTools.push({ name: tool.name, error: error });
}
}
return { createdTools, refreshedTools, failedTools, commandsSkipped };
}
// ═══════════════════════════════════════════════════════════
// CONFIG FILE
// ═══════════════════════════════════════════════════════════
async createConfig(openspecPath, extendMode) {
const configPath = path.join(openspecPath, 'config.yaml');
const configYmlPath = path.join(openspecPath, 'config.yml');
const configYamlExists = fs.existsSync(configPath);
const configYmlExists = fs.existsSync(configYmlPath);
if (configYamlExists || configYmlExists) {
return 'exists';
}
// In non-interactive mode without --force, skip config creation
if (!this.canPromptInteractively() && !this.force) {
return 'skipped';
}
try {
const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA });
await FileSystemUtils.writeFile(configPath, yamlContent);
return 'created';
}
catch {
return 'skipped';
}
}
// ═══════════════════════════════════════════════════════════
// UI & OUTPUT
// ═══════════════════════════════════════════════════════════
displaySuccessMessage(projectPath, tools, results, configStatus) {
console.log();
console.log(chalk.bold('OpenSpec Setup Complete'));
console.log();
// Show created vs refreshed tools
if (results.createdTools.length > 0) {
console.log(`Created: ${results.createdTools.map((t) => t.name).join(', ')}`);
}
if (results.refreshedTools.length > 0) {
console.log(`Refreshed: ${results.refreshedTools.map((t) => t.name).join(', ')}`);
}
// Show counts
const successfulTools = [...results.createdTools, ...results.refreshedTools];
if (successfulTools.length > 0) {
const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', ');
const hasCommands = results.commandsSkipped.length < successfulTools.length;
if (hasCommands) {
console.log(`${getSkillTemplates().length} skills and ${getCommandContents().length} commands in ${toolDirs}/`);
}
else {
console.log(`${getSkillTemplates().length} skills in ${toolDirs}/`);
}
}
// Show failures
if (results.failedTools.length > 0) {
console.log(chalk.red(`Failed: ${results.failedTools.map((f) => `${f.name} (${f.error.message})`).join(', ')}`));
}
// Show skipped commands
if (results.commandsSkipped.length > 0) {
console.log(chalk.dim(`Commands skipped for: ${results.commandsSkipped.join(', ')} (no adapter)`));
}
// Config status
if (configStatus === 'created') {
console.log(`Config: openspec/config.yaml (schema: ${DEFAULT_SCHEMA})`);
}
else if (configStatus === 'exists') {
// Show actual filename (config.yaml or config.yml)
const configYaml = path.join(projectPath, OPENSPEC_DIR_NAME, 'config.yaml');
const configYml = path.join(projectPath, OPENSPEC_DIR_NAME, 'config.yml');
const configName = fs.existsSync(configYaml) ? 'config.yaml' : fs.existsSync(configYml) ? 'config.yml' : 'config.yaml';
console.log(`Config: openspec/${configName} (exists)`);
}
else {
console.log(chalk.dim(`Config: skipped (non-interactive mode)`));
}
// Getting started
console.log();
console.log(chalk.bold('Getting started:'));
console.log(' /opsx:new Start a new change');
console.log(' /opsx:continue Create the next artifact');
console.log(' /opsx:apply Implement tasks');
// Links
console.log();
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
// 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();
}
startSpinner(text) {
return ora({
text,
stream: process.stdout,
color: 'gray',
spinner: PROGRESS_SPINNER,
}).start();
}
}
//# sourceMappingURL=init.js.map