@fission-ai/openspec
Version:
AI-native system for spec-driven development
597 lines • 29.3 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';
import { getGlobalConfig } from './global-config.js';
import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js';
import { getAvailableTools } from './available-tools.js';
import { migrateIfNeeded } from './migration.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: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
};
const WORKFLOW_TO_SKILL_DIR = {
'explore': 'openspec-explore',
'new': 'openspec-new-change',
'continue': 'openspec-continue-change',
'apply': 'openspec-apply-change',
'ff': 'openspec-ff-change',
'sync': 'openspec-sync-specs',
'archive': 'openspec-archive-change',
'bulk-archive': 'openspec-bulk-archive-change',
'verify': 'openspec-verify-change',
'onboard': 'openspec-onboard',
'propose': 'openspec-propose',
};
// -----------------------------------------------------------------------------
// Init Command Class
// -----------------------------------------------------------------------------
export class InitCommand {
toolsArg;
force;
interactiveOption;
profileOverride;
constructor(options = {}) {
this.toolsArg = options.tools;
this.force = options.force ?? false;
this.interactiveOption = options.interactive;
this.profileOverride = options.profile;
}
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);
// Detect available tools in the project (task 7.1)
const detectedTools = getAvailableTools(projectPath);
// Migration check: migrate existing projects to profile system (task 7.3)
if (extendMode) {
migrateIfNeeded(projectPath, detectedTools);
}
// Show animated welcome screen (interactive mode only)
const canPrompt = this.canPromptInteractively();
if (canPrompt) {
const { showWelcomeScreen } = await import('../ui/welcome-screen.js');
await showWelcomeScreen();
}
// Validate profile override early so invalid values fail before tool setup.
// The resolved value is consumed later when generation reads effective config.
this.resolveProfileOverride();
// Get tool states before processing
const toolStates = getToolStates(projectPath);
// Get tool selection (pass detected tools for pre-selection)
const selectedToolIds = await this.getSelectedTools(toolStates, extendMode, detectedTools, projectPath);
// 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 });
}
resolveProfileOverride() {
if (this.profileOverride === undefined) {
return undefined;
}
if (this.profileOverride === 'core' || this.profileOverride === 'custom') {
return this.profileOverride;
}
throw new Error(`Invalid profile "${this.profileOverride}". Available profiles: core, custom`);
}
// ═══════════════════════════════════════════════════════════
// 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, detectedTools, projectPath) {
// Check for --tools flag first
const nonInteractiveSelection = this.resolveToolsArg();
if (nonInteractiveSelection !== null) {
return nonInteractiveSelection;
}
const validTools = getToolsWithSkillsDir();
const detectedToolIds = new Set(detectedTools.map((t) => t.value));
const configuredToolIds = new Set([...toolStates.entries()]
.filter(([, status]) => status.configured)
.map(([toolId]) => toolId));
const shouldPreselectDetected = !extendMode && configuredToolIds.size === 0;
const canPrompt = this.canPromptInteractively();
// Non-interactive mode: use detected tools as fallback (task 7.8)
if (!canPrompt) {
if (detectedToolIds.size > 0) {
return [...detectedToolIds];
}
throw new Error(`No tools detected and no --tools flag provided. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...`);
}
if (validTools.length === 0) {
throw new Error(`No tools available for skill generation.`);
}
// Interactive mode: show searchable multi-select
const { searchableMultiSelect } = await import('../prompts/searchable-multi-select.js');
// Build choices: pre-select configured tools; keep detected tools visible but unselected.
const sortedChoices = validTools
.map((toolId) => {
const tool = AI_TOOLS.find((t) => t.value === toolId);
const status = toolStates.get(toolId);
const configured = status?.configured ?? false;
const detected = detectedToolIds.has(toolId);
return {
name: tool?.name || toolId,
value: toolId,
configured,
detected: detected && !configured,
preSelected: configured || (shouldPreselectDetected && detected && !configured),
};
})
.sort((a, b) => {
// Configured tools first, then detected (not configured), then everything else.
if (a.configured && !b.configured)
return -1;
if (!a.configured && b.configured)
return 1;
if (a.detected && !b.detected)
return -1;
if (!a.detected && b.detected)
return 1;
return 0;
});
const configuredNames = validTools
.filter((toolId) => configuredToolIds.has(toolId))
.map((toolId) => AI_TOOLS.find((t) => t.value === toolId)?.name || toolId);
if (configuredNames.length > 0) {
console.log(`OpenSpec configured: ${configuredNames.join(', ')} (pre-selected)`);
}
const detectedOnlyNames = detectedTools
.filter((tool) => !configuredToolIds.has(tool.value))
.map((tool) => tool.name);
if (detectedOnlyNames.length > 0) {
const detectionLabel = shouldPreselectDetected
? 'pre-selected for first-time setup'
: 'not pre-selected';
console.log(`Detected tool directories: ${detectedOnlyNames.join(', ')} (${detectionLabel})`);
}
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 = [];
let removedCommandCount = 0;
let removedSkillCount = 0;
// Read global config for profile and delivery settings (use --profile override if set)
const globalConfig = getGlobalConfig();
const profile = this.resolveProfileOverride() ?? globalConfig.profile ?? 'core';
const delivery = globalConfig.delivery ?? 'both';
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) : [];
// Process each tool
for (const tool of tools) {
const spinner = ora(`Setting up ${tool.name}...`).start();
try {
// Generate skill files if delivery includes 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 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);
}
}
if (!shouldGenerateSkills) {
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
removedSkillCount += await this.removeSkillDirs(skillsDir);
}
// Generate commands if delivery includes commands
if (shouldGenerateCommands) {
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);
}
}
if (!shouldGenerateCommands) {
removedCommandCount += await this.removeCommandFiles(projectPath, 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,
removedCommandCount,
removedSkillCount,
};
}
// ═══════════════════════════════════════════════════════════
// 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 (respecting profile filter)
const successfulTools = [...results.createdTools, ...results.refreshedTools];
if (successfulTools.length > 0) {
const globalConfig = getGlobalConfig();
const profile = this.profileOverride ?? globalConfig.profile ?? 'core';
const 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;
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}/`);
}
}
// 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)`));
}
if (results.removedCommandCount > 0) {
console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (delivery: skills)`));
}
if (results.removedSkillCount > 0) {
console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`));
}
// 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 (task 7.6: show propose if in profile)
const globalCfg = getGlobalConfig();
const activeProfile = this.profileOverride ?? globalCfg.profile ?? 'core';
const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)];
console.log();
if (activeWorkflows.includes('propose')) {
console.log(chalk.bold('Getting started:'));
console.log(' Start your first change: /opsx:propose "your idea"');
}
else if (activeWorkflows.includes('new')) {
console.log(chalk.bold('Getting started:'));
console.log(' Start your first change: /opsx:new "your idea"');
}
else {
console.log("Done. Run 'openspec config profile' to configure your workflows.");
}
// 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();
}
async removeSkillDirs(skillsDir) {
let removed = 0;
for (const workflow of ALL_WORKFLOWS) {
const dirName = WORKFLOW_TO_SKILL_DIR[workflow];
if (!dirName)
continue;
const skillDir = path.join(skillsDir, dirName);
try {
if (fs.existsSync(skillDir)) {
await fs.promises.rm(skillDir, { recursive: true, force: true });
removed++;
}
}
catch {
// Ignore errors
}
}
return removed;
}
async removeCommandFiles(projectPath, toolId) {
let removed = 0;
const adapter = CommandAdapterRegistry.get(toolId);
if (!adapter)
return 0;
for (const workflow of ALL_WORKFLOWS) {
const cmdPath = adapter.getFilePath(workflow);
const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath);
try {
if (fs.existsSync(fullPath)) {
await fs.promises.unlink(fullPath);
removed++;
}
}
catch {
// Ignore errors
}
}
return removed;
}
}
//# sourceMappingURL=init.js.map