UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

436 lines 21.7 kB
/** * 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