Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/add-init-language-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': minor
---

Add `openspec init --language <language>` to configure the language used for artifacts in new projects.
5 changes: 5 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ Default behavior uses global config defaults: profile `core`, delivery `both`, w
openspec init [path] [options]
```

Use `--language <language>` to add a language instruction to a new project's
`openspec/config.yaml`. For an existing project, edit the config's `context`
field so OpenSpec never overwrites project-specific guidance.

**Arguments:**

| Argument | Required | Description |
Expand All @@ -99,6 +103,7 @@ openspec init [path] [options]
| Option | Description |
|--------|-------------|
| `--tools <list>` | Configure AI tools non-interactively. Use `all`, `none`, or comma-separated list |
| `--language <language>` | Write artifacts in this language when creating a new config |
| `--force` | Auto-cleanup legacy files without prompting |
| `--profile <profile>` | Override global profile for this init run (`core` or `custom`) |
| `--no-animation` | Show a static welcome screen instead of the animated one |
Expand Down
17 changes: 17 additions & 0 deletions docs/multi-language.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ Configure OpenSpec to generate artifacts in languages other than English.

## Quick Setup

For a new project, set the language during initialization:

```bash
openspec init --language "Portuguese (pt-BR)"
```

This writes the language instruction to `openspec/config.yaml`. If the project
already has a config, edit its `context` field directly so existing project
guidance is preserved.

You can also configure the same behavior manually:

Add a language instruction to your `openspec/config.yaml`:

```yaml
Expand All @@ -12,13 +24,18 @@ schema: spec-driven
context: |
Language: Portuguese (pt-BR)
All artifacts must be written in Brazilian Portuguese.
Keep OpenSpec structural headings and SHALL/MUST keywords in English.

