diff --git a/flake.nix b/flake.nix index cb108788..5252845d 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ { default = pkgs.stdenv.mkDerivation (finalAttrs: { pname = "openspec"; - version = "0.20.0"; + version = "0.23.0"; src = ./.; @@ -27,7 +27,7 @@ inherit (finalAttrs) pname version src; pnpm = pkgs.pnpm_9; fetcherVersion = 3; - hash = "sha256-m/7IdY1ou9ljjYAcx3W8AyEJvIZfCBWIWxproQ/INPA="; + hash = "sha256-9s2kdvd7svK4hofnD66HkDc86WTQeayfF5y7L2dmjNg="; }; nativeBuildInputs = with pkgs; [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92da325a..a632f811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,8 +463,8 @@ packages: '@types/node': optional: true - '@inquirer/type@3.0.8': - resolution: {integrity: sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==} + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -1946,7 +1946,7 @@ snapshots: dependencies: '@inquirer/core': 10.2.2(@types/node@24.2.0) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@24.2.0) + '@inquirer/type': 3.0.10(@types/node@24.2.0) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: @@ -1955,7 +1955,7 @@ snapshots: '@inquirer/confirm@5.1.14(@types/node@24.2.0)': dependencies: '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.8(@types/node@24.2.0) + '@inquirer/type': 3.0.10(@types/node@24.2.0) optionalDependencies: '@types/node': 24.2.0 @@ -1963,7 +1963,7 @@ snapshots: dependencies: '@inquirer/ansi': 1.0.0 '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@24.2.0) + '@inquirer/type': 3.0.10(@types/node@24.2.0) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 @@ -1975,7 +1975,7 @@ snapshots: '@inquirer/editor@4.2.15(@types/node@24.2.0)': dependencies: '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.8(@types/node@24.2.0) + '@inquirer/type': 3.0.10(@types/node@24.2.0) external-editor: 3.1.0 optionalDependencies: '@types/node': 24.2.0 @@ -1983,7 +1983,7 @@ snapshots: '@inquirer/expand@4.0.17(@types/node@24.2.0)': dependencies: '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.8(@types/node@24.2.0) + '@inquirer/type': 3.0.10(@types/node@24.2.0) yoctocolors-cjs: 2.1.2 optionalDependencies: '@types/node': 24.2.0 @@ -2000,21 +2000,21 @@ snapshots: '@inquirer/input@4.2.1(@types/node@24.2.0)': dependencies: '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.8(@types/node@24.2.0) + '@inquirer/type': 3.0.10(@types/node@24.2.0) optionalDependencies: '@types/node': 24.2.0 '@inquirer/number@3.0.17(@types/node@24.2.0)': dependencies: '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.8(@types/node@24.2.0) + '@inquirer/type': 3.0.10(@types/node@24.2.0) optionalDependencies: '@types/node': 24.2.0 '@inquirer/password@4.0.17(@types/node@24.2.0)': dependencies: '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.8(@types/node@24.2.0) + '@inquirer/type': 3.0.10(@types/node@24.2.0) ansi-escapes: 4.3.2 optionalDependencies: '@types/node': 24.2.0 @@ -2037,7 +2037,7 @@ snapshots: '@inquirer/rawlist@4.1.5(@types/node@24.2.0)': dependencies: '@inquirer/core': 10.2.2(@types/node@24.2.0) - '@inquirer/type': 3.0.8(@types/node@24.2.0) + '@inquirer/type': 3.0.10(@types/node@24.2.0) yoctocolors-cjs: 2.1.2 optionalDependencies: '@types/node': 24.2.0 @@ -2046,7 +2046,7 @@ snapshots: dependencies: '@inquirer/core': 10.2.2(@types/node@24.2.0) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@24.2.0) + '@inquirer/type': 3.0.10(@types/node@24.2.0) yoctocolors-cjs: 2.1.2 optionalDependencies: '@types/node': 24.2.0 @@ -2055,13 +2055,13 @@ snapshots: dependencies: '@inquirer/core': 10.2.2(@types/node@24.2.0) '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@24.2.0) + '@inquirer/type': 3.0.10(@types/node@24.2.0) ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 optionalDependencies: '@types/node': 24.2.0 - '@inquirer/type@3.0.8(@types/node@24.2.0)': + '@inquirer/type@3.0.10(@types/node@24.2.0)': optionalDependencies: '@types/node': 24.2.0 diff --git a/scripts/update-flake.sh b/scripts/update-flake.sh index 022c9719..5b075b8b 100755 --- a/scripts/update-flake.sh +++ b/scripts/update-flake.sh @@ -41,8 +41,8 @@ sed "${SED_INPLACE[@]}" "s|hash = \"sha256-[^\"]*\"|hash = \"$PLACEHOLDER\"|" "$ echo " Building to get correct hash (this will fail)..." BUILD_OUTPUT=$(nix build 2>&1 || true) -# Extract the correct hash from error output -CORRECT_HASH=$(echo "$BUILD_OUTPUT" | grep -oP 'got:\s+\Ksha256-[A-Za-z0-9+/=]+' | head -1) +# Extract the correct hash from error output (portable - works on macOS and Linux) +CORRECT_HASH=$(echo "$BUILD_OUTPUT" | grep -o 'got:[[:space:]]*sha256-[A-Za-z0-9+/=]*' | head -1 | sed 's/got:[[:space:]]*//') if [ -z "$CORRECT_HASH" ]; then echo "❌ Error: Could not extract hash from build output" diff --git a/src/commands/artifact-workflow.ts b/src/commands/artifact-workflow.ts index 1b71d664..47adea4d 100644 --- a/src/commands/artifact-workflow.ts +++ b/src/commands/artifact-workflow.ts @@ -30,6 +30,7 @@ import { import { createChange, validateChangeName } from '../utils/change-utils.js'; import { getExploreSkillTemplate, getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getBulkArchiveChangeSkillTemplate, getVerifyChangeSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate, getOpsxBulkArchiveCommandTemplate, getOpsxVerifyCommandTemplate } from '../core/templates/skill-templates.js'; import { FileSystemUtils } from '../utils/file-system.js'; +import { isInteractive } from '../utils/interactive.js'; import { serializeConfig } from '../core/config-prompts.js'; import { readProjectConfig } from '../core/project-config.js'; import { AI_TOOLS } from '../core/config.js'; @@ -828,6 +829,8 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti interface ArtifactExperimentalSetupOptions { tool?: string; + interactive?: boolean; + selectedTools?: string[]; // For multi-select from interactive prompt } /** @@ -845,69 +848,131 @@ function getToolsWithSkillsDir(): string[] { async function artifactExperimentalSetupCommand(options: ArtifactExperimentalSetupOptions): Promise { const projectRoot = process.cwd(); - // Validate --tool flag is provided + // Validate --tool flag is provided or prompt interactively if (!options.tool) { const validTools = getToolsWithSkillsDir(); - throw new Error( - `Missing required option --tool. Valid tools with skill generation support:\n ${validTools.join('\n ')}` - ); - } + const canPrompt = isInteractive(options); + + if (canPrompt && validTools.length > 0) { + // Show animated welcome screen before tool selection + const { showWelcomeScreen } = await import('../ui/welcome-screen.js'); + await showWelcomeScreen(); + + const { searchableMultiSelect } = await import('../prompts/searchable-multi-select.js'); + + const selectedTools = await searchableMultiSelect({ + message: `Select tools to set up (${validTools.length} available)`, + pageSize: 15, + choices: validTools.map((toolId) => { + const tool = AI_TOOLS.find((t) => t.value === toolId); + return { name: tool?.name || toolId, value: toolId }; + }), + validate: (selected: string[]) => selected.length > 0 || 'Select at least one tool', + }); - // Validate tool exists in AI_TOOLS - const tool = AI_TOOLS.find((t) => t.value === options.tool); - if (!tool) { - const validTools = AI_TOOLS.map((t) => t.value); - throw new Error( - `Unknown tool '${options.tool}'. Valid tools:\n ${validTools.join('\n ')}` - ); + if (selectedTools.length === 0) { + throw new Error('At least one tool must be selected'); + } + + options.tool = selectedTools[0]; + options.selectedTools = selectedTools; + } else { + throw new Error( + `Missing required option --tool. Valid tools with skill generation support:\n ${validTools.join('\n ')}` + ); + } } - // Validate tool has skillsDir configured - if (!tool.skillsDir) { - const validTools = getToolsWithSkillsDir(); - throw new Error( - `Tool '${options.tool}' does not support skill generation (no skillsDir configured).\nTools with skill generation support:\n ${validTools.join('\n ')}` - ); + // Determine tools to set up + const toolsToSetup = options.selectedTools || [options.tool!]; + + // Validate all tools before starting + const validatedTools: Array<{ value: string; name: string; skillsDir: string }> = []; + for (const toolId of toolsToSetup) { + const tool = AI_TOOLS.find((t) => t.value === toolId); + if (!tool) { + const validToolIds = AI_TOOLS.map((t) => t.value); + 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 (no skillsDir configured).\nTools with skill generation support:\n ${validToolsWithSkills.join('\n ')}` + ); + } + + validatedTools.push({ value: tool.value, name: tool.name, skillsDir: tool.skillsDir }); } - const spinner = ora(`Setting up experimental artifact workflow for ${tool.name}...`).start(); + // Track all created files across all tools + const allCreatedSkillFiles: string[] = []; + const allCreatedCommandFiles: string[] = []; + let anyCommandsSkipped = false; + const toolsWithSkippedCommands: string[] = []; + const failedTools: Array<{ name: string; error: Error }> = []; + + // Get skill and command templates once (shared across all tools) + const exploreSkill = getExploreSkillTemplate(); + const newChangeSkill = getNewChangeSkillTemplate(); + const continueChangeSkill = getContinueChangeSkillTemplate(); + const applyChangeSkill = getApplyChangeSkillTemplate(); + const ffChangeSkill = getFfChangeSkillTemplate(); + const syncSpecsSkill = getSyncSpecsSkillTemplate(); + const archiveChangeSkill = getArchiveChangeSkillTemplate(); + const bulkArchiveChangeSkill = getBulkArchiveChangeSkillTemplate(); + const verifyChangeSkill = getVerifyChangeSkillTemplate(); + + const skillTemplates = [ + { template: exploreSkill, dirName: 'openspec-explore' }, + { template: newChangeSkill, dirName: 'openspec-new-change' }, + { template: continueChangeSkill, dirName: 'openspec-continue-change' }, + { template: applyChangeSkill, dirName: 'openspec-apply-change' }, + { template: ffChangeSkill, dirName: 'openspec-ff-change' }, + { template: syncSpecsSkill, dirName: 'openspec-sync-specs' }, + { template: archiveChangeSkill, dirName: 'openspec-archive-change' }, + { template: bulkArchiveChangeSkill, dirName: 'openspec-bulk-archive-change' }, + { template: verifyChangeSkill, dirName: 'openspec-verify-change' }, + ]; + + const commandTemplates = [ + { template: getOpsxExploreCommandTemplate(), id: 'explore' }, + { template: getOpsxNewCommandTemplate(), id: 'new' }, + { template: getOpsxContinueCommandTemplate(), id: 'continue' }, + { template: getOpsxApplyCommandTemplate(), id: 'apply' }, + { template: getOpsxFfCommandTemplate(), id: 'ff' }, + { template: getOpsxSyncCommandTemplate(), id: 'sync' }, + { template: getOpsxArchiveCommandTemplate(), id: 'archive' }, + { template: getOpsxBulkArchiveCommandTemplate(), id: 'bulk-archive' }, + { template: getOpsxVerifyCommandTemplate(), id: 'verify' }, + ]; + + const commandContents: CommandContent[] = commandTemplates.map(({ template, id }) => ({ + id, + name: template.name, + description: template.description, + category: template.category, + tags: template.tags, + body: template.content, + })); + + // Process each tool + for (const tool of validatedTools) { + const spinner = ora(`Setting up experimental artifact workflow for ${tool.name}...`).start(); - try { - // Use tool-specific skillsDir - const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); - - // Get skill templates - const exploreSkill = getExploreSkillTemplate(); - const newChangeSkill = getNewChangeSkillTemplate(); - const continueChangeSkill = getContinueChangeSkillTemplate(); - const applyChangeSkill = getApplyChangeSkillTemplate(); - const ffChangeSkill = getFfChangeSkillTemplate(); - const syncSpecsSkill = getSyncSpecsSkillTemplate(); - const archiveChangeSkill = getArchiveChangeSkillTemplate(); - const bulkArchiveChangeSkill = getBulkArchiveChangeSkillTemplate(); - const verifyChangeSkill = getVerifyChangeSkillTemplate(); - - // Create skill directories and SKILL.md files - const skills = [ - { template: exploreSkill, dirName: 'openspec-explore' }, - { template: newChangeSkill, dirName: 'openspec-new-change' }, - { template: continueChangeSkill, dirName: 'openspec-continue-change' }, - { template: applyChangeSkill, dirName: 'openspec-apply-change' }, - { template: ffChangeSkill, dirName: 'openspec-ff-change' }, - { template: syncSpecsSkill, dirName: 'openspec-sync-specs' }, - { template: archiveChangeSkill, dirName: 'openspec-archive-change' }, - { template: bulkArchiveChangeSkill, dirName: 'openspec-bulk-archive-change' }, - { template: verifyChangeSkill, dirName: 'openspec-verify-change' }, - ]; - - const createdSkillFiles: string[] = []; - - for (const { template, dirName } of skills) { - const skillDir = path.join(skillsDir, dirName); - const skillFile = path.join(skillDir, 'SKILL.md'); - - // Generate SKILL.md content with YAML frontmatter - const skillContent = `--- + try { + // Use tool-specific skillsDir + const skillsDir = path.join(projectRoot, 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 + const skillContent = `--- name: ${template.name} description: ${template.description} --- @@ -915,168 +980,176 @@ description: ${template.description} ${template.instructions} `; - // Write the skill file - await FileSystemUtils.writeFile(skillFile, skillContent); - createdSkillFiles.push(path.relative(projectRoot, skillFile)); - } + // Write the skill file + await FileSystemUtils.writeFile(skillFile, skillContent); + allCreatedSkillFiles.push(path.relative(projectRoot, skillFile)); + } + + // Generate commands using the adapter system + const adapter = CommandAdapterRegistry.get(tool.value); + if (adapter) { + const generatedCommands = generateCommands(commandContents, adapter); - // Generate commands using the adapter system - const createdCommandFiles: string[] = []; - let commandsSkipped = false; - - const adapter = CommandAdapterRegistry.get(tool.value); - if (adapter) { - // Get command templates and convert to CommandContent - const commandTemplates = [ - { template: getOpsxExploreCommandTemplate(), id: 'explore' }, - { template: getOpsxNewCommandTemplate(), id: 'new' }, - { template: getOpsxContinueCommandTemplate(), id: 'continue' }, - { template: getOpsxApplyCommandTemplate(), id: 'apply' }, - { template: getOpsxFfCommandTemplate(), id: 'ff' }, - { template: getOpsxSyncCommandTemplate(), id: 'sync' }, - { template: getOpsxArchiveCommandTemplate(), id: 'archive' }, - { template: getOpsxBulkArchiveCommandTemplate(), id: 'bulk-archive' }, - { template: getOpsxVerifyCommandTemplate(), id: 'verify' }, - ]; - - const commandContents: CommandContent[] = commandTemplates.map(({ template, id }) => ({ - id, - name: template.name, - description: template.description, - category: template.category, - tags: template.tags, - body: template.content, - })); - - const generatedCommands = generateCommands(commandContents, adapter); - - for (const cmd of generatedCommands) { - const commandFile = path.join(projectRoot, cmd.path); - await FileSystemUtils.writeFile(commandFile, cmd.fileContent); - createdCommandFiles.push(cmd.path); + for (const cmd of generatedCommands) { + const commandFile = path.join(projectRoot, cmd.path); + await FileSystemUtils.writeFile(commandFile, cmd.fileContent); + allCreatedCommandFiles.push(cmd.path); + } + } else { + anyCommandsSkipped = true; + toolsWithSkippedCommands.push(tool.value); } - } else { - commandsSkipped = true; + + spinner.succeed(`Setup complete for ${tool.name}!`); + } catch (error) { + spinner.fail(`Failed for ${tool.name}`); + failedTools.push({ name: tool.name, error: error as Error }); } + } + + // If all tools failed, throw an error + if (failedTools.length === validatedTools.length) { + const errorMessages = failedTools.map(f => ` ${f.name}: ${f.error.message}`).join('\n'); + throw new Error(`All tools failed to set up:\n${errorMessages}`); + } - spinner.succeed(`Experimental artifact workflow setup complete for ${tool.name}!`); + // Filter to only successfully configured tools + const successfulTools = validatedTools.filter(t => !failedTools.some(f => f.name === t.name)); - // Print success message - console.log(); - console.log(chalk.bold(`🧪 Experimental Artifact Workflow Setup Complete for ${tool.name}`)); + // Print success message + console.log(); + console.log(chalk.bold(`🧪 Experimental Artifact Workflow Setup Complete`)); + console.log(); + if (successfulTools.length > 0) { + console.log(chalk.bold(`Tools configured: ${successfulTools.map(t => t.name).join(', ')}`)); + } + if (failedTools.length > 0) { + console.log(chalk.red(`Tools failed: ${failedTools.map(f => f.name).join(', ')}`)); + } + console.log(); + + console.log(chalk.bold('Skills Created:')); + for (const file of allCreatedSkillFiles) { + console.log(chalk.green(' ✓ ' + file)); + } + console.log(); + + if (anyCommandsSkipped) { + console.log(chalk.yellow(`Command generation skipped for: ${toolsWithSkippedCommands.join(', ')} (no adapter)`)); console.log(); - console.log(chalk.bold('Skills Created:')); - for (const file of createdSkillFiles) { + } + + if (allCreatedCommandFiles.length > 0) { + console.log(chalk.bold('Slash Commands Created:')); + for (const file of allCreatedCommandFiles) { console.log(chalk.green(' ✓ ' + file)); } console.log(); + } - if (commandsSkipped) { - console.log(chalk.yellow(`Command generation skipped - no adapter for ${tool.value}`)); - console.log(); - } else { - console.log(chalk.bold('Slash Commands Created:')); - for (const file of createdCommandFiles) { - console.log(chalk.green(' ✓ ' + file)); - } - console.log(); - } + // Config creation section (happens once, not per-tool) + console.log('━'.repeat(70)); + console.log(); + console.log(chalk.bold('📋 Project Configuration (Optional)')); + console.log(); + console.log('Configure project defaults for OpenSpec workflows.'); + console.log(); - // Config creation section - console.log('━'.repeat(70)); + // Check if config already exists + const configPath = path.join(projectRoot, 'openspec', 'config.yaml'); + const configYmlPath = path.join(projectRoot, 'openspec', 'config.yml'); + const configExists = fs.existsSync(configPath) || fs.existsSync(configYmlPath); + + if (configExists) { + // Config already exists, skip creation + console.log(chalk.blue('ℹ️ openspec/config.yaml already exists. Skipping config creation.')); + console.log(); + console.log(' To update config, edit openspec/config.yaml manually or:'); + console.log(' 1. Delete openspec/config.yaml'); + console.log(' 2. Run openspec artifact-experimental-setup again'); console.log(); - console.log(chalk.bold('📋 Project Configuration (Optional)')); + } else if (!isInteractive(options)) { + // Non-interactive mode (CI, automation, piped input, or --no-interactive flag) + console.log(chalk.blue('ℹ️ Skipping config prompts (non-interactive mode)')); console.log(); - console.log('Configure project defaults for OpenSpec workflows.'); + console.log(' To create config manually, add openspec/config.yaml with:'); + console.log(chalk.dim(' schema: spec-driven')); console.log(); + } else { + // Create config with default schema + const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA }); - // Check if config already exists - const configPath = path.join(projectRoot, 'openspec', 'config.yaml'); - const configYmlPath = path.join(projectRoot, 'openspec', 'config.yml'); - const configExists = fs.existsSync(configPath) || fs.existsSync(configYmlPath); + try { + await FileSystemUtils.writeFile(configPath, yamlContent); - if (configExists) { - // Config already exists, skip creation - console.log(chalk.blue('ℹ️ openspec/config.yaml already exists. Skipping config creation.')); console.log(); - console.log(' To update config, edit openspec/config.yaml manually or:'); - console.log(' 1. Delete openspec/config.yaml'); - console.log(' 2. Run openspec artifact-experimental-setup again'); + console.log(chalk.green('✓ Created openspec/config.yaml')); console.log(); - } else if (!process.stdin.isTTY) { - // Non-interactive mode (CI, automation, piped input) - console.log(chalk.blue('ℹ️ Skipping config prompts (non-interactive mode)')); + console.log(` Default schema: ${chalk.cyan(DEFAULT_SCHEMA)}`); console.log(); - console.log(' To create config manually, add openspec/config.yaml with:'); - console.log(chalk.dim(' schema: spec-driven')); + console.log(chalk.dim(' Edit the file to add project context and per-artifact rules.')); console.log(); - } else { - // Create config with default schema - const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA }); - - try { - await FileSystemUtils.writeFile(configPath, yamlContent); - - console.log(); - console.log(chalk.green('✓ Created openspec/config.yaml')); - console.log(); - console.log(` Default schema: ${chalk.cyan(DEFAULT_SCHEMA)}`); - console.log(); - console.log(chalk.dim(' Edit the file to add project context and per-artifact rules.')); - console.log(); - // Git commit suggestion - console.log(chalk.bold('To share with team:')); - console.log(chalk.dim(` git add openspec/config.yaml ${tool.skillsDir}/`)); - console.log(chalk.dim(' git commit -m "Setup OpenSpec experimental workflow"')); - console.log(); - } catch (writeError) { - // Handle file write errors - console.error(); - console.error(chalk.red('✗ Failed to write openspec/config.yaml')); - console.error(chalk.dim(` ${(writeError as Error).message}`)); - console.error(); - console.error('Fallback: Create config manually:'); - console.error(chalk.dim(' 1. Create openspec/config.yaml')); - console.error(chalk.dim(' 2. Copy the following content:')); - console.error(); - console.error(chalk.dim(yamlContent)); - console.error(); - } + // Git commit suggestion with all tool directories + const toolDirs = validatedTools.map(t => t.skillsDir + '/').join(' '); + console.log(chalk.bold('To share with team:')); + console.log(chalk.dim(` git add openspec/config.yaml ${toolDirs}`)); + console.log(chalk.dim(' git commit -m "Setup OpenSpec experimental workflow"')); + console.log(); + } catch (writeError) { + // Handle file write errors + console.error(); + console.error(chalk.red('✗ Failed to write openspec/config.yaml')); + console.error(chalk.dim(` ${(writeError as Error).message}`)); + console.error(); + console.error('Fallback: Create config manually:'); + console.error(chalk.dim(' 1. Create openspec/config.yaml')); + console.error(chalk.dim(' 2. Copy the following content:')); + console.error(); + console.error(chalk.dim(yamlContent)); + console.error(); } + } - console.log('━'.repeat(70)); - console.log(); - console.log(chalk.bold('📖 Usage:')); - console.log(); - console.log(' ' + chalk.cyan('Skills') + ' work automatically in compatible editors:'); + console.log('━'.repeat(70)); + console.log(); + console.log(chalk.bold('📖 Usage:')); + console.log(); + console.log(' ' + chalk.cyan('Skills') + ' work automatically in compatible editors:'); + for (const tool of validatedTools) { console.log(` • ${tool.name} - Skills in ${tool.skillsDir}/skills/`); + } + console.log(); + console.log(' Ask naturally:'); + console.log(' • "I want to start a new OpenSpec change to add "'); + console.log(' • "Continue working on this change"'); + console.log(' • "Implement the tasks for this change"'); + console.log(); + if (allCreatedCommandFiles.length > 0) { + console.log(' ' + chalk.cyan('Slash Commands') + ' for explicit invocation:'); + console.log(' • /opsx:explore - Think through ideas, investigate problems'); + console.log(' • /opsx:new - Start a new change'); + console.log(' • /opsx:continue - Create the next artifact'); + console.log(' • /opsx:apply - Implement tasks'); + console.log(' • /opsx:ff - Fast-forward: create all artifacts at once'); + console.log(' • /opsx:sync - Sync delta specs to main specs'); + console.log(' • /opsx:verify - Verify implementation matches artifacts'); + console.log(' • /opsx:archive - Archive a completed change'); + console.log(' • /opsx:bulk-archive - Archive multiple completed changes'); console.log(); - console.log(' Ask naturally:'); - console.log(' • "I want to start a new OpenSpec change to add "'); - console.log(' • "Continue working on this change"'); - console.log(' • "Implement the tasks for this change"'); - console.log(); - if (!commandsSkipped) { - console.log(' ' + chalk.cyan('Slash Commands') + ' for explicit invocation:'); - console.log(' • /opsx:explore - Think through ideas, investigate problems'); - console.log(' • /opsx:new - Start a new change'); - console.log(' • /opsx:continue - Create the next artifact'); - console.log(' • /opsx:apply - Implement tasks'); - console.log(' • /opsx:ff - Fast-forward: create all artifacts at once'); - console.log(' • /opsx:sync - Sync delta specs to main specs'); - console.log(' • /opsx:verify - Verify implementation matches artifacts'); - console.log(' • /opsx:archive - Archive a completed change'); - console.log(' • /opsx:bulk-archive - Archive multiple completed changes'); - console.log(); + } + // Report any failures at the end + if (failedTools.length > 0) { + console.log(chalk.red('⚠️ Some tools failed to set up:')); + for (const { name, error } of failedTools) { + console.log(chalk.red(` • ${name}: ${error.message}`)); } - console.log(chalk.yellow('💡 This is an experimental feature.')); - console.log(' Feedback welcome at: https://github.com/Fission-AI/OpenSpec/issues'); console.log(); - } catch (error) { - spinner.fail(`Failed to setup experimental artifact workflow for ${tool.name}`); - throw error; } + + console.log(chalk.yellow('💡 This is an experimental feature.')); + console.log(' Feedback welcome at: https://github.com/Fission-AI/OpenSpec/issues'); + console.log(); } // ----------------------------------------------------------------------------- @@ -1215,6 +1288,7 @@ export function registerArtifactWorkflowCommands(program: Command): void { .command('artifact-experimental-setup') .description('[Experimental] Setup Agent Skills for the experimental artifact workflow') .option('--tool ', 'Target AI tool (e.g., claude, cursor, windsurf)') + .option('--no-interactive', 'Disable interactive prompts') .action(async (options: ArtifactExperimentalSetupOptions) => { try { await artifactExperimentalSetupCommand(options); diff --git a/src/prompts/searchable-multi-select.ts b/src/prompts/searchable-multi-select.ts new file mode 100644 index 00000000..60d02839 --- /dev/null +++ b/src/prompts/searchable-multi-select.ts @@ -0,0 +1,202 @@ +import chalk from 'chalk'; + +interface Choice { + name: string; + value: string; + description?: string; +} + +interface Config { + message: string; + choices: Choice[]; + pageSize?: number; + validate?: (selected: string[]) => boolean | string; +} + +/** + * Create the searchable multi-select prompt. + * Uses dynamic import to prevent pre-commit hook hangs (see #367). + */ +async function createSearchableMultiSelect(): Promise< + (config: Config) => Promise +> { + const { + createPrompt, + useState, + useKeypress, + useMemo, + usePrefix, + isEnterKey, + isBackspaceKey, + isUpKey, + isDownKey, + } = await import('@inquirer/core'); + + return createPrompt((config: Config, done: (value: string[]) => void): string => { + const { message, choices, pageSize = 15, validate } = config; + + const [searchText, setSearchText] = useState(''); + const [selectedValues, setSelectedValues] = useState([]); + const [cursor, setCursor] = useState(0); + const [status, setStatus] = useState<'idle' | 'done'>('idle'); + const [error, setError] = useState(null); + + const prefix = usePrefix({ status }); + + // Filter choices by search + const filteredChoices = useMemo(() => { + if (!searchText.trim()) return choices; + const term = searchText.toLowerCase(); + return choices.filter( + (c) => + c.name.toLowerCase().includes(term) || + c.value.toLowerCase().includes(term) + ); + }, [searchText, choices]); + + const selectedSet = useMemo(() => new Set(selectedValues), [selectedValues]); + const choiceMap = useMemo( + () => new Map(choices.map((c) => [c.value, c])), + [choices] + ); + + useKeypress((key) => { + if (status === 'done') return; + + // Tab to confirm + if (key.name === 'tab') { + if (validate) { + const result = validate(selectedValues); + if (result !== true) { + setError(typeof result === 'string' ? result : 'Invalid'); + return; + } + } + setStatus('done'); + done(selectedValues); + return; + } + + // Enter to add item + if (isEnterKey(key)) { + const choice = filteredChoices[cursor]; + if (choice && !selectedSet.has(choice.value)) { + setSelectedValues([...selectedValues, choice.value]); + setSearchText(''); + setCursor(0); + } + return; + } + + // Backspace to remove or delete search char + if (isBackspaceKey(key)) { + if (searchText === '' && selectedValues.length > 0) { + setSelectedValues(selectedValues.slice(0, -1)); + } else { + setSearchText(searchText.slice(0, -1)); + setCursor(0); + } + return; + } + + // Navigation + if (isUpKey(key)) { + setCursor(Math.max(0, cursor - 1)); + return; + } + if (isDownKey(key)) { + setCursor(Math.min(filteredChoices.length - 1, cursor + 1)); + return; + } + + // Character input - handle printable characters + if (key.name && key.name.length === 1 && !key.ctrl) { + setSearchText(searchText + key.name); + setCursor(0); + } + }); + + // Render done state + if (status === 'done') { + const names = selectedValues + .map((v) => choiceMap.get(v)?.name ?? v) + .join(', '); + return `${prefix} ${chalk.bold(message)} ${chalk.cyan(names || '(none)')}`; + } + + // Render active state + const lines: string[] = []; + lines.push(`${prefix} ${chalk.bold(message)}`); + + // Selected chips + const chips = + selectedValues.length > 0 + ? selectedValues + .map((v) => chalk.bgCyan.black(` ${choiceMap.get(v)?.name} `)) + .join(' ') + : chalk.dim('(none selected)'); + lines.push(` Selected: ${chips}`); + + // Search box + lines.push( + ` Search: ${chalk.yellow('[')}${searchText || chalk.dim('type to filter')}${chalk.yellow(']')}` + ); + + // Instructions + lines.push( + chalk.dim(' ↑↓ navigate • Enter add • Backspace remove • Tab confirm') + ); + + // List + if (filteredChoices.length === 0) { + lines.push(chalk.yellow(' No matches')); + } else { + // Calculate pagination + const startIndex = Math.max( + 0, + Math.min(cursor - Math.floor(pageSize / 2), filteredChoices.length - pageSize) + ); + const endIndex = Math.min(startIndex + pageSize, filteredChoices.length); + const visibleChoices = filteredChoices.slice(startIndex, endIndex); + + for (let i = 0; i < visibleChoices.length; i++) { + const item = visibleChoices[i]; + const actualIndex = startIndex + i; + const isActive = actualIndex === cursor; + const selected = selectedSet.has(item.value); + const icon = selected ? chalk.green('◉') : chalk.dim('○'); + const arrow = isActive ? chalk.cyan('›') : ' '; + const name = isActive ? chalk.cyan(item.name) : item.name; + const suffix = selected ? chalk.dim(' (selected)') : ''; + lines.push(` ${arrow} ${icon} ${name}${suffix}`); + } + + // Show pagination indicator if needed + if (filteredChoices.length > pageSize) { + const currentPage = Math.floor(cursor / pageSize) + 1; + const totalPages = Math.ceil(filteredChoices.length / pageSize); + lines.push(chalk.dim(` (${currentPage}/${totalPages})`)); + } + } + + if (error) lines.push(chalk.red(` ${error}`)); + return lines.join('\n'); + }); +} + +/** + * A searchable multi-select prompt with visible search box, + * selected items display, and intuitive keyboard navigation. + * + * - Type to filter choices + * - ↑↓ to navigate + * - Enter to add highlighted item + * - Backspace to remove last selected item (or delete search char) + * - Tab to confirm selections + */ +export async function searchableMultiSelect(config: Config): Promise { + const prompt = await createSearchableMultiSelect(); + return prompt(config); +} + +export default searchableMultiSelect; diff --git a/src/ui/ascii-patterns.ts b/src/ui/ascii-patterns.ts new file mode 100644 index 00000000..5c674952 --- /dev/null +++ b/src/ui/ascii-patterns.ts @@ -0,0 +1,137 @@ +/** + * ASCII art animation patterns for the welcome screen. + * OpenSpec logo animation - diamond/rhombus shape with hollow center "O". + */ + +// Detect if full Unicode is supported +const supportsUnicode = + process.platform !== 'win32' || + !!process.env.WT_SESSION || // Windows Terminal + !!process.env.TERM_PROGRAM; // Modern terminal + +// Character set based on Unicode support +// Block characters for pixel-art aesthetic +const CHARS = supportsUnicode + ? { full: '██', dim: '░░', empty: ' ' } + : { full: '##', dim: '++', empty: ' ' }; + +const _ = CHARS.empty; +const F = CHARS.full; +const D = CHARS.dim; + +/** + * Welcome animation frames - OpenSpec logo building from center + * 7 rows × 6 columns diamond with hollow center "O" + * Center bar is 2 cols × 3 rows (rows 3,4,5 cols 3,4) + * Each frame is an array of strings (lines of ASCII art) + * Grid: 6 cols × 2 chars = 12 chars wide + */ +export const WELCOME_ANIMATION = { + interval: 120, + frames: [ + // Frame 1: Empty + [ + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3 + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + ], + // Frame 2: Center blocks appear (dim) - 2x3 center bar + [ + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3 + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${D}${D}${_}${_}`, + `${_}${_}${_}${_}${D}${D}${_}${_}`, + `${_}${_}${_}${_}${D}${D}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + ], + // Frame 3: Center blocks solidify + [ + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3 + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + ], + // Frame 4: Top and bottom points appear + [ + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3 + `${_}${_}${_}${_}${D}${D}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${_}${_}${_}${_}${_}`, + `${_}${_}${_}${_}${D}${D}${_}${_}`, + ], + // Frame 5: Inner ring forming + [ + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3 + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${D}${_}${_}${D}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${D}${_}${_}${D}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + ], + // Frame 6: Outer ring appearing + [ + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3 + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${F}${_}${_}${F}${_}`, + `${_}${_}${D}${_}${F}${F}${_}${D}`, + `${_}${_}${D}${_}${F}${F}${_}${D}`, + `${_}${_}${D}${_}${F}${F}${_}${D}`, + `${_}${_}${_}${F}${_}${_}${F}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + ], + // Frame 7: Full logo + [ + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3 + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${F}${_}${_}${F}${_}`, + `${_}${_}${F}${_}${F}${F}${_}${F}`, + `${_}${_}${F}${_}${F}${F}${_}${F}`, + `${_}${_}${F}${_}${F}${F}${_}${F}`, + `${_}${_}${_}${F}${_}${_}${F}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + ], + // Frame 8: Hold complete logo + [ + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 1 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 2 + `${_}${_}${_}${_}${_}${_}${_}${_}`, // padding row 3 + `${_}${_}${_}${_}${F}${F}${_}${_}`, + `${_}${_}${_}${F}${_}${_}${F}${_}`, + `${_}${_}${F}${_}${F}${F}${_}${F}`, + `${_}${_}${F}${_}${F}${F}${_}${F}`, + `${_}${_}${F}${_}${F}${F}${_}${F}`, + `${_}${_}${_}${F}${_}${_}${F}${_}`, + `${_}${_}${_}${_}${F}${F}${_}${_}`, + ], + ], +}; diff --git a/src/ui/welcome-screen.ts b/src/ui/welcome-screen.ts new file mode 100644 index 00000000..0876fefe --- /dev/null +++ b/src/ui/welcome-screen.ts @@ -0,0 +1,177 @@ +/** + * Animated welcome screen for the experimental artifact workflow setup. + * Shows side-by-side layout with animated ASCII art on left and welcome text on right. + */ + +import chalk from 'chalk'; +import { WELCOME_ANIMATION } from './ascii-patterns.js'; + +// Minimum terminal width for side-by-side layout +const MIN_WIDTH = 60; + +// Width of the ASCII art column (with padding) +const ART_COLUMN_WIDTH = 24; + +/** + * Welcome text content (right column) + */ +function getWelcomeText(): string[] { + return [ + chalk.white.bold('Welcome to OpenSpec'), + chalk.dim('Experimental Artifact Workflow'), + '', + chalk.white('This setup will configure:'), + chalk.dim(' • Agent Skills for AI tools'), + chalk.dim(' • /opsx:* slash commands'), + '', + chalk.white('Quick start after setup:'), + ` ${chalk.yellow('/opsx:new')} ${chalk.dim('Create a change')}`, + ` ${chalk.yellow('/opsx:continue')} ${chalk.dim('Next artifact')}`, + ` ${chalk.yellow('/opsx:apply')} ${chalk.dim('Implement tasks')}`, + '', + chalk.cyan('Press Enter to select tools...'), + ]; +} + +/** + * Renders a single frame with side-by-side layout + */ +function renderFrame(artLines: string[], textLines: string[]): string { + const maxLines = Math.max(artLines.length, textLines.length); + const lines: string[] = []; + + for (let i = 0; i < maxLines; i++) { + const artLine = artLines[i] || ''; + const textLine = textLines[i] || ''; + + // Pad the art column to fixed width + const paddedArt = artLine.padEnd(ART_COLUMN_WIDTH); + + // Color the ASCII art with cyan for visual appeal + const coloredArt = chalk.cyan(paddedArt); + + // Clear line before writing to prevent residual characters + lines.push(`\x1b[2K${coloredArt}${textLine}`); + } + + return lines.join('\n'); +} + +/** + * Checks if the terminal supports animation + */ +function canAnimate(): boolean { + // Must be TTY + if (!process.stdout.isTTY) return false; + + // Respect NO_COLOR + if (process.env.NO_COLOR) return false; + + // Check terminal width + const columns = process.stdout.columns || 80; + if (columns < MIN_WIDTH) return false; + + return true; +} + +/** + * Wait for Enter key press + */ +function waitForEnter(): Promise { + return new Promise((resolve) => { + const { stdin } = process; + + // Handle non-TTY gracefully + if (!stdin.isTTY) { + resolve(); + return; + } + + const wasRaw = stdin.isRaw; + stdin.setRawMode(true); + stdin.resume(); + + const onData = (data: Buffer): void => { + const char = data.toString(); + + // Enter key or Ctrl+C + if (char === '\r' || char === '\n' || char === '\u0003') { + stdin.removeListener('data', onData); + stdin.setRawMode(wasRaw); + stdin.pause(); + + // Handle Ctrl+C + if (char === '\u0003') { + process.stdout.write('\n'); + process.exit(0); + } + + resolve(); + } + }; + + stdin.on('data', onData); + }); +} + +/** + * Shows the animated welcome screen. + * Returns when user presses Enter. + */ +export async function showWelcomeScreen(): Promise { + const textLines = getWelcomeText(); + + if (!canAnimate()) { + // Fallback: show static welcome + const frame = WELCOME_ANIMATION.frames[3]; // Peak frame + process.stdout.write('\n' + renderFrame(frame, textLines) + '\n\n'); + return; + } + + let frameIndex = 0; + let running = true; + let isFirstRender = true; + + // Content height for cursor movement between frames + const numContentLines = Math.max(WELCOME_ANIMATION.frames[0].length, textLines.length); + const frameHeight = numContentLines + 1; // internal newlines (11) + trailing newlines (2) = 13 + + // Total height including initial newline (for cleanup) + const totalHeight = frameHeight + 1; // 14 + + // Initial render + process.stdout.write('\n'); + + // Animation loop + const interval = setInterval(() => { + if (!running) return; + + const frame = WELCOME_ANIMATION.frames[frameIndex]; + + // Move cursor up to overwrite previous frame (always after first render) + if (!isFirstRender) { + process.stdout.write(`\x1b[${frameHeight}A`); + } + isFirstRender = false; + + // Render current frame + process.stdout.write(renderFrame(frame, textLines) + '\n\n'); + + // Advance to next frame + frameIndex = (frameIndex + 1) % WELCOME_ANIMATION.frames.length; + }, WELCOME_ANIMATION.interval); + + // Wait for Enter + await waitForEnter(); + + // Stop animation + running = false; + clearInterval(interval); + + // Clear the welcome screen and move on + process.stdout.write(`\x1b[${totalHeight}A`); + for (let i = 0; i < totalHeight; i++) { + process.stdout.write('\x1b[2K\n'); // Clear line + } + process.stdout.write(`\x1b[${totalHeight}A`); // Move back up +}