-
Notifications
You must be signed in to change notification settings - Fork 1.3k
refactor(setup): simplify config creation and fix test hanging #537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| schema: spec-driven | ||
| created: 2026-01-20 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
|
||
| <!-- No existing spec-level behavior is changing - this is purely additive --> | ||
|
|
||
| ## 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` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string[]>; | ||
| } | ||
|
|
||
| /** | ||
| * 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<ConfigPromptResult> { | ||
| // 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<string, string[]> | 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<string[]> { | ||
| // 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<ProjectConfig>): string { | ||
| // Build clean config object (only include defined fields) | ||
| const cleanConfig: Record<string, unknown> = { | ||
| 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'; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
serializeConfig drops provided context/rules and can emit invalid schema.
This function accepts
Partial<ProjectConfig>but only prints comments plusschema. Ifschemais missing, it writesschema: undefined, and any suppliedcontext/rulesare silently discarded. Either render actual values when present or narrow the API to a schema-only template and update the docs.🛠️ One possible fix (validate schema, render context/rules when provided)
export function serializeConfig(config: Partial<ProjectConfig>): string { + if (!config.schema) { + throw new Error('serializeConfig requires config.schema'); + } 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(''); + if (config.context) { + lines.push('context: |'); + for (const line of config.context.split('\n')) { + lines.push(` ${line}`); + } + lines.push(''); + } else { + // 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'); + if (config.rules && Object.keys(config.rules).length > 0) { + lines.push('rules:'); + for (const [artifactId, rules] of Object.entries(config.rules)) { + lines.push(` ${artifactId}:`); + for (const rule of rules) { + lines.push(` - ${rule}`); + } + } + } else { + // 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'; }📝 Committable suggestion
🤖 Prompt for AI Agents