# Your other project context below...
Tech stack: TypeScript, React, Node.js
```

That's it. All generated artifacts will now be in Portuguese.

OpenSpec's document structure and normative `SHALL`/`MUST` keywords remain in
English because validation relies on them. The surrounding requirement and
scenario prose can use your selected language.

## Language Examples

### Portuguese (Brazil)
Expand Down
29 changes: 29 additions & 0 deletions openspec/specs/cli-init/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,35 @@ The command SHALL create an OpenSpec config file with schema settings.
- **THEN** preserve the existing config file
- **AND** display "(exists)" indicator in output

### Requirement: Artifact Language Configuration

The command SHALL let users configure the artifact language during initialization without changing existing project guidance.

#### Scenario: Configuring language for a new project

- **WHEN** the user runs `openspec init --language <language>` and no OpenSpec config exists
- **THEN** create `openspec/config.yaml` with context instructing agents to write artifacts in the selected language
- **AND** keep OpenSpec structural headings and `SHALL`/`MUST` requirement keywords in English
- **AND** make the language context available to artifact instructions

#### Scenario: Protecting existing project context

- **WHEN** the user runs `openspec init --language <language>` and an OpenSpec config already exists without the same generated language guidance
- **THEN** fail before changing project files
- **AND** direct the user to edit the existing config context

#### Scenario: Rejecting an unsafe language value

- **WHEN** the `--language` value is empty, multiline, contains control characters, or would exceed the project context size limit
- **THEN** fail before creating OpenSpec files
- **AND** explain why the value is invalid

#### Scenario: Language config cannot be written

- **WHEN** the user runs `openspec init --language <language>` and the new config cannot be written
- **THEN** fail instead of reporting successful initialization
- **AND** avoid creating unrelated tool files when writability can be determined in advance

### Requirement: Experimental Command Alias

The command SHALL maintain backward compatibility with the experimental command.
Expand Down
4 changes: 3 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,13 @@ program
.command('init [path]')
.description('Initialize OpenSpec in your project')
.option('--tools <tools>', toolsOptionDescription)
.option('--language <language>', 'Write new OpenSpec artifacts in this language')
.option('--force', 'Auto-cleanup legacy files without prompting')
.option('--profile <profile>', 'Override global config profile (core or custom)')
.option('--no-animation', 'Show a static welcome screen instead of the animated one')
.option('--copilot-cloud', 'Set up GitHub Copilot cloud coding-agent files without prompting')
.option('--no-copilot-cloud', 'Skip GitHub Copilot cloud coding-agent files without prompting')
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean; copilotCloud?: boolean }) => {
.action(async (targetPath = '.', options?: { tools?: string; language?: string; force?: boolean; profile?: string; animation?: boolean; copilotCloud?: boolean }) => {
try {
// Validate that the path is a valid directory
const resolvedPath = path.resolve(targetPath);
Expand All @@ -209,6 +210,7 @@ program
const { InitCommand } = await import('../core/init.js');
const initCommand = new InitCommand({
tools: options?.tools,
language: options?.language,
force: options?.force,
profile: options?.profile,
animation: options?.animation,
Expand Down
5 changes: 5 additions & 0 deletions src/core/completions/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
description: 'Configure AI tools non-interactively (e.g., "all", "none", or comma-separated tool IDs)',
takesValue: true,
},
{
name: 'language',
description: 'Write new OpenSpec artifacts in this language',
takesValue: true,
},
{
name: 'force',
description: 'Auto-cleanup legacy files without prompting',
Expand Down
28 changes: 18 additions & 10 deletions src/core/config-prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,24 @@ export function serializeConfig(config: Partial<ProjectConfig>): string {
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 !== undefined) {
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)');
Expand Down
91 changes: 88 additions & 3 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import ora from 'ora';
import * as fs from 'fs';
import { createRequire } from 'module';
import { FileSystemUtils } from '../utils/file-system.js';
import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
import {
classifyOpenSpecDir,
MAX_CONTEXT_SIZE,
readProjectConfig,
storePointerProblem,
} from './project-config.js';
import { findRepoPlanningRootSync } from './planning-home.js';
import { getSkillReferenceTransformer, getTransformerForTool, usesNaturalLanguageSkillReferences } from '../utils/command-references.js';
import {
Expand Down Expand Up @@ -83,6 +88,14 @@ const { version: OPENSPEC_VERSION } = require('../../package.json');

const DEFAULT_SCHEMA = 'spec-driven';

function formatLanguageContext(language: string): string {
return [
`Language: ${language}`,
`All artifacts must be written in ${language}.`,
'Keep OpenSpec structural headings and SHALL/MUST keywords in English.',
].join('\n');
}

const PROGRESS_SPINNER = {
interval: 80,
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
Expand All @@ -109,6 +122,7 @@ const WORKFLOW_TO_SKILL_DIR: Record<string, string> = {

type InitCommandOptions = {
tools?: string;
language?: string;
force?: boolean;
interactive?: boolean;
profile?: string;
Expand Down Expand Up @@ -147,6 +161,7 @@ type DeferredLegacyCleanup = {

export class InitCommand {
private readonly toolsArg?: string;
private readonly language?: string;
private readonly force: boolean;
private readonly interactiveOption?: boolean;
private readonly profileOverride?: string;
Expand All @@ -155,6 +170,7 @@ export class InitCommand {

constructor(options: InitCommandOptions = {}) {
this.toolsArg = options.tools;
this.language = this.normalizeLanguage(options.language);
this.force = options.force ?? false;
this.interactiveOption = options.interactive;
this.profileOverride = options.profile;
Expand Down Expand Up @@ -197,6 +213,8 @@ export class InitCommand {
}
}

await this.assertLanguageCanBeApplied(projectPath, openspecPath);

// Check for legacy artifacts and handle cleanup
const deferredLegacyCleanup = await this.handleLegacyCleanup(projectPath, extendMode);

Expand Down Expand Up @@ -985,6 +1003,66 @@ export class InitCommand {
// CONFIG FILE
// ═══════════════════════════════════════════════════════════

private normalizeLanguage(language: string | undefined): string | undefined {
if (language === undefined) return undefined;

const normalized = language.trim();
if (!normalized) {
throw new Error('The --language option requires a non-empty value.');
}
if (/\p{Cc}|\p{Bidi_Control}|[\u200B\u2028\u2029\uFEFF]/u.test(normalized)) {
throw new Error(
'The --language option must be a single line without control or invisible formatting characters.'
);
}
const serializedContext = `${formatLanguageContext(normalized)}\n`;
if (Buffer.byteLength(serializedContext, 'utf8') > MAX_CONTEXT_SIZE) {
throw new Error(
`The --language option is too long for OpenSpec's ${MAX_CONTEXT_SIZE / 1024}KB project context limit.`
);
}
return normalized;
}

private languageContext(): string | undefined {
if (!this.language) return undefined;
return formatLanguageContext(this.language);
}

