Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/schema-alias-support/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-20
28 changes: 28 additions & 0 deletions openspec/changes/schema-alias-support/proposal.md
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`
114 changes: 31 additions & 83 deletions src/commands/artifact-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -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();
}
}

Expand Down
220 changes: 30 additions & 190 deletions src/core/config-prompts.ts
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';
Comment on lines 9 to +38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

serializeConfig drops provided context/rules and can emit invalid schema.

This function accepts Partial<ProjectConfig> but only prints comments plus schema. If schema is missing, it writes schema: undefined, and any supplied context/rules are 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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';
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('');
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('');
}
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';
}
🤖 Prompt for AI Agents
In `@src/core/config-prompts.ts` around lines 9 - 38, serializeConfig currently
prints only comments and blindly emits "schema: undefined" when config.schema is
missing and drops config.context/config.rules; update serializeConfig to
validate that config.schema is present (throw or return an error) and when
config.context or config.rules exist, render them into the output instead of
discarding them: render context as a YAML block scalar (e.g., "context: |"
followed by each line of config.context indented) and render rules as a proper
YAML mapping/sequence (preserve nested artifact keys and arrays) while keeping
the comment sections when those fields are absent; ensure the final output still
ends with a newline.

}
Loading
Loading