From 33b51936594607a9c7840cb5b8be8bb5f3cdaa7b Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Thu, 22 Jan 2026 20:38:44 -0800 Subject: [PATCH 1/2] refactor(commands): modularize artifact workflow into separate files Split the monolithic artifact-workflow.ts into separate modules under src/commands/experimental/: - index.ts: main exports and command registration - status.ts: status display logic - new-change.ts: change creation logic - schemas.ts: Zod schemas - setup.ts: setup command logic - templates.ts: template generation - shared.ts: shared utilities - instructions.ts: instruction generation Also extracted init wizard logic to src/core/init/wizard.ts. --- src/cli/index.ts | 2 +- src/commands/artifact-workflow.ts | 1301 --------------------- src/commands/experimental/index.ts | 139 +++ src/commands/experimental/instructions.ts | 481 ++++++++ src/commands/experimental/new-change.ts | 61 + src/commands/experimental/schemas.ts | 46 + src/commands/experimental/setup.ts | 358 ++++++ src/commands/experimental/shared.ts | 161 +++ src/commands/experimental/status.ts | 90 ++ src/commands/experimental/templates.ts | 91 ++ src/core/init.ts | 531 ++------- src/core/init/wizard.ts | 373 ++++++ 12 files changed, 1910 insertions(+), 1724 deletions(-) delete mode 100644 src/commands/artifact-workflow.ts create mode 100644 src/commands/experimental/index.ts create mode 100644 src/commands/experimental/instructions.ts create mode 100644 src/commands/experimental/new-change.ts create mode 100644 src/commands/experimental/schemas.ts create mode 100644 src/commands/experimental/setup.ts create mode 100644 src/commands/experimental/shared.ts create mode 100644 src/commands/experimental/status.ts create mode 100644 src/commands/experimental/templates.ts create mode 100644 src/core/init/wizard.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index 4dc22ee6..29e2edf6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,7 +15,7 @@ import { ShowCommand } from '../commands/show.js'; import { CompletionCommand } from '../commands/completion.js'; import { FeedbackCommand } from '../commands/feedback.js'; import { registerConfigCommand } from '../commands/config.js'; -import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js'; +import { registerArtifactWorkflowCommands } from '../commands/experimental/index.js'; import { registerSchemaCommand } from '../commands/schema.js'; import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; diff --git a/src/commands/artifact-workflow.ts b/src/commands/artifact-workflow.ts deleted file mode 100644 index 47adea4d..00000000 --- a/src/commands/artifact-workflow.ts +++ /dev/null @@ -1,1301 +0,0 @@ -/** - * Artifact Workflow CLI Commands (Experimental) - * - * This file contains all artifact workflow commands in isolation for easy removal. - * Commands expose the ArtifactGraph and InstructionLoader APIs to users and agents. - * - * To remove this feature: - * 1. Delete this file - * 2. Remove the registerArtifactWorkflowCommands() call from src/cli/index.ts - */ - -import type { Command } from 'commander'; -import ora from 'ora'; -import chalk from 'chalk'; -import path from 'path'; -import * as fs from 'fs'; -import { - loadChangeContext, - formatChangeStatus, - generateInstructions, - listSchemas, - listSchemasWithInfo, - getSchemaDir, - resolveSchema, - ArtifactGraph, - type ChangeStatus, - type ArtifactInstructions, - type SchemaInfo, -} from '../core/artifact-graph/index.js'; -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'; -import { - generateCommands, - CommandAdapterRegistry, - type CommandContent, -} from '../core/command-generation/index.js'; - -// ----------------------------------------------------------------------------- -// Types for Apply Instructions -// ----------------------------------------------------------------------------- - -interface TaskItem { - id: string; - description: string; - done: boolean; -} - -interface ApplyInstructions { - changeName: string; - changeDir: string; - schemaName: string; - contextFiles: Record; - progress: { - total: number; - complete: number; - remaining: number; - }; - tasks: TaskItem[]; - state: 'blocked' | 'all_done' | 'ready'; - missingArtifacts?: string[]; - instruction: string; -} - -const DEFAULT_SCHEMA = 'spec-driven'; - -/** - * Checks if color output is disabled via NO_COLOR env or --no-color flag. - */ -function isColorDisabled(): boolean { - return process.env.NO_COLOR === '1' || process.env.NO_COLOR === 'true'; -} - -/** - * Gets the color function based on status. - */ -function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: string) => string { - if (isColorDisabled()) { - return (text: string) => text; - } - switch (status) { - case 'done': - return chalk.green; - case 'ready': - return chalk.yellow; - case 'blocked': - return chalk.red; - } -} - -/** - * Gets the status indicator for an artifact. - */ -function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string { - const color = getStatusColor(status); - switch (status) { - case 'done': - return color('[x]'); - case 'ready': - return color('[ ]'); - case 'blocked': - return color('[-]'); - } -} - -/** - * Validates that a change exists and returns available changes if not. - * Checks directory existence directly to support scaffolded changes (without proposal.md). - */ -async function validateChangeExists( - changeName: string | undefined, - projectRoot: string -): Promise { - const changesPath = path.join(projectRoot, 'openspec', 'changes'); - - // Get all change directories (not just those with proposal.md) - const getAvailableChanges = async (): Promise => { - try { - const entries = await fs.promises.readdir(changesPath, { withFileTypes: true }); - return entries - .filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.')) - .map((e) => e.name); - } catch { - return []; - } - }; - - if (!changeName) { - const available = await getAvailableChanges(); - if (available.length === 0) { - throw new Error('No changes found. Create one with: openspec new change '); - } - throw new Error( - `Missing required option --change. Available changes:\n ${available.join('\n ')}` - ); - } - - // Validate change name format to prevent path traversal - const nameValidation = validateChangeName(changeName); - if (!nameValidation.valid) { - throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`); - } - - // Check directory existence directly - const changePath = path.join(changesPath, changeName); - const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory(); - - if (!exists) { - const available = await getAvailableChanges(); - if (available.length === 0) { - throw new Error( - `Change '${changeName}' not found. No changes exist. Create one with: openspec new change ` - ); - } - throw new Error( - `Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}` - ); - } - - return changeName; -} - -/** - * Validates that a schema exists and returns available schemas if not. - * - * @param schemaName - The schema name to validate - * @param projectRoot - Optional project root for project-local schema resolution - */ -function validateSchemaExists(schemaName: string, projectRoot?: string): string { - const schemaDir = getSchemaDir(schemaName, projectRoot); - if (!schemaDir) { - const availableSchemas = listSchemas(projectRoot); - throw new Error( - `Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}` - ); - } - return schemaName; -} - -// ----------------------------------------------------------------------------- -// Status Command -// ----------------------------------------------------------------------------- - -interface StatusOptions { - change?: string; - schema?: string; - json?: boolean; -} - -async function statusCommand(options: StatusOptions): Promise { - const spinner = ora('Loading change status...').start(); - - try { - const projectRoot = process.cwd(); - const changeName = await validateChangeExists(options.change, projectRoot); - - // Validate schema if explicitly provided - if (options.schema) { - validateSchemaExists(options.schema, projectRoot); - } - - // loadChangeContext will auto-detect schema from metadata if not provided - const context = loadChangeContext(projectRoot, changeName, options.schema); - const status = formatChangeStatus(context); - - spinner.stop(); - - if (options.json) { - console.log(JSON.stringify(status, null, 2)); - return; - } - - printStatusText(status); - } catch (error) { - spinner.stop(); - throw error; - } -} - -function printStatusText(status: ChangeStatus): void { - const doneCount = status.artifacts.filter((a) => a.status === 'done').length; - const total = status.artifacts.length; - - console.log(`Change: ${status.changeName}`); - console.log(`Schema: ${status.schemaName}`); - console.log(`Progress: ${doneCount}/${total} artifacts complete`); - console.log(); - - for (const artifact of status.artifacts) { - const indicator = getStatusIndicator(artifact.status); - const color = getStatusColor(artifact.status); - let line = `${indicator} ${artifact.id}`; - - if (artifact.status === 'blocked' && artifact.missingDeps && artifact.missingDeps.length > 0) { - line += color(` (blocked by: ${artifact.missingDeps.join(', ')})`); - } - - console.log(line); - } - - if (status.isComplete) { - console.log(); - console.log(chalk.green('All artifacts complete!')); - } -} - -// ----------------------------------------------------------------------------- -// Instructions Command -// ----------------------------------------------------------------------------- - -interface InstructionsOptions { - change?: string; - schema?: string; - json?: boolean; -} - -async function instructionsCommand( - artifactId: string | undefined, - options: InstructionsOptions -): Promise { - const spinner = ora('Generating instructions...').start(); - - try { - const projectRoot = process.cwd(); - const changeName = await validateChangeExists(options.change, projectRoot); - - // Validate schema if explicitly provided - if (options.schema) { - validateSchemaExists(options.schema, projectRoot); - } - - // loadChangeContext will auto-detect schema from metadata if not provided - const context = loadChangeContext(projectRoot, changeName, options.schema); - - if (!artifactId) { - spinner.stop(); - const validIds = context.graph.getAllArtifacts().map((a) => a.id); - throw new Error( - `Missing required argument . Valid artifacts:\n ${validIds.join('\n ')}` - ); - } - - const artifact = context.graph.getArtifact(artifactId); - - if (!artifact) { - spinner.stop(); - const validIds = context.graph.getAllArtifacts().map((a) => a.id); - throw new Error( - `Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}` - ); - } - - const instructions = generateInstructions(context, artifactId, projectRoot); - const isBlocked = instructions.dependencies.some((d) => !d.done); - - spinner.stop(); - - if (options.json) { - console.log(JSON.stringify(instructions, null, 2)); - return; - } - - printInstructionsText(instructions, isBlocked); - } catch (error) { - spinner.stop(); - throw error; - } -} - -function printInstructionsText(instructions: ArtifactInstructions, isBlocked: boolean): void { - const { - artifactId, - changeName, - schemaName, - changeDir, - outputPath, - description, - instruction, - context, - rules, - template, - dependencies, - unlocks, - } = instructions; - - // Opening tag - console.log(``); - console.log(); - - // Warning for blocked artifacts - if (isBlocked) { - const missing = dependencies.filter((d) => !d.done).map((d) => d.id); - console.log(''); - console.log('This artifact has unmet dependencies. Complete them first or proceed with caution.'); - console.log(`Missing: ${missing.join(', ')}`); - console.log(''); - console.log(); - } - - // Task directive - console.log(''); - console.log(`Create the ${artifactId} artifact for change "${changeName}".`); - console.log(description); - console.log(''); - console.log(); - - // Project context (AI constraint - do not include in output) - if (context) { - console.log(''); - console.log(''); - console.log(context); - console.log(''); - console.log(); - } - - // Rules (AI constraint - do not include in output) - if (rules && rules.length > 0) { - console.log(''); - console.log(''); - for (const rule of rules) { - console.log(`- ${rule}`); - } - console.log(''); - console.log(); - } - - // Dependencies (files to read for context) - if (dependencies.length > 0) { - console.log(''); - console.log('Read these files for context before creating this artifact:'); - console.log(); - for (const dep of dependencies) { - const status = dep.done ? 'done' : 'missing'; - const fullPath = path.join(changeDir, dep.path); - console.log(``); - console.log(` ${fullPath}`); - console.log(` ${dep.description}`); - console.log(''); - } - console.log(''); - console.log(); - } - - // Output location - console.log(''); - console.log(`Write to: ${path.join(changeDir, outputPath)}`); - console.log(''); - console.log(); - - // Instruction (guidance) - if (instruction) { - console.log(''); - console.log(instruction.trim()); - console.log(''); - console.log(); - } - - // Template - console.log(''); - console.log(); - - // Success criteria placeholder - console.log(''); - console.log(''); - console.log(''); - console.log(); - - // Unlocks - if (unlocks.length > 0) { - console.log(''); - console.log(`Completing this artifact enables: ${unlocks.join(', ')}`); - console.log(''); - console.log(); - } - - // Closing tag - console.log(''); -} - -// ----------------------------------------------------------------------------- -// Apply Instructions Command -// ----------------------------------------------------------------------------- - -interface ApplyInstructionsOptions { - change?: string; - schema?: string; - json?: boolean; -} - -/** - * Parses tasks.md content and extracts task items with their completion status. - */ -function parseTasksFile(content: string): TaskItem[] { - const tasks: TaskItem[] = []; - const lines = content.split('\n'); - let taskIndex = 0; - - for (const line of lines) { - // Match checkbox patterns: - [ ] or - [x] or - [X] - const checkboxMatch = line.match(/^[-*]\s*\[([ xX])\]\s*(.+)$/); - if (checkboxMatch) { - taskIndex++; - const done = checkboxMatch[1].toLowerCase() === 'x'; - const description = checkboxMatch[2].trim(); - tasks.push({ - id: `${taskIndex}`, - description, - done, - }); - } - } - - return tasks; -} - -/** - * Checks if an artifact output exists in the change directory. - * Supports glob patterns (e.g., "specs/*.md") by verifying at least one matching file exists. - */ -function artifactOutputExists(changeDir: string, generates: string): boolean { - // Normalize the generates path to use platform-specific separators - const normalizedGenerates = generates.split('/').join(path.sep); - const fullPath = path.join(changeDir, normalizedGenerates); - - // If it's a glob pattern (contains ** or *), check for matching files - if (generates.includes('*')) { - // Extract the directory part before the glob pattern - const parts = normalizedGenerates.split(path.sep); - const dirParts: string[] = []; - let patternPart = ''; - for (const part of parts) { - if (part.includes('*')) { - patternPart = part; - break; - } - dirParts.push(part); - } - const dirPath = path.join(changeDir, ...dirParts); - - // Check if directory exists - if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { - return false; - } - - // Extract expected extension from pattern (e.g., "*.md" -> ".md") - const extMatch = patternPart.match(/\*(\.[a-zA-Z0-9]+)$/); - const expectedExt = extMatch ? extMatch[1] : null; - - // Recursively check for matching files - const hasMatchingFiles = (dir: string): boolean => { - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - // For ** patterns, recurse into subdirectories - if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) { - return true; - } - } else if (entry.isFile()) { - // Check if file matches expected extension (or any file if no extension specified) - if (!expectedExt || entry.name.endsWith(expectedExt)) { - return true; - } - } - } - } catch { - return false; - } - return false; - }; - - return hasMatchingFiles(dirPath); - } - - return fs.existsSync(fullPath); -} - -/** - * Generates apply instructions for implementing tasks from a change. - * Schema-aware: reads apply phase configuration from schema to determine - * required artifacts, tracking file, and instruction. - */ -async function generateApplyInstructions( - projectRoot: string, - changeName: string, - schemaName?: string -): Promise { - // loadChangeContext will auto-detect schema from metadata if not provided - const context = loadChangeContext(projectRoot, changeName, schemaName); - const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); - - // Get the full schema to access the apply phase configuration - const schema = resolveSchema(context.schemaName); - const applyConfig = schema.apply; - - // Determine required artifacts and tracking file from schema - // Fallback: if no apply block, require all artifacts - const requiredArtifactIds = applyConfig?.requires ?? schema.artifacts.map((a) => a.id); - const tracksFile = applyConfig?.tracks ?? null; - const schemaInstruction = applyConfig?.instruction ?? null; - - // Check which required artifacts are missing - const missingArtifacts: string[] = []; - for (const artifactId of requiredArtifactIds) { - const artifact = schema.artifacts.find((a) => a.id === artifactId); - if (artifact && !artifactOutputExists(changeDir, artifact.generates)) { - missingArtifacts.push(artifactId); - } - } - - // Build context files from all existing artifacts in schema - const contextFiles: Record = {}; - for (const artifact of schema.artifacts) { - if (artifactOutputExists(changeDir, artifact.generates)) { - contextFiles[artifact.id] = path.join(changeDir, artifact.generates); - } - } - - // Parse tasks if tracking file exists - let tasks: TaskItem[] = []; - let tracksFileExists = false; - if (tracksFile) { - const tracksPath = path.join(changeDir, tracksFile); - tracksFileExists = fs.existsSync(tracksPath); - if (tracksFileExists) { - const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8'); - tasks = parseTasksFile(tasksContent); - } - } - - // Calculate progress - const total = tasks.length; - const complete = tasks.filter((t) => t.done).length; - const remaining = total - complete; - - // Determine state and instruction - let state: ApplyInstructions['state']; - let instruction: string; - - if (missingArtifacts.length > 0) { - state = 'blocked'; - instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nUse the openspec-continue-change skill to create the missing artifacts first.`; - } else if (tracksFile && !tracksFileExists) { - // Tracking file configured but doesn't exist yet - const tracksFilename = path.basename(tracksFile); - state = 'blocked'; - instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`; - } else if (tracksFile && tracksFileExists && total === 0) { - // Tracking file exists but contains no tasks - const tracksFilename = path.basename(tracksFile); - state = 'blocked'; - instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`; - } else if (tracksFile && remaining === 0 && total > 0) { - state = 'all_done'; - instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.'; - } else if (!tracksFile) { - // No tracking file (e.g., TDD schema) - ready to apply - state = 'ready'; - instruction = schemaInstruction?.trim() ?? 'All required artifacts complete. Proceed with implementation.'; - } else { - state = 'ready'; - instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.'; - } - - return { - changeName, - changeDir, - schemaName: context.schemaName, - contextFiles, - progress: { total, complete, remaining }, - tasks, - state, - missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined, - instruction, - }; -} - -async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise { - const spinner = ora('Generating apply instructions...').start(); - - try { - const projectRoot = process.cwd(); - const changeName = await validateChangeExists(options.change, projectRoot); - - // Validate schema if explicitly provided - if (options.schema) { - validateSchemaExists(options.schema, projectRoot); - } - - // generateApplyInstructions uses loadChangeContext which auto-detects schema - const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema); - - spinner.stop(); - - if (options.json) { - console.log(JSON.stringify(instructions, null, 2)); - return; - } - - printApplyInstructionsText(instructions); - } catch (error) { - spinner.stop(); - throw error; - } -} - -function printApplyInstructionsText(instructions: ApplyInstructions): void { - const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions; - - console.log(`## Apply: ${changeName}`); - console.log(`Schema: ${schemaName}`); - console.log(); - - // Warning for blocked state - if (state === 'blocked' && missingArtifacts) { - console.log('### โš ๏ธ Blocked'); - console.log(); - console.log(`Missing artifacts: ${missingArtifacts.join(', ')}`); - console.log('Use the openspec-continue-change skill to create these first.'); - console.log(); - } - - // Context files (dynamically from schema) - const contextFileEntries = Object.entries(contextFiles); - if (contextFileEntries.length > 0) { - console.log('### Context Files'); - for (const [artifactId, filePath] of contextFileEntries) { - console.log(`- ${artifactId}: ${filePath}`); - } - console.log(); - } - - // Progress (only show if we have tracking) - if (progress.total > 0 || tasks.length > 0) { - console.log('### Progress'); - if (state === 'all_done') { - console.log(`${progress.complete}/${progress.total} complete โœ“`); - } else { - console.log(`${progress.complete}/${progress.total} complete`); - } - console.log(); - } - - // Tasks - if (tasks.length > 0) { - console.log('### Tasks'); - for (const task of tasks) { - const checkbox = task.done ? '[x]' : '[ ]'; - console.log(`- ${checkbox} ${task.description}`); - } - console.log(); - } - - // Instruction - console.log('### Instruction'); - console.log(instruction); -} - -// ----------------------------------------------------------------------------- -// Templates Command -// ----------------------------------------------------------------------------- - -interface TemplatesOptions { - schema?: string; - json?: boolean; -} - -interface TemplateInfo { - artifactId: string; - templatePath: string; - source: 'project' | 'user' | 'package'; -} - -async function templatesCommand(options: TemplatesOptions): Promise { - const spinner = ora('Loading templates...').start(); - - try { - const projectRoot = process.cwd(); - const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot); - const schema = resolveSchema(schemaName, projectRoot); - const graph = ArtifactGraph.fromSchema(schema); - const schemaDir = getSchemaDir(schemaName, projectRoot)!; - - // Determine the source (project, user, or package) - const { - getUserSchemasDir, - getProjectSchemasDir, - } = await import('../core/artifact-graph/resolver.js'); - const projectSchemasDir = getProjectSchemasDir(projectRoot); - const userSchemasDir = getUserSchemasDir(); - - let source: 'project' | 'user' | 'package'; - if (schemaDir.startsWith(projectSchemasDir)) { - source = 'project'; - } else if (schemaDir.startsWith(userSchemasDir)) { - source = 'user'; - } else { - source = 'package'; - } - - const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({ - artifactId: artifact.id, - templatePath: path.join(schemaDir, 'templates', artifact.template), - source, - })); - - spinner.stop(); - - if (options.json) { - const output: Record = {}; - for (const t of templates) { - output[t.artifactId] = { path: t.templatePath, source: t.source }; - } - console.log(JSON.stringify(output, null, 2)); - return; - } - - console.log(`Schema: ${schemaName}`); - console.log(`Source: ${source}`); - console.log(); - - for (const t of templates) { - console.log(`${t.artifactId}:`); - console.log(` ${t.templatePath}`); - } - } catch (error) { - spinner.stop(); - throw error; - } -} - -// ----------------------------------------------------------------------------- -// New Change Command -// ----------------------------------------------------------------------------- - -interface NewChangeOptions { - description?: string; - schema?: string; -} - -async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise { - if (!name) { - throw new Error('Missing required argument '); - } - - const validation = validateChangeName(name); - if (!validation.valid) { - throw new Error(validation.error); - } - - const projectRoot = process.cwd(); - - // Validate schema if provided - if (options.schema) { - validateSchemaExists(options.schema, projectRoot); - } - - const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : ''; - const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start(); - - try { - const result = await createChange(projectRoot, name, { schema: options.schema }); - - // If description provided, create README.md with description - if (options.description) { - const { promises: fs } = await import('fs'); - const changeDir = path.join(projectRoot, 'openspec', 'changes', name); - const readmePath = path.join(changeDir, 'README.md'); - await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8'); - } - - spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${result.schema})`); - } catch (error) { - spinner.fail(`Failed to create change '${name}'`); - throw error; - } -} - -// ----------------------------------------------------------------------------- -// Artifact Experimental Setup Command -// ----------------------------------------------------------------------------- - -interface ArtifactExperimentalSetupOptions { - tool?: string; - interactive?: boolean; - selectedTools?: string[]; // For multi-select from interactive prompt -} - -/** - * Gets the list of tools with skillsDir configured. - */ -function getToolsWithSkillsDir(): string[] { - return AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value); -} - -/** - * Generates Agent Skills and slash commands for the experimental artifact workflow. - * Creates /skills/ directory with SKILL.md files following Agent Skills spec. - * Creates slash commands using tool-specific adapters. - */ -async function artifactExperimentalSetupCommand(options: ArtifactExperimentalSetupOptions): Promise { - const projectRoot = process.cwd(); - - // Validate --tool flag is provided or prompt interactively - if (!options.tool) { - const validTools = getToolsWithSkillsDir(); - 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', - }); - - 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 ')}` - ); - } - } - - // 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 }); - } - - // 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'); - - // 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} ---- - -${template.instructions} -`; - - // 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); - - 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); - } - - 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}`); - } - - // 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`)); - 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(); - } - - if (allCreatedCommandFiles.length > 0) { - console.log(chalk.bold('Slash Commands Created:')); - for (const file of allCreatedCommandFiles) { - 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(); - - // 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(); - } 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(' 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 }); - - 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 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:'); - 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(); - } - // 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(); - } - - console.log(chalk.yellow('๐Ÿ’ก This is an experimental feature.')); - console.log(' Feedback welcome at: https://github.com/Fission-AI/OpenSpec/issues'); - console.log(); -} - -// ----------------------------------------------------------------------------- -// Schemas Command -// ----------------------------------------------------------------------------- - -interface SchemasOptions { - json?: boolean; -} - -async function schemasCommand(options: SchemasOptions): Promise { - const projectRoot = process.cwd(); - const schemas = listSchemasWithInfo(projectRoot); - - if (options.json) { - console.log(JSON.stringify(schemas, null, 2)); - return; - } - - console.log('Available schemas:'); - console.log(); - - for (const schema of schemas) { - let sourceLabel = ''; - if (schema.source === 'project') { - sourceLabel = chalk.cyan(' (project)'); - } else if (schema.source === 'user') { - sourceLabel = chalk.dim(' (user override)'); - } - console.log(` ${chalk.bold(schema.name)}${sourceLabel}`); - console.log(` ${schema.description}`); - console.log(` Artifacts: ${schema.artifacts.join(' โ†’ ')}`); - console.log(); - } -} - -// ----------------------------------------------------------------------------- -// Command Registration -// ----------------------------------------------------------------------------- - -/** - * Registers all artifact workflow commands on the given program. - * All commands are marked as experimental in their help text. - */ -export function registerArtifactWorkflowCommands(program: Command): void { - // Status command - program - .command('status') - .description('[Experimental] Display artifact completion status for a change') - .option('--change ', 'Change name to show status for') - .option('--schema ', 'Schema override (auto-detected from .openspec.yaml)') - .option('--json', 'Output as JSON') - .action(async (options: StatusOptions) => { - try { - await statusCommand(options); - } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); - process.exit(1); - } - }); - - // Instructions command - program - .command('instructions [artifact]') - .description('[Experimental] Output enriched instructions for creating an artifact or applying tasks') - .option('--change ', 'Change name') - .option('--schema ', 'Schema override (auto-detected from .openspec.yaml)') - .option('--json', 'Output as JSON') - .action(async (artifactId: string | undefined, options: InstructionsOptions) => { - try { - // Special case: "apply" is not an artifact, but a command to get apply instructions - if (artifactId === 'apply') { - await applyInstructionsCommand(options); - } else { - await instructionsCommand(artifactId, options); - } - } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); - process.exit(1); - } - }); - - // Templates command - program - .command('templates') - .description('[Experimental] Show resolved template paths for all artifacts in a schema') - .option('--schema ', `Schema to use (default: ${DEFAULT_SCHEMA})`) - .option('--json', 'Output as JSON mapping artifact IDs to template paths') - .action(async (options: TemplatesOptions) => { - try { - await templatesCommand(options); - } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); - process.exit(1); - } - }); - - // Schemas command - program - .command('schemas') - .description('[Experimental] List available workflow schemas with descriptions') - .option('--json', 'Output as JSON (for agent use)') - .action(async (options: SchemasOptions) => { - try { - await schemasCommand(options); - } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); - process.exit(1); - } - }); - - // New command group with change subcommand - const newCmd = program.command('new').description('[Experimental] Create new items'); - - newCmd - .command('change ') - .description('[Experimental] Create a new change directory') - .option('--description ', 'Description to add to README.md') - .option('--schema ', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) - .action(async (name: string, options: NewChangeOptions) => { - try { - await newChangeCommand(name, options); - } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); - process.exit(1); - } - }); - - // Artifact experimental setup command - program - .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); - } catch (error) { - console.log(); - ora().fail(`Error: ${(error as Error).message}`); - process.exit(1); - } - }); -} diff --git a/src/commands/experimental/index.ts b/src/commands/experimental/index.ts new file mode 100644 index 00000000..94341930 --- /dev/null +++ b/src/commands/experimental/index.ts @@ -0,0 +1,139 @@ +/** + * Artifact Workflow CLI Commands (Experimental) + * + * This module contains all artifact workflow commands in isolation for easy removal. + * Commands expose the ArtifactGraph and InstructionLoader APIs to users and agents. + * + * To remove this feature: + * 1. Delete this directory + * 2. Remove the registerArtifactWorkflowCommands() call from src/cli/index.ts + */ + +import type { Command } from 'commander'; +import ora from 'ora'; + +import { DEFAULT_SCHEMA } from './shared.js'; +import { statusCommand, type StatusOptions } from './status.js'; +import { + instructionsCommand, + applyInstructionsCommand, + type InstructionsOptions, +} from './instructions.js'; +import { templatesCommand, type TemplatesOptions } from './templates.js'; +import { schemasCommand, type SchemasOptions } from './schemas.js'; +import { newChangeCommand, type NewChangeOptions } from './new-change.js'; +import { artifactExperimentalSetupCommand, type ArtifactExperimentalSetupOptions } from './setup.js'; + +// ----------------------------------------------------------------------------- +// Command Registration +// ----------------------------------------------------------------------------- + +/** + * Registers all artifact workflow commands on the given program. + * All commands are marked as experimental in their help text. + */ +export function registerArtifactWorkflowCommands(program: Command): void { + // Status command + program + .command('status') + .description('[Experimental] Display artifact completion status for a change') + .option('--change ', 'Change name to show status for') + .option('--schema ', 'Schema override (auto-detected from .openspec.yaml)') + .option('--json', 'Output as JSON') + .action(async (options: StatusOptions) => { + try { + await statusCommand(options); + } catch (error) { + console.log(); + ora().fail(`Error: ${(error as Error).message}`); + process.exit(1); + } + }); + + // Instructions command + program + .command('instructions [artifact]') + .description('[Experimental] Output enriched instructions for creating an artifact or applying tasks') + .option('--change ', 'Change name') + .option('--schema ', 'Schema override (auto-detected from .openspec.yaml)') + .option('--json', 'Output as JSON') + .action(async (artifactId: string | undefined, options: InstructionsOptions) => { + try { + // Special case: "apply" is not an artifact, but a command to get apply instructions + if (artifactId === 'apply') { + await applyInstructionsCommand(options); + } else { + await instructionsCommand(artifactId, options); + } + } catch (error) { + console.log(); + ora().fail(`Error: ${(error as Error).message}`); + process.exit(1); + } + }); + + // Templates command + program + .command('templates') + .description('[Experimental] Show resolved template paths for all artifacts in a schema') + .option('--schema ', `Schema to use (default: ${DEFAULT_SCHEMA})`) + .option('--json', 'Output as JSON mapping artifact IDs to template paths') + .action(async (options: TemplatesOptions) => { + try { + await templatesCommand(options); + } catch (error) { + console.log(); + ora().fail(`Error: ${(error as Error).message}`); + process.exit(1); + } + }); + + // Schemas command + program + .command('schemas') + .description('[Experimental] List available workflow schemas with descriptions') + .option('--json', 'Output as JSON (for agent use)') + .action(async (options: SchemasOptions) => { + try { + await schemasCommand(options); + } catch (error) { + console.log(); + ora().fail(`Error: ${(error as Error).message}`); + process.exit(1); + } + }); + + // New command group with change subcommand + const newCmd = program.command('new').description('[Experimental] Create new items'); + + newCmd + .command('change ') + .description('[Experimental] Create a new change directory') + .option('--description ', 'Description to add to README.md') + .option('--schema ', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) + .action(async (name: string, options: NewChangeOptions) => { + try { + await newChangeCommand(name, options); + } catch (error) { + console.log(); + ora().fail(`Error: ${(error as Error).message}`); + process.exit(1); + } + }); + + // Artifact experimental setup command + program + .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); + } catch (error) { + console.log(); + ora().fail(`Error: ${(error as Error).message}`); + process.exit(1); + } + }); +} diff --git a/src/commands/experimental/instructions.ts b/src/commands/experimental/instructions.ts new file mode 100644 index 00000000..5add248b --- /dev/null +++ b/src/commands/experimental/instructions.ts @@ -0,0 +1,481 @@ +/** + * Instructions Command + * + * Generates enriched instructions for creating artifacts or applying tasks. + * Includes both artifact instructions and apply instructions. + */ + +import ora from 'ora'; +import path from 'path'; +import * as fs from 'fs'; +import { + loadChangeContext, + generateInstructions, + resolveSchema, + type ArtifactInstructions, +} from '../../core/artifact-graph/index.js'; +import { + validateChangeExists, + validateSchemaExists, + type TaskItem, + type ApplyInstructions, +} from './shared.js'; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface InstructionsOptions { + change?: string; + schema?: string; + json?: boolean; +} + +export interface ApplyInstructionsOptions { + change?: string; + schema?: string; + json?: boolean; +} + +// ----------------------------------------------------------------------------- +// Artifact Instructions Command +// ----------------------------------------------------------------------------- + +export async function instructionsCommand( + artifactId: string | undefined, + options: InstructionsOptions +): Promise { + const spinner = ora('Generating instructions...').start(); + + try { + const projectRoot = process.cwd(); + const changeName = await validateChangeExists(options.change, projectRoot); + + // Validate schema if explicitly provided + if (options.schema) { + validateSchemaExists(options.schema, projectRoot); + } + + // loadChangeContext will auto-detect schema from metadata if not provided + const context = loadChangeContext(projectRoot, changeName, options.schema); + + if (!artifactId) { + spinner.stop(); + const validIds = context.graph.getAllArtifacts().map((a) => a.id); + throw new Error( + `Missing required argument . Valid artifacts:\n ${validIds.join('\n ')}` + ); + } + + const artifact = context.graph.getArtifact(artifactId); + + if (!artifact) { + spinner.stop(); + const validIds = context.graph.getAllArtifacts().map((a) => a.id); + throw new Error( + `Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}` + ); + } + + const instructions = generateInstructions(context, artifactId, projectRoot); + const isBlocked = instructions.dependencies.some((d) => !d.done); + + spinner.stop(); + + if (options.json) { + console.log(JSON.stringify(instructions, null, 2)); + return; + } + + printInstructionsText(instructions, isBlocked); + } catch (error) { + spinner.stop(); + throw error; + } +} + +export function printInstructionsText(instructions: ArtifactInstructions, isBlocked: boolean): void { + const { + artifactId, + changeName, + schemaName, + changeDir, + outputPath, + description, + instruction, + context, + rules, + template, + dependencies, + unlocks, + } = instructions; + + // Opening tag + console.log(``); + console.log(); + + // Warning for blocked artifacts + if (isBlocked) { + const missing = dependencies.filter((d) => !d.done).map((d) => d.id); + console.log(''); + console.log('This artifact has unmet dependencies. Complete them first or proceed with caution.'); + console.log(`Missing: ${missing.join(', ')}`); + console.log(''); + console.log(); + } + + // Task directive + console.log(''); + console.log(`Create the ${artifactId} artifact for change "${changeName}".`); + console.log(description); + console.log(''); + console.log(); + + // Project context (AI constraint - do not include in output) + if (context) { + console.log(''); + console.log(''); + console.log(context); + console.log(''); + console.log(); + } + + // Rules (AI constraint - do not include in output) + if (rules && rules.length > 0) { + console.log(''); + console.log(''); + for (const rule of rules) { + console.log(`- ${rule}`); + } + console.log(''); + console.log(); + } + + // Dependencies (files to read for context) + if (dependencies.length > 0) { + console.log(''); + console.log('Read these files for context before creating this artifact:'); + console.log(); + for (const dep of dependencies) { + const status = dep.done ? 'done' : 'missing'; + const fullPath = path.join(changeDir, dep.path); + console.log(``); + console.log(` ${fullPath}`); + console.log(` ${dep.description}`); + console.log(''); + } + console.log(''); + console.log(); + } + + // Output location + console.log(''); + console.log(`Write to: ${path.join(changeDir, outputPath)}`); + console.log(''); + console.log(); + + // Instruction (guidance) + if (instruction) { + console.log(''); + console.log(instruction.trim()); + console.log(''); + console.log(); + } + + // Template + console.log(''); + console.log(); + + // Success criteria placeholder + console.log(''); + console.log(''); + console.log(''); + console.log(); + + // Unlocks + if (unlocks.length > 0) { + console.log(''); + console.log(`Completing this artifact enables: ${unlocks.join(', ')}`); + console.log(''); + console.log(); + } + + // Closing tag + console.log(''); +} + +// ----------------------------------------------------------------------------- +// Apply Instructions Command +// ----------------------------------------------------------------------------- + +/** + * Parses tasks.md content and extracts task items with their completion status. + */ +function parseTasksFile(content: string): TaskItem[] { + const tasks: TaskItem[] = []; + const lines = content.split('\n'); + let taskIndex = 0; + + for (const line of lines) { + // Match checkbox patterns: - [ ] or - [x] or - [X] + const checkboxMatch = line.match(/^[-*]\s*\[([ xX])\]\s*(.+)$/); + if (checkboxMatch) { + taskIndex++; + const done = checkboxMatch[1].toLowerCase() === 'x'; + const description = checkboxMatch[2].trim(); + tasks.push({ + id: `${taskIndex}`, + description, + done, + }); + } + } + + return tasks; +} + +/** + * Checks if an artifact output exists in the change directory. + * Supports glob patterns (e.g., "specs/*.md") by verifying at least one matching file exists. + */ +function artifactOutputExists(changeDir: string, generates: string): boolean { + // Normalize the generates path to use platform-specific separators + const normalizedGenerates = generates.split('/').join(path.sep); + const fullPath = path.join(changeDir, normalizedGenerates); + + // If it's a glob pattern (contains ** or *), check for matching files + if (generates.includes('*')) { + // Extract the directory part before the glob pattern + const parts = normalizedGenerates.split(path.sep); + const dirParts: string[] = []; + let patternPart = ''; + for (const part of parts) { + if (part.includes('*')) { + patternPart = part; + break; + } + dirParts.push(part); + } + const dirPath = path.join(changeDir, ...dirParts); + + // Check if directory exists + if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { + return false; + } + + // Extract expected extension from pattern (e.g., "*.md" -> ".md") + const extMatch = patternPart.match(/\*(\.[a-zA-Z0-9]+)$/); + const expectedExt = extMatch ? extMatch[1] : null; + + // Recursively check for matching files + const hasMatchingFiles = (dir: string): boolean => { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + // For ** patterns, recurse into subdirectories + if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) { + return true; + } + } else if (entry.isFile()) { + // Check if file matches expected extension (or any file if no extension specified) + if (!expectedExt || entry.name.endsWith(expectedExt)) { + return true; + } + } + } + } catch { + return false; + } + return false; + }; + + return hasMatchingFiles(dirPath); + } + + return fs.existsSync(fullPath); +} + +/** + * Generates apply instructions for implementing tasks from a change. + * Schema-aware: reads apply phase configuration from schema to determine + * required artifacts, tracking file, and instruction. + */ +export async function generateApplyInstructions( + projectRoot: string, + changeName: string, + schemaName?: string +): Promise { + // loadChangeContext will auto-detect schema from metadata if not provided + const context = loadChangeContext(projectRoot, changeName, schemaName); + const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName); + + // Get the full schema to access the apply phase configuration + const schema = resolveSchema(context.schemaName); + const applyConfig = schema.apply; + + // Determine required artifacts and tracking file from schema + // Fallback: if no apply block, require all artifacts + const requiredArtifactIds = applyConfig?.requires ?? schema.artifacts.map((a) => a.id); + const tracksFile = applyConfig?.tracks ?? null; + const schemaInstruction = applyConfig?.instruction ?? null; + + // Check which required artifacts are missing + const missingArtifacts: string[] = []; + for (const artifactId of requiredArtifactIds) { + const artifact = schema.artifacts.find((a) => a.id === artifactId); + if (artifact && !artifactOutputExists(changeDir, artifact.generates)) { + missingArtifacts.push(artifactId); + } + } + + // Build context files from all existing artifacts in schema + const contextFiles: Record = {}; + for (const artifact of schema.artifacts) { + if (artifactOutputExists(changeDir, artifact.generates)) { + contextFiles[artifact.id] = path.join(changeDir, artifact.generates); + } + } + + // Parse tasks if tracking file exists + let tasks: TaskItem[] = []; + let tracksFileExists = false; + if (tracksFile) { + const tracksPath = path.join(changeDir, tracksFile); + tracksFileExists = fs.existsSync(tracksPath); + if (tracksFileExists) { + const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8'); + tasks = parseTasksFile(tasksContent); + } + } + + // Calculate progress + const total = tasks.length; + const complete = tasks.filter((t) => t.done).length; + const remaining = total - complete; + + // Determine state and instruction + let state: ApplyInstructions['state']; + let instruction: string; + + if (missingArtifacts.length > 0) { + state = 'blocked'; + instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nUse the openspec-continue-change skill to create the missing artifacts first.`; + } else if (tracksFile && !tracksFileExists) { + // Tracking file configured but doesn't exist yet + const tracksFilename = path.basename(tracksFile); + state = 'blocked'; + instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`; + } else if (tracksFile && tracksFileExists && total === 0) { + // Tracking file exists but contains no tasks + const tracksFilename = path.basename(tracksFile); + state = 'blocked'; + instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`; + } else if (tracksFile && remaining === 0 && total > 0) { + state = 'all_done'; + instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.'; + } else if (!tracksFile) { + // No tracking file (e.g., TDD schema) - ready to apply + state = 'ready'; + instruction = schemaInstruction?.trim() ?? 'All required artifacts complete. Proceed with implementation.'; + } else { + state = 'ready'; + instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.'; + } + + return { + changeName, + changeDir, + schemaName: context.schemaName, + contextFiles, + progress: { total, complete, remaining }, + tasks, + state, + missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined, + instruction, + }; +} + +export async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise { + const spinner = ora('Generating apply instructions...').start(); + + try { + const projectRoot = process.cwd(); + const changeName = await validateChangeExists(options.change, projectRoot); + + // Validate schema if explicitly provided + if (options.schema) { + validateSchemaExists(options.schema, projectRoot); + } + + // generateApplyInstructions uses loadChangeContext which auto-detects schema + const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema); + + spinner.stop(); + + if (options.json) { + console.log(JSON.stringify(instructions, null, 2)); + return; + } + + printApplyInstructionsText(instructions); + } catch (error) { + spinner.stop(); + throw error; + } +} + +export function printApplyInstructionsText(instructions: ApplyInstructions): void { + const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions; + + console.log(`## Apply: ${changeName}`); + console.log(`Schema: ${schemaName}`); + console.log(); + + // Warning for blocked state + if (state === 'blocked' && missingArtifacts) { + console.log('### โš ๏ธ Blocked'); + console.log(); + console.log(`Missing artifacts: ${missingArtifacts.join(', ')}`); + console.log('Use the openspec-continue-change skill to create these first.'); + console.log(); + } + + // Context files (dynamically from schema) + const contextFileEntries = Object.entries(contextFiles); + if (contextFileEntries.length > 0) { + console.log('### Context Files'); + for (const [artifactId, filePath] of contextFileEntries) { + console.log(`- ${artifactId}: ${filePath}`); + } + console.log(); + } + + // Progress (only show if we have tracking) + if (progress.total > 0 || tasks.length > 0) { + console.log('### Progress'); + if (state === 'all_done') { + console.log(`${progress.complete}/${progress.total} complete โœ“`); + } else { + console.log(`${progress.complete}/${progress.total} complete`); + } + console.log(); + } + + // Tasks + if (tasks.length > 0) { + console.log('### Tasks'); + for (const task of tasks) { + const checkbox = task.done ? '[x]' : '[ ]'; + console.log(`- ${checkbox} ${task.description}`); + } + console.log(); + } + + // Instruction + console.log('### Instruction'); + console.log(instruction); +} diff --git a/src/commands/experimental/new-change.ts b/src/commands/experimental/new-change.ts new file mode 100644 index 00000000..1435e1ad --- /dev/null +++ b/src/commands/experimental/new-change.ts @@ -0,0 +1,61 @@ +/** + * New Change Command + * + * Creates a new change directory with optional description and schema. + */ + +import ora from 'ora'; +import path from 'path'; +import { createChange, validateChangeName } from '../../utils/change-utils.js'; +import { validateSchemaExists } from './shared.js'; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface NewChangeOptions { + description?: string; + schema?: string; +} + +// ----------------------------------------------------------------------------- +// Command Implementation +// ----------------------------------------------------------------------------- + +export async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise { + if (!name) { + throw new Error('Missing required argument '); + } + + const validation = validateChangeName(name); + if (!validation.valid) { + throw new Error(validation.error); + } + + const projectRoot = process.cwd(); + + // Validate schema if provided + if (options.schema) { + validateSchemaExists(options.schema, projectRoot); + } + + const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : ''; + const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start(); + + try { + const result = await createChange(projectRoot, name, { schema: options.schema }); + + // If description provided, create README.md with description + if (options.description) { + const { promises: fs } = await import('fs'); + const changeDir = path.join(projectRoot, 'openspec', 'changes', name); + const readmePath = path.join(changeDir, 'README.md'); + await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8'); + } + + spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${result.schema})`); + } catch (error) { + spinner.fail(`Failed to create change '${name}'`); + throw error; + } +} diff --git a/src/commands/experimental/schemas.ts b/src/commands/experimental/schemas.ts new file mode 100644 index 00000000..b9af74a6 --- /dev/null +++ b/src/commands/experimental/schemas.ts @@ -0,0 +1,46 @@ +/** + * Schemas Command + * + * Lists available workflow schemas with descriptions. + */ + +import chalk from 'chalk'; +import { listSchemasWithInfo } from '../../core/artifact-graph/index.js'; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface SchemasOptions { + json?: boolean; +} + +// ----------------------------------------------------------------------------- +// Command Implementation +// ----------------------------------------------------------------------------- + +export async function schemasCommand(options: SchemasOptions): Promise { + const projectRoot = process.cwd(); + const schemas = listSchemasWithInfo(projectRoot); + + if (options.json) { + console.log(JSON.stringify(schemas, null, 2)); + return; + } + + console.log('Available schemas:'); + console.log(); + + for (const schema of schemas) { + let sourceLabel = ''; + if (schema.source === 'project') { + sourceLabel = chalk.cyan(' (project)'); + } else if (schema.source === 'user') { + sourceLabel = chalk.dim(' (user override)'); + } + console.log(` ${chalk.bold(schema.name)}${sourceLabel}`); + console.log(` ${schema.description}`); + console.log(` Artifacts: ${schema.artifacts.join(' โ†’ ')}`); + console.log(); + } +} diff --git a/src/commands/experimental/setup.ts b/src/commands/experimental/setup.ts new file mode 100644 index 00000000..15c58d57 --- /dev/null +++ b/src/commands/experimental/setup.ts @@ -0,0 +1,358 @@ +/** + * Artifact Experimental Setup Command + * + * Generates Agent Skills and slash commands for the experimental artifact workflow. + */ + +import ora from 'ora'; +import chalk from 'chalk'; +import path from 'path'; +import * as fs from 'fs'; +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 { AI_TOOLS } from '../../core/config.js'; +import { + generateCommands, + CommandAdapterRegistry, + type CommandContent, +} from '../../core/command-generation/index.js'; +import { DEFAULT_SCHEMA } from './shared.js'; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface ArtifactExperimentalSetupOptions { + tool?: string; + interactive?: boolean; + selectedTools?: string[]; // For multi-select from interactive prompt +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/** + * Gets the list of tools with skillsDir configured. + */ +export function getToolsWithSkillsDir(): string[] { + return AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value); +} + +// ----------------------------------------------------------------------------- +// Command Implementation +// ----------------------------------------------------------------------------- + +/** + * Generates Agent Skills and slash commands for the experimental artifact workflow. + * Creates /skills/ directory with SKILL.md files following Agent Skills spec. + * Creates slash commands using tool-specific adapters. + */ +export async function artifactExperimentalSetupCommand(options: ArtifactExperimentalSetupOptions): Promise { + const projectRoot = process.cwd(); + + // Validate --tool flag is provided or prompt interactively + if (!options.tool) { + const validTools = getToolsWithSkillsDir(); + 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', + }); + + 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 ')}` + ); + } + } + + // 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 }); + } + + // 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'); + + // 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} +--- + +${template.instructions} +`; + + // 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); + + 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); + } + + 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}`); + } + + // 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`)); + 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(); + } + + if (allCreatedCommandFiles.length > 0) { + console.log(chalk.bold('Slash Commands Created:')); + for (const file of allCreatedCommandFiles) { + 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(); + + // 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(); + } 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(' 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 }); + + 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 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:'); + 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(); + } + // 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(); + } + + console.log(chalk.yellow('๐Ÿ’ก This is an experimental feature.')); + console.log(' Feedback welcome at: https://github.com/Fission-AI/OpenSpec/issues'); + console.log(); +} diff --git a/src/commands/experimental/shared.ts b/src/commands/experimental/shared.ts new file mode 100644 index 00000000..a2c8bdcc --- /dev/null +++ b/src/commands/experimental/shared.ts @@ -0,0 +1,161 @@ +/** + * Shared Types and Utilities for Artifact Workflow Commands + * + * This module contains types, constants, and validation helpers used across + * multiple artifact workflow commands. + */ + +import chalk from 'chalk'; +import path from 'path'; +import * as fs from 'fs'; +import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js'; +import { validateChangeName } from '../../utils/change-utils.js'; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface TaskItem { + id: string; + description: string; + done: boolean; +} + +export interface ApplyInstructions { + changeName: string; + changeDir: string; + schemaName: string; + contextFiles: Record; + progress: { + total: number; + complete: number; + remaining: number; + }; + tasks: TaskItem[]; + state: 'blocked' | 'all_done' | 'ready'; + missingArtifacts?: string[]; + instruction: string; +} + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +export const DEFAULT_SCHEMA = 'spec-driven'; + +// ----------------------------------------------------------------------------- +// Utility Functions +// ----------------------------------------------------------------------------- + +/** + * Checks if color output is disabled via NO_COLOR env or --no-color flag. + */ +export function isColorDisabled(): boolean { + return process.env.NO_COLOR === '1' || process.env.NO_COLOR === 'true'; +} + +/** + * Gets the color function based on status. + */ +export function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: string) => string { + if (isColorDisabled()) { + return (text: string) => text; + } + switch (status) { + case 'done': + return chalk.green; + case 'ready': + return chalk.yellow; + case 'blocked': + return chalk.red; + } +} + +/** + * Gets the status indicator for an artifact. + */ +export function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string { + const color = getStatusColor(status); + switch (status) { + case 'done': + return color('[x]'); + case 'ready': + return color('[ ]'); + case 'blocked': + return color('[-]'); + } +} + +/** + * Validates that a change exists and returns available changes if not. + * Checks directory existence directly to support scaffolded changes (without proposal.md). + */ +export async function validateChangeExists( + changeName: string | undefined, + projectRoot: string +): Promise { + const changesPath = path.join(projectRoot, 'openspec', 'changes'); + + // Get all change directories (not just those with proposal.md) + const getAvailableChanges = async (): Promise => { + try { + const entries = await fs.promises.readdir(changesPath, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.')) + .map((e) => e.name); + } catch { + return []; + } + }; + + if (!changeName) { + const available = await getAvailableChanges(); + if (available.length === 0) { + throw new Error('No changes found. Create one with: openspec new change '); + } + throw new Error( + `Missing required option --change. Available changes:\n ${available.join('\n ')}` + ); + } + + // Validate change name format to prevent path traversal + const nameValidation = validateChangeName(changeName); + if (!nameValidation.valid) { + throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`); + } + + // Check directory existence directly + const changePath = path.join(changesPath, changeName); + const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory(); + + if (!exists) { + const available = await getAvailableChanges(); + if (available.length === 0) { + throw new Error( + `Change '${changeName}' not found. No changes exist. Create one with: openspec new change ` + ); + } + throw new Error( + `Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}` + ); + } + + return changeName; +} + +/** + * Validates that a schema exists and returns available schemas if not. + * + * @param schemaName - The schema name to validate + * @param projectRoot - Optional project root for project-local schema resolution + */ +export function validateSchemaExists(schemaName: string, projectRoot?: string): string { + const schemaDir = getSchemaDir(schemaName, projectRoot); + if (!schemaDir) { + const availableSchemas = listSchemas(projectRoot); + throw new Error( + `Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}` + ); + } + return schemaName; +} diff --git a/src/commands/experimental/status.ts b/src/commands/experimental/status.ts new file mode 100644 index 00000000..59e46c25 --- /dev/null +++ b/src/commands/experimental/status.ts @@ -0,0 +1,90 @@ +/** + * Status Command + * + * Displays artifact completion status for a change. + */ + +import ora from 'ora'; +import chalk from 'chalk'; +import { + loadChangeContext, + formatChangeStatus, + type ChangeStatus, +} from '../../core/artifact-graph/index.js'; +import { + validateChangeExists, + validateSchemaExists, + getStatusIndicator, + getStatusColor, +} from './shared.js'; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface StatusOptions { + change?: string; + schema?: string; + json?: boolean; +} + +// ----------------------------------------------------------------------------- +// Command Implementation +// ----------------------------------------------------------------------------- + +export async function statusCommand(options: StatusOptions): Promise { + const spinner = ora('Loading change status...').start(); + + try { + const projectRoot = process.cwd(); + const changeName = await validateChangeExists(options.change, projectRoot); + + // Validate schema if explicitly provided + if (options.schema) { + validateSchemaExists(options.schema, projectRoot); + } + + // loadChangeContext will auto-detect schema from metadata if not provided + const context = loadChangeContext(projectRoot, changeName, options.schema); + const status = formatChangeStatus(context); + + spinner.stop(); + + if (options.json) { + console.log(JSON.stringify(status, null, 2)); + return; + } + + printStatusText(status); + } catch (error) { + spinner.stop(); + throw error; + } +} + +export function printStatusText(status: ChangeStatus): void { + const doneCount = status.artifacts.filter((a) => a.status === 'done').length; + const total = status.artifacts.length; + + console.log(`Change: ${status.changeName}`); + console.log(`Schema: ${status.schemaName}`); + console.log(`Progress: ${doneCount}/${total} artifacts complete`); + console.log(); + + for (const artifact of status.artifacts) { + const indicator = getStatusIndicator(artifact.status); + const color = getStatusColor(artifact.status); + let line = `${indicator} ${artifact.id}`; + + if (artifact.status === 'blocked' && artifact.missingDeps && artifact.missingDeps.length > 0) { + line += color(` (blocked by: ${artifact.missingDeps.join(', ')})`); + } + + console.log(line); + } + + if (status.isComplete) { + console.log(); + console.log(chalk.green('All artifacts complete!')); + } +} diff --git a/src/commands/experimental/templates.ts b/src/commands/experimental/templates.ts new file mode 100644 index 00000000..b9de4a4f --- /dev/null +++ b/src/commands/experimental/templates.ts @@ -0,0 +1,91 @@ +/** + * Templates Command + * + * Shows resolved template paths for all artifacts in a schema. + */ + +import ora from 'ora'; +import path from 'path'; +import { + resolveSchema, + getSchemaDir, + ArtifactGraph, +} from '../../core/artifact-graph/index.js'; +import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js'; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface TemplatesOptions { + schema?: string; + json?: boolean; +} + +export interface TemplateInfo { + artifactId: string; + templatePath: string; + source: 'project' | 'user' | 'package'; +} + +// ----------------------------------------------------------------------------- +// Command Implementation +// ----------------------------------------------------------------------------- + +export async function templatesCommand(options: TemplatesOptions): Promise { + const spinner = ora('Loading templates...').start(); + + try { + const projectRoot = process.cwd(); + const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot); + const schema = resolveSchema(schemaName, projectRoot); + const graph = ArtifactGraph.fromSchema(schema); + const schemaDir = getSchemaDir(schemaName, projectRoot)!; + + // Determine the source (project, user, or package) + const { + getUserSchemasDir, + getProjectSchemasDir, + } = await import('../../core/artifact-graph/resolver.js'); + const projectSchemasDir = getProjectSchemasDir(projectRoot); + const userSchemasDir = getUserSchemasDir(); + + let source: 'project' | 'user' | 'package'; + if (schemaDir.startsWith(projectSchemasDir)) { + source = 'project'; + } else if (schemaDir.startsWith(userSchemasDir)) { + source = 'user'; + } else { + source = 'package'; + } + + const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({ + artifactId: artifact.id, + templatePath: path.join(schemaDir, 'templates', artifact.template), + source, + })); + + spinner.stop(); + + if (options.json) { + const output: Record = {}; + for (const t of templates) { + output[t.artifactId] = { path: t.templatePath, source: t.source }; + } + console.log(JSON.stringify(output, null, 2)); + return; + } + + console.log(`Schema: ${schemaName}`); + console.log(`Source: ${source}`); + console.log(); + + for (const t of templates) { + console.log(`${t.artifactId}:`); + console.log(` ${t.templatePath}`); + } + } catch (error) { + spinner.stop(); + throw error; + } +} diff --git a/src/core/init.ts b/src/core/init.ts index ebc98c9c..901cbd26 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -1,15 +1,4 @@ import path from 'path'; -import { - createPrompt, - isBackspaceKey, - isDownKey, - isEnterKey, - isSpaceKey, - isUpKey, - useKeypress, - usePagination, - useState, -} from '@inquirer/core'; import chalk from 'chalk'; import ora from 'ora'; import { FileSystemUtils } from '../utils/file-system.js'; @@ -24,350 +13,24 @@ import { OPENSPEC_MARKERS, } from './config.js'; import { PALETTE } from './styles/palette.js'; +import { + LETTER_MAP, + ROOT_STUB_CHOICE_VALUE, + OTHER_TOOLS_HEADING_VALUE, + LIST_SPACER_VALUE, + ToolWizardChoice, + ToolSelectionPrompt, + toolSelectionWizard, + parseToolLabel, +} from './init/wizard.js'; const PROGRESS_SPINNER = { interval: 80, frames: ['โ–‘โ–‘โ–‘', 'โ–’โ–‘โ–‘', 'โ–’โ–’โ–‘', 'โ–’โ–’โ–’', 'โ–“โ–’โ–’', 'โ–“โ–“โ–’', 'โ–“โ–“โ–“', 'โ–’โ–“โ–“', 'โ–‘โ–’โ–“'], }; -const LETTER_MAP: Record = { - O: [' โ–ˆโ–ˆโ–ˆโ–ˆ ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ', ' โ–ˆโ–ˆโ–ˆโ–ˆ '], - P: ['โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ ', 'โ–ˆโ–ˆ ', 'โ–ˆโ–ˆ '], - E: ['โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ', 'โ–ˆโ–ˆ ', 'โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ ', 'โ–ˆโ–ˆ ', 'โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ'], - N: ['โ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ'], - S: [' โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ', 'โ–ˆโ–ˆ ', ' โ–ˆโ–ˆโ–ˆโ–ˆ ', ' โ–ˆโ–ˆ', 'โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ '], - C: [' โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ', 'โ–ˆโ–ˆ ', 'โ–ˆโ–ˆ ', 'โ–ˆโ–ˆ ', ' โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ'], - ' ': [' ', ' ', ' ', ' ', ' '], -}; - -type ToolLabel = { - primary: string; - annotation?: string; -}; - -const sanitizeToolLabel = (raw: string): string => - raw.replace(/โœ…/gu, 'โœ”').trim(); - -const parseToolLabel = (raw: string): ToolLabel => { - const sanitized = sanitizeToolLabel(raw); - const match = sanitized.match(/^(.*?)\s*\((.+)\)$/u); - if (!match) { - return { primary: sanitized }; - } - return { - primary: match[1].trim(), - annotation: match[2].trim(), - }; -}; - -const isSelectableChoice = ( - choice: ToolWizardChoice -): choice is Extract => choice.selectable; - -type ToolWizardChoice = - | { - kind: 'heading' | 'info'; - value: string; - label: ToolLabel; - selectable: false; - } - | { - kind: 'option'; - value: string; - label: ToolLabel; - configured: boolean; - selectable: true; - }; - -type ToolWizardConfig = { - extendMode: boolean; - baseMessage: string; - choices: ToolWizardChoice[]; - initialSelected?: string[]; -}; - -type WizardStep = 'intro' | 'select' | 'review'; - -type ToolSelectionPrompt = (config: ToolWizardConfig) => Promise; - type RootStubStatus = 'created' | 'updated' | 'skipped'; -const ROOT_STUB_CHOICE_VALUE = '__root_stub__'; - -const OTHER_TOOLS_HEADING_VALUE = '__heading-other__'; -const LIST_SPACER_VALUE = '__list-spacer__'; - -const toolSelectionWizard = createPrompt( - (config, done) => { - const totalSteps = 3; - const [step, setStep] = useState('intro'); - const selectableChoices = config.choices.filter(isSelectableChoice); - const initialCursorIndex = config.choices.findIndex((choice) => - choice.selectable - ); - const [cursor, setCursor] = useState( - initialCursorIndex === -1 ? 0 : initialCursorIndex - ); - const [selected, setSelected] = useState(() => { - const initial = new Set( - (config.initialSelected ?? []).filter((value) => - selectableChoices.some((choice) => choice.value === value) - ) - ); - return selectableChoices - .map((choice) => choice.value) - .filter((value) => initial.has(value)); - }); - const [error, setError] = useState(null); - - const selectedSet = new Set(selected); - const pageSize = Math.max(config.choices.length, 1); - - const updateSelected = (next: Set) => { - const ordered = selectableChoices - .map((choice) => choice.value) - .filter((value) => next.has(value)); - setSelected(ordered); - }; - - const page = usePagination({ - items: config.choices, - active: cursor, - pageSize, - loop: false, - renderItem: ({ item, isActive }) => { - if (!item.selectable) { - const prefix = item.kind === 'info' ? ' ' : ''; - const textColor = - item.kind === 'heading' ? PALETTE.lightGray : PALETTE.midGray; - return `${PALETTE.midGray(' ')} ${PALETTE.midGray(' ')} ${textColor( - `${prefix}${item.label.primary}` - )}`; - } - - const isSelected = selectedSet.has(item.value); - const cursorSymbol = isActive - ? PALETTE.white('โ€บ') - : PALETTE.midGray(' '); - const indicator = isSelected - ? PALETTE.white('โ—‰') - : PALETTE.midGray('โ—‹'); - const nameColor = isActive ? PALETTE.white : PALETTE.midGray; - const annotation = item.label.annotation - ? PALETTE.midGray(` (${item.label.annotation})`) - : ''; - const configuredNote = item.configured - ? PALETTE.midGray(' (already configured)') - : ''; - const label = `${nameColor(item.label.primary)}${annotation}${configuredNote}`; - return `${cursorSymbol} ${indicator} ${label}`; - }, - }); - - const moveCursor = (direction: 1 | -1) => { - if (selectableChoices.length === 0) { - return; - } - - let nextIndex = cursor; - while (true) { - nextIndex = nextIndex + direction; - if (nextIndex < 0 || nextIndex >= config.choices.length) { - return; - } - - if (config.choices[nextIndex]?.selectable) { - setCursor(nextIndex); - return; - } - } - }; - - useKeypress((key) => { - if (step === 'intro') { - if (isEnterKey(key)) { - setStep('select'); - } - return; - } - - if (step === 'select') { - if (isUpKey(key)) { - moveCursor(-1); - setError(null); - return; - } - - if (isDownKey(key)) { - moveCursor(1); - setError(null); - return; - } - - if (isSpaceKey(key)) { - const current = config.choices[cursor]; - if (!current || !current.selectable) return; - - const next = new Set(selected); - if (next.has(current.value)) { - next.delete(current.value); - } else { - next.add(current.value); - } - - updateSelected(next); - setError(null); - return; - } - - if (isEnterKey(key)) { - const current = config.choices[cursor]; - if ( - current && - current.selectable && - !selectedSet.has(current.value) - ) { - const next = new Set(selected); - next.add(current.value); - updateSelected(next); - } - setStep('review'); - setError(null); - return; - } - - if (key.name === 'escape') { - const next = new Set(); - updateSelected(next); - setError(null); - } - return; - } - - if (step === 'review') { - if (isEnterKey(key)) { - const finalSelection = config.choices - .map((choice) => choice.value) - .filter( - (value) => - selectedSet.has(value) && value !== ROOT_STUB_CHOICE_VALUE - ); - done(finalSelection); - return; - } - - if (isBackspaceKey(key) || key.name === 'escape') { - setStep('select'); - setError(null); - } - } - }); - - const rootStubChoice = selectableChoices.find( - (choice) => choice.value === ROOT_STUB_CHOICE_VALUE - ); - const rootStubSelected = rootStubChoice - ? selectedSet.has(ROOT_STUB_CHOICE_VALUE) - : false; - const nativeChoices = selectableChoices.filter( - (choice) => choice.value !== ROOT_STUB_CHOICE_VALUE - ); - const selectedNativeChoices = nativeChoices.filter((choice) => - selectedSet.has(choice.value) - ); - - const formatSummaryLabel = ( - choice: Extract - ) => { - const annotation = choice.label.annotation - ? PALETTE.midGray(` (${choice.label.annotation})`) - : ''; - const configuredNote = choice.configured - ? PALETTE.midGray(' (already configured)') - : ''; - return `${PALETTE.white(choice.label.primary)}${annotation}${configuredNote}`; - }; - - const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3; - const lines: string[] = []; - lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`)); - lines.push(''); - - if (step === 'intro') { - const introHeadline = config.extendMode - ? 'Extend your OpenSpec tooling' - : 'Configure your OpenSpec tooling'; - const introBody = config.extendMode - ? 'We detected an existing setup. We will help you refresh or add integrations.' - : "Let's get your AI assistants connected so they understand OpenSpec."; - - lines.push(PALETTE.white(introHeadline)); - lines.push(PALETTE.midGray(introBody)); - lines.push(''); - lines.push(PALETTE.midGray('Press Enter to continue.')); - } else if (step === 'select') { - lines.push(PALETTE.white(config.baseMessage)); - lines.push( - PALETTE.midGray( - 'Use โ†‘/โ†“ to move ยท Space to toggle ยท Enter selects highlighted tool and reviews.' - ) - ); - lines.push(''); - lines.push(page); - lines.push(''); - lines.push(PALETTE.midGray('Selected configuration:')); - if (rootStubSelected && rootStubChoice) { - lines.push( - ` ${PALETTE.white('-')} ${formatSummaryLabel(rootStubChoice)}` - ); - } - if (selectedNativeChoices.length === 0) { - lines.push( - ` ${PALETTE.midGray('- No natively supported providers selected')}` - ); - } else { - selectedNativeChoices.forEach((choice) => { - lines.push( - ` ${PALETTE.white('-')} ${formatSummaryLabel(choice)}` - ); - }); - } - } else { - lines.push(PALETTE.white('Review selections')); - lines.push( - PALETTE.midGray('Press Enter to confirm or Backspace to adjust.') - ); - lines.push(''); - - if (rootStubSelected && rootStubChoice) { - lines.push( - `${PALETTE.white('โ–Œ')} ${formatSummaryLabel(rootStubChoice)}` - ); - } - - if (selectedNativeChoices.length === 0) { - lines.push( - PALETTE.midGray( - 'No natively supported providers selected. Universal instructions will still be applied.' - ) - ); - } else { - selectedNativeChoices.forEach((choice) => { - lines.push( - `${PALETTE.white('โ–Œ')} ${formatSummaryLabel(choice)}` - ); - }); - } - } - - if (error) { - return [lines.join('\n'), chalk.red(error)]; - } - - return lines.join('\n'); - } -); - type InitCommandOptions = { prompt?: ToolSelectionPrompt; tools?: string; @@ -377,6 +40,10 @@ export class InitCommand { private readonly prompt: ToolSelectionPrompt; private readonly toolsArg?: string; + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // CONSTRUCTOR & MAIN ENTRY + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + constructor(options: InitCommandOptions = {}) { this.prompt = options.prompt ?? ((config) => toolSelectionWizard(config)); this.toolsArg = options.tools; @@ -459,6 +126,10 @@ export class InitCommand { ); } + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // VALIDATION & SETUP + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + private async validate( projectPath: string, _openspecPath: string @@ -472,6 +143,86 @@ export class InitCommand { return extendMode; } + private async getExistingToolStates( + projectPath: string, + extendMode: boolean + ): Promise> { + // Fresh initialization - no tools configured yet + if (!extendMode) { + return Object.fromEntries(AI_TOOLS.map(t => [t.value, false])); + } + + // Extend mode - check all tools in parallel for better performance + const entries = await Promise.all( + AI_TOOLS.map(async (t) => [t.value, await this.isToolConfigured(projectPath, t.value)] as const) + ); + return Object.fromEntries(entries); + } + + private async isToolConfigured( + projectPath: string, + toolId: string + ): Promise { + // A tool is only considered "configured by OpenSpec" if its files contain OpenSpec markers. + // For tools with both config files and slash commands, BOTH must have markers. + // For slash commands, at least one file with markers is sufficient (not all required). + + // Helper to check if a file exists and contains OpenSpec markers + const fileHasMarkers = async (absolutePath: string): Promise => { + try { + const content = await FileSystemUtils.readFile(absolutePath); + return content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end); + } catch { + return false; + } + }; + + let hasConfigFile = false; + let hasSlashCommands = false; + + // Check if the tool has a config file with OpenSpec markers + const configFile = ToolRegistry.get(toolId)?.configFileName; + if (configFile) { + const configPath = path.join(projectPath, configFile); + hasConfigFile = (await FileSystemUtils.fileExists(configPath)) && (await fileHasMarkers(configPath)); + } + + // Check if any slash command file exists with OpenSpec markers + const slashConfigurator = SlashCommandRegistry.get(toolId); + if (slashConfigurator) { + for (const target of slashConfigurator.getTargets()) { + const absolute = slashConfigurator.resolveAbsolutePath(projectPath, target.id); + if ((await FileSystemUtils.fileExists(absolute)) && (await fileHasMarkers(absolute))) { + hasSlashCommands = true; + break; // At least one file with markers is sufficient + } + } + } + + // Tool is only configured if BOTH exist with markers + // OR if the tool has no config file requirement (slash commands only) + // OR if the tool has no slash commands requirement (config file only) + const hasConfigFileRequirement = configFile !== undefined; + const hasSlashCommandRequirement = slashConfigurator !== undefined; + + if (hasConfigFileRequirement && hasSlashCommandRequirement) { + // Both are required - both must be present with markers + return hasConfigFile && hasSlashCommands; + } else if (hasConfigFileRequirement) { + // Only config file required + return hasConfigFile; + } else if (hasSlashCommandRequirement) { + // Only slash commands required + return hasSlashCommands; + } + + return false; + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // CONFIGURATION & TOOL SELECTION + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + private async getConfiguration( existingTools: Record, extendMode: boolean @@ -629,81 +380,9 @@ export class InitCommand { }); } - private async getExistingToolStates( - projectPath: string, - extendMode: boolean - ): Promise> { - // Fresh initialization - no tools configured yet - if (!extendMode) { - return Object.fromEntries(AI_TOOLS.map(t => [t.value, false])); - } - - // Extend mode - check all tools in parallel for better performance - const entries = await Promise.all( - AI_TOOLS.map(async (t) => [t.value, await this.isToolConfigured(projectPath, t.value)] as const) - ); - return Object.fromEntries(entries); - } - - private async isToolConfigured( - projectPath: string, - toolId: string - ): Promise { - // A tool is only considered "configured by OpenSpec" if its files contain OpenSpec markers. - // For tools with both config files and slash commands, BOTH must have markers. - // For slash commands, at least one file with markers is sufficient (not all required). - - // Helper to check if a file exists and contains OpenSpec markers - const fileHasMarkers = async (absolutePath: string): Promise => { - try { - const content = await FileSystemUtils.readFile(absolutePath); - return content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end); - } catch { - return false; - } - }; - - let hasConfigFile = false; - let hasSlashCommands = false; - - // Check if the tool has a config file with OpenSpec markers - const configFile = ToolRegistry.get(toolId)?.configFileName; - if (configFile) { - const configPath = path.join(projectPath, configFile); - hasConfigFile = (await FileSystemUtils.fileExists(configPath)) && (await fileHasMarkers(configPath)); - } - - // Check if any slash command file exists with OpenSpec markers - const slashConfigurator = SlashCommandRegistry.get(toolId); - if (slashConfigurator) { - for (const target of slashConfigurator.getTargets()) { - const absolute = slashConfigurator.resolveAbsolutePath(projectPath, target.id); - if ((await FileSystemUtils.fileExists(absolute)) && (await fileHasMarkers(absolute))) { - hasSlashCommands = true; - break; // At least one file with markers is sufficient - } - } - } - - // Tool is only configured if BOTH exist with markers - // OR if the tool has no config file requirement (slash commands only) - // OR if the tool has no slash commands requirement (config file only) - const hasConfigFileRequirement = configFile !== undefined; - const hasSlashCommandRequirement = slashConfigurator !== undefined; - - if (hasConfigFileRequirement && hasSlashCommandRequirement) { - // Both are required - both must be present with markers - return hasConfigFile && hasSlashCommands; - } else if (hasConfigFileRequirement) { - // Only config file required - return hasConfigFile; - } else if (hasSlashCommandRequirement) { - // Only slash commands required - return hasSlashCommands; - } - - return false; - } + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // FILE SYSTEM OPERATIONS + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• private async createDirectoryStructure(openspecPath: string): Promise { const directories = [ @@ -760,6 +439,10 @@ export class InitCommand { } } + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // TOOL CONFIGURATION + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + private async configureAITools( projectPath: string, openspecDir: string, @@ -802,6 +485,10 @@ export class InitCommand { return existed ? 'updated' : 'created'; } + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // UI & OUTPUT + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + private displaySuccessMessage( selectedTools: AIToolOption[], created: AIToolOption[], diff --git a/src/core/init/wizard.ts b/src/core/init/wizard.ts new file mode 100644 index 00000000..cdc2f578 --- /dev/null +++ b/src/core/init/wizard.ts @@ -0,0 +1,373 @@ +import { + createPrompt, + isBackspaceKey, + isDownKey, + isEnterKey, + isSpaceKey, + isUpKey, + useKeypress, + usePagination, + useState, +} from '@inquirer/core'; +import chalk from 'chalk'; +import { PALETTE } from '../styles/palette.js'; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// CONSTANTS +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +export const LETTER_MAP: Record = { + O: [' โ–ˆโ–ˆโ–ˆโ–ˆ ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ', ' โ–ˆโ–ˆโ–ˆโ–ˆ '], + P: ['โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ ', 'โ–ˆโ–ˆ ', 'โ–ˆโ–ˆ '], + E: ['โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ', 'โ–ˆโ–ˆ ', 'โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ ', 'โ–ˆโ–ˆ ', 'โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ'], + N: ['โ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ', 'โ–ˆโ–ˆ โ–ˆโ–ˆ'], + S: [' โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ', 'โ–ˆโ–ˆ ', ' โ–ˆโ–ˆโ–ˆโ–ˆ ', ' โ–ˆโ–ˆ', 'โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ '], + C: [' โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ', 'โ–ˆโ–ˆ ', 'โ–ˆโ–ˆ ', 'โ–ˆโ–ˆ ', ' โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ'], + ' ': [' ', ' ', ' ', ' ', ' '], +}; + +export const ROOT_STUB_CHOICE_VALUE = '__root_stub__'; +export const OTHER_TOOLS_HEADING_VALUE = '__heading-other__'; +export const LIST_SPACER_VALUE = '__list-spacer__'; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// TYPES +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +export type ToolLabel = { + primary: string; + annotation?: string; +}; + +export type ToolWizardChoice = + | { + kind: 'heading' | 'info'; + value: string; + label: ToolLabel; + selectable: false; + } + | { + kind: 'option'; + value: string; + label: ToolLabel; + configured: boolean; + selectable: true; + }; + +export type ToolWizardConfig = { + extendMode: boolean; + baseMessage: string; + choices: ToolWizardChoice[]; + initialSelected?: string[]; +}; + +export type WizardStep = 'intro' | 'select' | 'review'; + +export type ToolSelectionPrompt = (config: ToolWizardConfig) => Promise; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// HELPERS +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +export const sanitizeToolLabel = (raw: string): string => + raw.replace(/โœ…/gu, 'โœ”').trim(); + +export const parseToolLabel = (raw: string): ToolLabel => { + const sanitized = sanitizeToolLabel(raw); + const match = sanitized.match(/^(.*?)\s*\((.+)\)$/u); + if (!match) { + return { primary: sanitized }; + } + return { + primary: match[1].trim(), + annotation: match[2].trim(), + }; +}; + +export const isSelectableChoice = ( + choice: ToolWizardChoice +): choice is Extract => choice.selectable; + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// WIZARD PROMPT +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +const toolSelectionWizardPrompt = createPrompt( + (config, done) => { + const totalSteps = 3; + const [step, setStep] = useState('intro'); + const selectableChoices = config.choices.filter(isSelectableChoice); + const initialCursorIndex = config.choices.findIndex((choice) => + choice.selectable + ); + const [cursor, setCursor] = useState( + initialCursorIndex === -1 ? 0 : initialCursorIndex + ); + const [selected, setSelected] = useState(() => { + const initial = new Set( + (config.initialSelected ?? []).filter((value) => + selectableChoices.some((choice) => choice.value === value) + ) + ); + return selectableChoices + .map((choice) => choice.value) + .filter((value) => initial.has(value)); + }); + const [error, setError] = useState(null); + + const selectedSet = new Set(selected); + const pageSize = Math.max(config.choices.length, 1); + + const updateSelected = (next: Set) => { + const ordered = selectableChoices + .map((choice) => choice.value) + .filter((value) => next.has(value)); + setSelected(ordered); + }; + + const page = usePagination({ + items: config.choices, + active: cursor, + pageSize, + loop: false, + renderItem: ({ item, isActive }) => { + if (!item.selectable) { + const prefix = item.kind === 'info' ? ' ' : ''; + const textColor = + item.kind === 'heading' ? PALETTE.lightGray : PALETTE.midGray; + return `${PALETTE.midGray(' ')} ${PALETTE.midGray(' ')} ${textColor( + `${prefix}${item.label.primary}` + )}`; + } + + const isSelected = selectedSet.has(item.value); + const cursorSymbol = isActive + ? PALETTE.white('โ€บ') + : PALETTE.midGray(' '); + const indicator = isSelected + ? PALETTE.white('โ—‰') + : PALETTE.midGray('โ—‹'); + const nameColor = isActive ? PALETTE.white : PALETTE.midGray; + const annotation = item.label.annotation + ? PALETTE.midGray(` (${item.label.annotation})`) + : ''; + const configuredNote = item.configured + ? PALETTE.midGray(' (already configured)') + : ''; + const label = `${nameColor(item.label.primary)}${annotation}${configuredNote}`; + return `${cursorSymbol} ${indicator} ${label}`; + }, + }); + + const moveCursor = (direction: 1 | -1) => { + if (selectableChoices.length === 0) { + return; + } + + let nextIndex = cursor; + while (true) { + nextIndex = nextIndex + direction; + if (nextIndex < 0 || nextIndex >= config.choices.length) { + return; + } + + if (config.choices[nextIndex]?.selectable) { + setCursor(nextIndex); + return; + } + } + }; + + useKeypress((key) => { + if (step === 'intro') { + if (isEnterKey(key)) { + setStep('select'); + } + return; + } + + if (step === 'select') { + if (isUpKey(key)) { + moveCursor(-1); + setError(null); + return; + } + + if (isDownKey(key)) { + moveCursor(1); + setError(null); + return; + } + + if (isSpaceKey(key)) { + const current = config.choices[cursor]; + if (!current || !current.selectable) return; + + const next = new Set(selected); + if (next.has(current.value)) { + next.delete(current.value); + } else { + next.add(current.value); + } + + updateSelected(next); + setError(null); + return; + } + + if (isEnterKey(key)) { + const current = config.choices[cursor]; + if ( + current && + current.selectable && + !selectedSet.has(current.value) + ) { + const next = new Set(selected); + next.add(current.value); + updateSelected(next); + } + setStep('review'); + setError(null); + return; + } + + if (key.name === 'escape') { + const next = new Set(); + updateSelected(next); + setError(null); + } + return; + } + + if (step === 'review') { + if (isEnterKey(key)) { + const finalSelection = config.choices + .map((choice) => choice.value) + .filter( + (value) => + selectedSet.has(value) && value !== ROOT_STUB_CHOICE_VALUE + ); + done(finalSelection); + return; + } + + if (isBackspaceKey(key) || key.name === 'escape') { + setStep('select'); + setError(null); + } + } + }); + + const rootStubChoice = selectableChoices.find( + (choice) => choice.value === ROOT_STUB_CHOICE_VALUE + ); + const rootStubSelected = rootStubChoice + ? selectedSet.has(ROOT_STUB_CHOICE_VALUE) + : false; + const nativeChoices = selectableChoices.filter( + (choice) => choice.value !== ROOT_STUB_CHOICE_VALUE + ); + const selectedNativeChoices = nativeChoices.filter((choice) => + selectedSet.has(choice.value) + ); + + const formatSummaryLabel = ( + choice: Extract + ) => { + const annotation = choice.label.annotation + ? PALETTE.midGray(` (${choice.label.annotation})`) + : ''; + const configuredNote = choice.configured + ? PALETTE.midGray(' (already configured)') + : ''; + return `${PALETTE.white(choice.label.primary)}${annotation}${configuredNote}`; + }; + + const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3; + const lines: string[] = []; + lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`)); + lines.push(''); + + if (step === 'intro') { + const introHeadline = config.extendMode + ? 'Extend your OpenSpec tooling' + : 'Configure your OpenSpec tooling'; + const introBody = config.extendMode + ? 'We detected an existing setup. We will help you refresh or add integrations.' + : "Let's get your AI assistants connected so they understand OpenSpec."; + + lines.push(PALETTE.white(introHeadline)); + lines.push(PALETTE.midGray(introBody)); + lines.push(''); + lines.push(PALETTE.midGray('Press Enter to continue.')); + } else if (step === 'select') { + lines.push(PALETTE.white(config.baseMessage)); + lines.push( + PALETTE.midGray( + 'Use โ†‘/โ†“ to move ยท Space to toggle ยท Enter selects highlighted tool and reviews.' + ) + ); + lines.push(''); + lines.push(page); + lines.push(''); + lines.push(PALETTE.midGray('Selected configuration:')); + if (rootStubSelected && rootStubChoice) { + lines.push( + ` ${PALETTE.white('-')} ${formatSummaryLabel(rootStubChoice)}` + ); + } + if (selectedNativeChoices.length === 0) { + lines.push( + ` ${PALETTE.midGray('- No natively supported providers selected')}` + ); + } else { + selectedNativeChoices.forEach((choice) => { + lines.push( + ` ${PALETTE.white('-')} ${formatSummaryLabel(choice)}` + ); + }); + } + } else { + lines.push(PALETTE.white('Review selections')); + lines.push( + PALETTE.midGray('Press Enter to confirm or Backspace to adjust.') + ); + lines.push(''); + + if (rootStubSelected && rootStubChoice) { + lines.push( + `${PALETTE.white('โ–Œ')} ${formatSummaryLabel(rootStubChoice)}` + ); + } + + if (selectedNativeChoices.length === 0) { + lines.push( + PALETTE.midGray( + 'No natively supported providers selected. Universal instructions will still be applied.' + ) + ); + } else { + selectedNativeChoices.forEach((choice) => { + lines.push( + `${PALETTE.white('โ–Œ')} ${formatSummaryLabel(choice)}` + ); + }); + } + } + + if (error) { + return [lines.join('\n'), chalk.red(error)]; + } + + return lines.join('\n'); + } +); + +/** + * Run the tool selection wizard prompt. + * This wrapper function provides an explicit return type to avoid TypeScript's + * inability to express the internal inquirer Prompt type in public declarations. + */ +export function toolSelectionWizard(config: ToolWizardConfig): Promise { + return toolSelectionWizardPrompt(config); +} From 5ea086e8c2706b56abcccc1f0167ae6da7d153a0 Mon Sep 17 00:00:00 2001 From: Tabish Bidiwale Date: Thu, 22 Jan 2026 21:05:39 -0800 Subject: [PATCH 2/2] fix(commands): address code review feedback from PR #562 - Fix template source detection using path.relative instead of startsWith to prevent misclassification of paths with shared prefixes - Fix config file log message to show actual file name (config.yaml vs config.yml) - Fix selectedTools option to be honored when provided programmatically --- src/commands/experimental/setup.ts | 18 ++++++++++++------ src/commands/experimental/templates.ts | 11 +++++++++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/commands/experimental/setup.ts b/src/commands/experimental/setup.ts index 77b0dda6..171e947b 100644 --- a/src/commands/experimental/setup.ts +++ b/src/commands/experimental/setup.ts @@ -124,8 +124,9 @@ function getExperimentalToolStates(projectRoot: string): Map { const projectRoot = process.cwd(); - // Validate --tool flag is provided or prompt interactively - if (!options.tool) { + // Validate --tool flag or selectedTools is provided, or prompt interactively + const hasToolsSpecified = options.tool || (options.selectedTools && options.selectedTools.length > 0); + if (!hasToolsSpecified) { const validTools = getToolsWithSkillsDir(); const canPrompt = isInteractive(options); @@ -180,8 +181,10 @@ export async function artifactExperimentalSetupCommand(options: ArtifactExperime } } - // Determine tools to set up - const toolsToSetup = options.selectedTools || [options.tool!]; + // Determine tools to set up - prefer selectedTools if provided + const toolsToSetup = options.selectedTools && options.selectedTools.length > 0 + ? options.selectedTools + : [options.tool!]; // Get tool states before processing to track created vs refreshed const preSetupStates = getExperimentalToolStates(projectRoot); @@ -363,10 +366,13 @@ ${template.instructions} // Config creation (simplified) const configPath = path.join(projectRoot, 'openspec', 'config.yaml'); const configYmlPath = path.join(projectRoot, 'openspec', 'config.yml'); - const configExists = fs.existsSync(configPath) || fs.existsSync(configYmlPath); + const configYamlExists = fs.existsSync(configPath); + const configYmlExists = fs.existsSync(configYmlPath); + const configExists = configYamlExists || configYmlExists; if (configExists) { - console.log(`Config: openspec/config.yaml (exists)`); + const existingConfigName = configYamlExists ? 'config.yaml' : 'config.yml'; + console.log(`Config: openspec/${existingConfigName} (exists)`); } else if (!isInteractive(options)) { console.log(chalk.dim(`Config: skipped (non-interactive mode)`)); } else { diff --git a/src/commands/experimental/templates.ts b/src/commands/experimental/templates.ts index b9de4a4f..0660e998 100644 --- a/src/commands/experimental/templates.ts +++ b/src/commands/experimental/templates.ts @@ -50,10 +50,17 @@ export async function templatesCommand(options: TemplatesOptions): Promise const projectSchemasDir = getProjectSchemasDir(projectRoot); const userSchemasDir = getUserSchemasDir(); + // Determine source by checking if schemaDir is inside each base directory + // Using path.relative is more robust than startsWith for path comparisons + const isInsideDir = (child: string, parent: string): boolean => { + const relative = path.relative(parent, child); + return !relative.startsWith('..') && !path.isAbsolute(relative); + }; + let source: 'project' | 'user' | 'package'; - if (schemaDir.startsWith(projectSchemasDir)) { + if (isInsideDir(schemaDir, projectSchemasDir)) { source = 'project'; - } else if (schemaDir.startsWith(userSchemasDir)) { + } else if (isInsideDir(schemaDir, userSchemasDir)) { source = 'user'; } else { source = 'package';