diff --git a/openspec/changes/schema-alias-support/.openspec.yaml b/openspec/changes/schema-alias-support/.openspec.yaml new file mode 100644 index 00000000..749f7518 --- /dev/null +++ b/openspec/changes/schema-alias-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-20 diff --git a/openspec/changes/schema-alias-support/proposal.md b/openspec/changes/schema-alias-support/proposal.md new file mode 100644 index 00000000..0dd23598 --- /dev/null +++ b/openspec/changes/schema-alias-support/proposal.md @@ -0,0 +1,28 @@ +## Why + +We want to rename `spec-driven` to `openspec-default` to better reflect that it's the standard/default workflow. However, renaming directly would break existing projects that have `schema: spec-driven` in their `openspec/config.yaml`. Adding alias support allows both names to work interchangeably, enabling a smooth transition with no breaking changes. + +## What Changes + +- Add schema alias resolution in the schema resolver +- `openspec-default` and `spec-driven` will both resolve to the same schema +- The physical directory remains `schemas/spec-driven/` (or could be renamed to `schemas/openspec-default/` with `spec-driven` as the alias) +- All CLI commands and config files accept either name +- No changes required to existing user configs + +## Capabilities + +### New Capabilities + +- `schema-aliases`: Support for schema name aliases so multiple names can resolve to the same schema directory + +### Modified Capabilities + + + +## Impact + +- `src/core/artifact-graph/resolver.ts` - Add alias resolution logic +- `schemas/` directory - Potentially rename `spec-driven` to `openspec-default` +- Documentation - Update to prefer `openspec-default` while noting `spec-driven` still works +- Default schema constants - Update `DEFAULT_SCHEMA` to `openspec-default` diff --git a/src/commands/artifact-workflow.ts b/src/commands/artifact-workflow.ts index b6b9c8e5..a58c2c00 100644 --- a/src/commands/artifact-workflow.ts +++ b/src/commands/artifact-workflow.ts @@ -30,7 +30,7 @@ import { import { createChange, validateChangeName } from '../utils/change-utils.js'; import { getExploreSkillTemplate, getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getBulkArchiveChangeSkillTemplate, getVerifyChangeSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate, getOpsxBulkArchiveCommandTemplate, getOpsxVerifyCommandTemplate } from '../core/templates/skill-templates.js'; import { FileSystemUtils } from '../utils/file-system.js'; -import { promptForConfig, serializeConfig, isExitPromptError } from '../core/config-prompts.js'; +import { serializeConfig } from '../core/config-prompts.js'; import { readProjectConfig } from '../core/project-config.js'; // ----------------------------------------------------------------------------- @@ -945,89 +945,37 @@ ${template.content} console.log(chalk.dim(' schema: spec-driven')); console.log(); } else { - // Prompt for config creation + // Create config with default schema + const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA }); + try { - const configResult = await promptForConfig(projectRoot); - - if (configResult.createConfig && configResult.schema) { - // Build config object - const config = { - schema: configResult.schema, - context: configResult.context, - rules: configResult.rules, - }; - - // Serialize to YAML - const yamlContent = serializeConfig(config); - - // Write config file - try { - await FileSystemUtils.writeFile(configPath, yamlContent); - - console.log(); - console.log(chalk.green('✓ Created openspec/config.yaml')); - console.log(); - console.log('━'.repeat(70)); - console.log(); - console.log(chalk.bold('📖 Config created at: openspec/config.yaml')); - - // Display summary - const contextLines = config.context ? config.context.split('\n').length : 0; - const rulesCount = config.rules ? Object.keys(config.rules).length : 0; - - console.log(` â€ĸ Default schema: ${chalk.cyan(config.schema)}`); - if (contextLines > 0) { - console.log(` â€ĸ Project context: ${chalk.cyan(`Added (${contextLines} lines)`)}`); - } - if (rulesCount > 0) { - console.log(` â€ĸ Rules: ${chalk.cyan(`${rulesCount} artifact${rulesCount > 1 ? 's' : ''} configured`)}`); - } - console.log(); - - // Usage examples - console.log(chalk.bold('Usage:')); - console.log(' â€ĸ New changes automatically use this schema'); - console.log(' â€ĸ Context injected into all artifact instructions'); - console.log(' â€ĸ Rules applied to matching artifacts'); - console.log(); - - // Git commit suggestion - console.log(chalk.bold('To share with team:')); - console.log(chalk.dim(' git add openspec/config.yaml .claude/')); - console.log(chalk.dim(' git commit -m "Setup OpenSpec experimental workflow with project config"')); - 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(); - } - } else { - // User chose not to create config - console.log(); - console.log(chalk.blue('â„šī¸ Skipped config creation.')); - console.log(' You can create openspec/config.yaml manually later.'); - console.log(); - } - } catch (promptError) { - if (isExitPromptError(promptError)) { - // User cancelled (Ctrl+C) - console.log(); - console.log(chalk.blue('â„šī¸ Config creation cancelled')); - console.log(' Skills and commands already created'); - console.log(' Run setup again to create config later'); - console.log(); - } else { - // Unexpected error - throw promptError; - } + await FileSystemUtils.writeFile(configPath, yamlContent); + + console.log(); + console.log(chalk.green('✓ Created openspec/config.yaml')); + console.log(); + console.log(` Default schema: ${chalk.cyan(DEFAULT_SCHEMA)}`); + console.log(); + console.log(chalk.dim(' Edit the file to add project context and per-artifact rules.')); + console.log(); + + // Git commit suggestion + console.log(chalk.bold('To share with team:')); + console.log(chalk.dim(' git add openspec/config.yaml .claude/')); + 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(); } } diff --git a/src/core/config-prompts.ts b/src/core/config-prompts.ts index b015ee5a..d3bb029e 100644 --- a/src/core/config-prompts.ts +++ b/src/core/config-prompts.ts @@ -1,199 +1,39 @@ -import { stringify as stringifyYaml } from 'yaml'; -import { listSchemasWithInfo, resolveSchema } from './artifact-graph/resolver.js'; import type { ProjectConfig } from './project-config.js'; /** - * Check if an error is an ExitPromptError (user cancelled with Ctrl+C). - * Used instead of instanceof check since @inquirer modules use dynamic imports. - */ -export function isExitPromptError(error: unknown): boolean { - return ( - error !== null && - typeof error === 'object' && - 'name' in error && - (error as { name: string }).name === 'ExitPromptError' - ); -} - -/** - * Result of interactive config creation prompts. - */ -export interface ConfigPromptResult { - /** Whether to create config file */ - createConfig: boolean; - /** Selected schema name */ - schema?: string; - /** Project context (optional) */ - context?: string; - /** Per-artifact rules (optional) */ - rules?: Record; -} - -/** - * Prompt user to create project config interactively. - * Used by experimental setup command. - * - * @param projectRoot - Optional project root for project-local schema resolution - * @returns Config prompt result - * @throws ExitPromptError if user cancels (Ctrl+C) - */ -export async function promptForConfig( - projectRoot?: string -): Promise { - // Dynamic imports to prevent pre-commit hook hangs (see #367) - const { confirm, select, editor, checkbox } = await import('@inquirer/prompts'); - - // Ask if user wants to create config - const shouldCreate = await confirm({ - message: 'Create openspec/config.yaml?', - default: true, - }); - - if (!shouldCreate) { - return { createConfig: false }; - } - - // Get available schemas - const schemas = listSchemasWithInfo(projectRoot); - - if (schemas.length === 0) { - throw new Error('No schemas found. Cannot create config.'); - } - - // Prompt for schema selection - const selectedSchema = await select({ - message: 'Default schema for new changes?', - choices: schemas.map((s) => ({ - name: `${s.name} (${s.artifacts.join(' → ')})`, - value: s.name, - description: s.description || undefined, - })), - }); - - // Prompt for project context - console.log('\nAdd project context? (optional)'); - console.log('Context is shown to AI when creating artifacts.'); - console.log('Examples: tech stack, conventions, style guides, domain knowledge\n'); - - const contextInput = await editor({ - message: 'Press Enter to skip, or edit context:', - default: '', - waitForUseInput: false, - }); - - const context = contextInput.trim() || undefined; - - // Prompt for per-artifact rules - const addRules = await confirm({ - message: 'Add per-artifact rules? (optional)', - default: false, - }); - - let rules: Record | undefined; - - if (addRules) { - // Load the selected schema to get artifact list - const schema = resolveSchema(selectedSchema, projectRoot); - const artifactIds = schema.artifacts.map((a) => a.id); - - // Let user select which artifacts to add rules for - const selectedArtifacts = await checkbox({ - message: 'Which artifacts should have custom rules?', - choices: artifactIds.map((id) => ({ - name: id, - value: id, - })), - }); - - if (selectedArtifacts.length > 0) { - rules = {}; - - // For each selected artifact, collect rules line by line - for (const artifactId of selectedArtifacts) { - const artifactRules = await promptForArtifactRules(artifactId); - if (artifactRules.length > 0) { - rules[artifactId] = artifactRules; - } - } - - // If no rules were actually added, set to undefined - if (Object.keys(rules).length === 0) { - rules = undefined; - } - } - } - - return { - createConfig: true, - schema: selectedSchema, - context, - rules, - }; -} - -/** - * Prompt for rules for a specific artifact. - * Collects rules one per line until user enters empty line. - * - * @param artifactId - The artifact ID to collect rules for - * @returns Array of rules - */ -async function promptForArtifactRules(artifactId: string): Promise { - // Dynamic import to prevent pre-commit hook hangs (see #367) - const { input } = await import('@inquirer/prompts'); - - const rules: string[] = []; - - console.log(`\nRules for ${artifactId} artifact:`); - console.log('Enter rules one per line, press Enter on empty line to finish:\n'); - - while (true) { - const rule = await input({ - message: '│', - validate: () => { - // Empty string is valid (signals end of input) - return true; - }, - }); - - const trimmed = rule.trim(); - - // Empty line signals end of input - if (!trimmed) { - break; - } - - rules.push(trimmed); - } - - return rules; -} - -/** - * Serialize config to YAML string with proper multi-line formatting. + * Serialize config to YAML string with helpful comments. * * @param config - Partial config object (schema required, context/rules optional) * @returns YAML string ready to write to file */ export function serializeConfig(config: Partial): string { - // Build clean config object (only include defined fields) - const cleanConfig: Record = { - schema: config.schema, - }; - - if (config.context) { - cleanConfig.context = config.context; - } - - if (config.rules && Object.keys(config.rules).length > 0) { - cleanConfig.rules = config.rules; - } - - // Serialize to YAML with proper formatting - return stringifyYaml(cleanConfig, { - indent: 2, - lineWidth: 0, // Don't wrap long lines - defaultStringType: 'PLAIN', - defaultKeyType: 'PLAIN', - }); + const lines: string[] = []; + + // Schema (required) + lines.push(`schema: ${config.schema}`); + lines.push(''); + + // Context section with comments + lines.push('# Project context (optional)'); + lines.push('# This is shown to AI when creating artifacts.'); + lines.push('# Add your tech stack, conventions, style guides, domain knowledge, etc.'); + lines.push('# Example:'); + lines.push('# context: |'); + lines.push('# Tech stack: TypeScript, React, Node.js'); + lines.push('# We use conventional commits'); + lines.push('# Domain: e-commerce platform'); + lines.push(''); + + // Rules section with comments + lines.push('# Per-artifact rules (optional)'); + lines.push('# Add custom rules for specific artifacts.'); + lines.push('# Example:'); + lines.push('# rules:'); + lines.push('# proposal:'); + lines.push('# - Keep proposals under 500 words'); + lines.push('# - Always include a "Non-goals" section'); + lines.push('# tasks:'); + lines.push('# - Break tasks into chunks of max 2 hours'); + + return lines.join('\n') + '\n'; } diff --git a/vitest.setup.ts b/vitest.setup.ts index 3ffc2c60..1eea108b 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -7,6 +7,9 @@ export async function setup() { // Global teardown to ensure clean exit export async function teardown() { - // Clear any remaining timers - // This helps prevent hanging handles from keeping the process alive + // Force exit after a short grace period if the process hasn't exited cleanly. + // This handles cases where child processes or open handles keep the worker alive. + setTimeout(() => { + process.exit(0); + }, 1000).unref(); }