private async assertLanguageCanBeApplied(
projectPath: string,
openspecPath: string
): Promise<void> {
const languageContext = this.languageContext();
if (!languageContext) return;

const configPath = path.join(openspecPath, 'config.yaml');
const hasConfig = fs.existsSync(configPath) ||
fs.existsSync(path.join(openspecPath, 'config.yml'));
if (!hasConfig) {
try {
FileSystemUtils.assertProjectArtifactPath(projectPath, configPath);
} catch (error) {
const reason = error instanceof Error ? `: ${error.message}` : '';
throw new Error(`Cannot create openspec/config.yaml for --language${reason}`);
}
if (!(await FileSystemUtils.canWriteFile(configPath))) {
throw new Error(
'Cannot create openspec/config.yaml for --language: the destination is not writable.'
);
}
return;
}

const existingContext = readProjectConfig(projectPath)?.context;
if (existingContext?.includes(languageContext)) return;

throw new Error(
'--language does not overwrite an existing OpenSpec config. ' +
'Add the language instruction to its context field instead.'
);
}

private async createConfig(openspecPath: string, extendMode: boolean): Promise<'created' | 'exists' | 'skipped'> {
const configPath = path.join(openspecPath, 'config.yaml');
const configYmlPath = path.join(openspecPath, 'config.yml');
Expand All @@ -997,11 +1075,18 @@ export class InitCommand {


try {
const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA });
const yamlContent = serializeConfig({
schema: DEFAULT_SCHEMA,
context: this.languageContext(),
});
FileSystemUtils.assertProjectArtifactPath(path.dirname(openspecPath), configPath);
await FileSystemUtils.writeFile(configPath, yamlContent);
return 'created';
} catch {
} catch (error) {
if (this.language) {
const reason = error instanceof Error ? `: ${error.message}` : '';
throw new Error(`Failed to create openspec/config.yaml for --language${reason}`);
}
return 'skipped';
}
}
Expand Down
32 changes: 32 additions & 0 deletions test/cli-e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe('openspec CLI e2e basics', () => {
expect(normalizedOutput).toContain(
`Use "all", "none", or a comma-separated list of: ${expectedTools}`
);
expect(normalizedOutput).toContain('--language <language>');
});

it('reports the package version', async () => {
Expand Down Expand Up @@ -127,6 +128,37 @@ describe('openspec CLI e2e basics', () => {
});

describe('init command non-interactive options', () => {
it('initializes artifact language non-interactively', async () => {
const projectDir = await prepareFixture('tmp-init');
const emptyProjectDir = path.join(projectDir, '..', 'language-project');
await fs.mkdir(emptyProjectDir, { recursive: true });

const result = await runCLI(
['init', '--tools', 'none', '--language', 'French', '--no-animation'],
{ cwd: emptyProjectDir },
);

expect(result.exitCode).toBe(0);
const config = await fs.readFile(
path.join(emptyProjectDir, 'openspec', 'config.yaml'),
'utf-8',
);
expect(config).toContain('Language: French');
expect(config).toContain('All artifacts must be written in French.');
expect(config).toContain('Keep OpenSpec structural headings and SHALL/MUST keywords in English.');

const created = await runCLI(['new', 'change', 'language-check'], {
cwd: emptyProjectDir,
});
expect(created.exitCode).toBe(0);
const instructions = await runCLI(
['instructions', 'proposal', '--change', 'language-check', '--json'],
{ cwd: emptyProjectDir },
);
expect(instructions.exitCode).toBe(0);
expect(JSON.parse(instructions.stdout).context).toContain('Language: French');
});

it('initializes with --tools all option', async () => {
const projectDir = await prepareFixture('tmp-init');
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
Expand Down
9 changes: 9 additions & 0 deletions test/commands/declared-store-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ describe('declared store fallback (3.2)', () => {
expect(snapshot(path.join(tempDir, 'data'))).toEqual(dataBefore);
}

const refusedWithLanguage = await runCLI(
['init', '.', '--tools', 'none', '--language', 'French'],
{ cwd: pointerRepo, env }
);
expect(refusedWithLanguage.exitCode).toBe(1);
expect(refusedWithLanguage.stderr).toContain("externalized to store 'team-context'");
expect(refusedWithLanguage.stderr).toContain('Remove the store: line');
expect(snapshot(pointerRepo)).toEqual(before);

// Conversion: remove the line, rerun, get a normal local root.
fs.writeFileSync(path.join(pointerRepo, 'openspec', 'config.yaml'), 'schema: spec-driven\n');
const converted = await runCLI(['init', '.', '--tools', 'none'], {
Expand Down
Loading
Loading