diff --git a/openspec/changes/add-non-interactive-init-options/proposal.md b/openspec/changes/add-non-interactive-init-options/proposal.md new file mode 100644 index 00000000..7ed7da92 --- /dev/null +++ b/openspec/changes/add-non-interactive-init-options/proposal.md @@ -0,0 +1,12 @@ +## Why +The current `openspec init` command requires interactive prompts, preventing automation in CI/CD pipelines and scripted setups. Adding non-interactive options will enable programmatic initialization for automated workflows while maintaining the existing interactive experience as the default. + +## What Changes +- Replace the multiple flag design with a single `--tools` option that accepts `all`, `none`, or a comma-separated list of tool IDs +- Update InitCommand to bypass interactive prompts when `--tools` is supplied and apply single-flag validation rules +- Document the non-interactive behavior via the CLI init spec delta (scenarios for `all`, `none`, list parsing, and invalid entries) +- Generate CLI help text dynamically from `AI_TOOLS` so supported tools stay in sync + +## Impact +- Affected specs: `specs/cli-init/spec.md` +- Affected code: `src/cli/index.ts`, `src/core/init.ts` diff --git a/openspec/changes/add-non-interactive-init-options/specs/cli-init/spec.md b/openspec/changes/add-non-interactive-init-options/specs/cli-init/spec.md new file mode 100644 index 00000000..7e381983 --- /dev/null +++ b/openspec/changes/add-non-interactive-init-options/specs/cli-init/spec.md @@ -0,0 +1,39 @@ +# Delta for CLI Init Specification + +## ADDED Requirements +### Requirement: Non-Interactive Mode +The command SHALL support non-interactive operation through command-line options for automation and CI/CD use cases. + +#### Scenario: Select all tools non-interactively +- **WHEN** run with `--tools all` +- **THEN** automatically select every available AI tool without prompting +- **AND** proceed with initialization using the selected tools + +#### Scenario: Select specific tools non-interactively +- **WHEN** run with `--tools claude,cursor` +- **THEN** parse the comma-separated tool IDs and validate against available tools +- **AND** proceed with initialization using only the specified valid tools + +#### Scenario: Skip tool configuration non-interactively +- **WHEN** run with `--tools none` +- **THEN** skip AI tool configuration entirely +- **AND** only create the OpenSpec directory structure and template files + +#### Scenario: Invalid tool specification +- **WHEN** run with `--tools` containing any IDs not present in the AI tool registry +- **THEN** exit with code 1 and display available values (`all`, `none`, or the supported tool IDs) + +#### Scenario: Help text lists available tool IDs +- **WHEN** displaying CLI help for `openspec init` +- **THEN** show the `--tools` option description with the valid values derived from the AI tool registry + +## MODIFIED Requirements +### Requirement: Interactive Mode +The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions. + +#### Scenario: Displaying interactive menu +- **WHEN** run in fresh or extend mode without non-interactive options +- **THEN** present a looping select menu that lets users toggle tools with Enter and finish via a "Done" option +- **AND** label already configured tools with "(already configured)" while keeping disabled options marked "coming soon" +- **AND** change the prompt copy in extend mode to "Which AI tools would you like to add or refresh?" +- **AND** display inline instructions clarifying that Enter toggles a tool and selecting "Done" confirms the list diff --git a/openspec/changes/add-non-interactive-init-options/tasks.md b/openspec/changes/add-non-interactive-init-options/tasks.md new file mode 100644 index 00000000..8867fd40 --- /dev/null +++ b/openspec/changes/add-non-interactive-init-options/tasks.md @@ -0,0 +1,17 @@ +## 1. CLI Option Registration +- [x] 1.1 Replace the multiple flag design with a single `--tools ` option supporting `all|none|a,b,c` and keep strict argument validation. +- [x] 1.2 Populate the `--tools` help text dynamically from the `AI_TOOLS` registry. + +## 2. InitCommand Modifications +- [x] 2.1 Accept the single tools option in the InitCommand constructor and plumb it through existing flows. +- [x] 2.2 Update tool selection logic to shortcut prompts for `all`, `none`, and explicit lists. +- [x] 2.3 Fail fast with exit code 1 and a helpful message when the parsed list contains unsupported tool IDs. + +## 3. Specification Updates +- [x] 3.1 Capture the non-interactive scenarios (`all`, `none`, list, invalid) in the change delta without modifying `specs/cli-init/spec.md` directly. +- [x] 3.2 Document that CLI help reflects the available tool IDs managed by `AI_TOOLS`. + +## 4. Testing +- [x] 4.1 Add unit coverage for parsing `--tools` values, including invalid entries. +- [x] 4.2 Add integration coverage ensuring non-interactive runs generate the expected files and exit codes. +- [x] 4.3 Verify the interactive flow remains unchanged when `--tools` is omitted. diff --git a/src/cli/index.ts b/src/cli/index.ts index eff75db2..4a4c5311 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,6 +4,7 @@ import ora from 'ora'; import path from 'path'; import { promises as fs } from 'fs'; import { InitCommand } from '../core/init.js'; +import { AI_TOOLS } from '../core/config.js'; import { UpdateCommand } from '../core/update.js'; import { ListCommand } from '../core/list.js'; import { ArchiveCommand } from '../core/archive.js'; @@ -33,10 +34,14 @@ program.hook('preAction', (thisCommand) => { } }); +const availableToolIds = AI_TOOLS.filter((tool) => tool.available).map((tool) => tool.value); +const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}`; + program .command('init [path]') .description('Initialize OpenSpec in your project') - .action(async (targetPath = '.') => { + .option('--tools ', toolsOptionDescription) + .action(async (targetPath = '.', options?: { tools?: string }) => { try { // Validate that the path is a valid directory const resolvedPath = path.resolve(targetPath); @@ -57,7 +62,9 @@ program } } - const initCommand = new InitCommand(); + const initCommand = new InitCommand({ + tools: options?.tools, + }); await initCommand.execute(targetPath); } catch (error) { console.log(); // Empty line for spacing diff --git a/src/core/init.ts b/src/core/init.ts index c8dc9029..1c4f14fb 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -369,13 +369,16 @@ const toolSelectionWizard = createPrompt( type InitCommandOptions = { prompt?: ToolSelectionPrompt; + tools?: string; }; export class InitCommand { private readonly prompt: ToolSelectionPrompt; + private readonly toolsArg?: string; constructor(options: InitCommandOptions = {}) { this.prompt = options.prompt ?? ((config) => toolSelectionWizard(config)); + this.toolsArg = options.tools; } async execute(targetPath: string): Promise { @@ -470,13 +473,86 @@ export class InitCommand { existingTools: Record, extendMode: boolean ): Promise { - const selectedTools = await this.promptForAITools( - existingTools, - extendMode - ); + const selectedTools = await this.getSelectedTools(existingTools, extendMode); return { aiTools: selectedTools }; } + private async getSelectedTools( + existingTools: Record, + extendMode: boolean + ): Promise { + const nonInteractiveSelection = this.resolveToolsArg(); + if (nonInteractiveSelection !== null) { + return nonInteractiveSelection; + } + + // Fall back to interactive mode + return this.promptForAITools(existingTools, extendMode); + } + + private resolveToolsArg(): string[] | null { + if (typeof this.toolsArg === 'undefined') { + return null; + } + + const raw = this.toolsArg.trim(); + if (raw.length === 0) { + throw new Error( + 'The --tools option requires a value. Use "all", "none", or a comma-separated list of tool IDs.' + ); + } + + const availableTools = AI_TOOLS.filter((tool) => tool.available); + const availableValues = availableTools.map((tool) => tool.value); + const availableSet = new Set(availableValues); + const availableList = ['all', 'none', ...availableValues].join(', '); + + const lowerRaw = raw.toLowerCase(); + if (lowerRaw === 'all') { + return availableValues; + } + + if (lowerRaw === 'none') { + return []; + } + + const tokens = raw + .split(',') + .map((token) => token.trim()) + .filter((token) => token.length > 0); + + if (tokens.length === 0) { + throw new Error( + 'The --tools option requires at least one tool ID when not using "all" or "none".' + ); + } + + const normalizedTokens = tokens.map((token) => token.toLowerCase()); + + if (normalizedTokens.some((token) => token === 'all' || token === 'none')) { + throw new Error('Cannot combine reserved values "all" or "none" with specific tool IDs.'); + } + + const invalidTokens = tokens.filter( + (_token, index) => !availableSet.has(normalizedTokens[index]) + ); + + if (invalidTokens.length > 0) { + throw new Error( + `Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}` + ); + } + + const deduped: string[] = []; + for (const token of normalizedTokens) { + if (!deduped.includes(token)) { + deduped.push(token); + } + } + + return deduped; + } + private async promptForAITools( existingTools: Record, extendMode: boolean diff --git a/test/cli-e2e/basic.test.ts b/test/cli-e2e/basic.test.ts index 892ea584..f4451fb0 100644 --- a/test/cli-e2e/basic.test.ts +++ b/test/cli-e2e/basic.test.ts @@ -3,6 +3,16 @@ import { promises as fs } from 'fs'; import path from 'path'; import { tmpdir } from 'os'; import { runCLI, cliProjectRoot } from '../helpers/run-cli.js'; +import { AI_TOOLS } from '../../src/core/config.js'; + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} const tempRoots: string[] = []; @@ -26,6 +36,20 @@ describe('openspec CLI e2e basics', () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Usage: openspec'); expect(result.stderr).toBe(''); + + }); + + it('shows dynamic tool ids in init help', async () => { + const result = await runCLI(['init', '--help']); + expect(result.exitCode).toBe(0); + + const expectedTools = AI_TOOLS.filter((tool) => tool.available) + .map((tool) => tool.value) + .join(', '); + const normalizedOutput = result.stdout.replace(/\s+/g, ' ').trim(); + expect(normalizedOutput).toContain( + `Use "all", "none", or a comma-separated list of: ${expectedTools}` + ); }); it('reports the package version', async () => { @@ -53,4 +77,76 @@ describe('openspec CLI e2e basics', () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain("Unknown item 'does-not-exist'"); }); + + describe('init command non-interactive options', () => { + it('initializes with --tools all option', async () => { + const projectDir = await prepareFixture('tmp-init'); + const emptyProjectDir = path.join(projectDir, '..', 'empty-project'); + await fs.mkdir(emptyProjectDir, { recursive: true }); + + const result = await runCLI(['init', '--tools', 'all'], { cwd: emptyProjectDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Tool summary:'); + + // Check that tool configurations were created + const claudePath = path.join(emptyProjectDir, 'CLAUDE.md'); + const cursorProposal = path.join(emptyProjectDir, '.cursor/commands/openspec-proposal.md'); + expect(await fileExists(claudePath)).toBe(true); + expect(await fileExists(cursorProposal)).toBe(true); + }); + + it('initializes with --tools list option', async () => { + const projectDir = await prepareFixture('tmp-init'); + const emptyProjectDir = path.join(projectDir, '..', 'empty-project'); + await fs.mkdir(emptyProjectDir, { recursive: true }); + + const result = await runCLI(['init', '--tools', 'claude'], { cwd: emptyProjectDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Tool summary:'); + + const claudePath = path.join(emptyProjectDir, 'CLAUDE.md'); + const cursorProposal = path.join(emptyProjectDir, '.cursor/commands/openspec-proposal.md'); + expect(await fileExists(claudePath)).toBe(true); + expect(await fileExists(cursorProposal)).toBe(false); // Not selected + }); + + it('initializes with --tools none option', async () => { + const projectDir = await prepareFixture('tmp-init'); + const emptyProjectDir = path.join(projectDir, '..', 'empty-project'); + await fs.mkdir(emptyProjectDir, { recursive: true }); + + const result = await runCLI(['init', '--tools', 'none'], { cwd: emptyProjectDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Tool summary:'); + + const claudePath = path.join(emptyProjectDir, 'CLAUDE.md'); + const cursorProposal = path.join(emptyProjectDir, '.cursor/commands/openspec-proposal.md'); + const rootAgentsPath = path.join(emptyProjectDir, 'AGENTS.md'); + + expect(await fileExists(rootAgentsPath)).toBe(true); + expect(await fileExists(claudePath)).toBe(false); + expect(await fileExists(cursorProposal)).toBe(false); + }); + + it('returns error for invalid tool names', async () => { + const projectDir = await prepareFixture('tmp-init'); + const emptyProjectDir = path.join(projectDir, '..', 'empty-project'); + await fs.mkdir(emptyProjectDir, { recursive: true }); + + const result = await runCLI(['init', '--tools', 'invalid-tool'], { cwd: emptyProjectDir }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid tool(s): invalid-tool'); + expect(result.stderr).toContain('Available values:'); + }); + + it('returns error when combining reserved keywords with explicit ids', async () => { + const projectDir = await prepareFixture('tmp-init'); + const emptyProjectDir = path.join(projectDir, '..', 'empty-project'); + await fs.mkdir(emptyProjectDir, { recursive: true }); + + const result = await runCLI(['init', '--tools', 'all,claude'], { cwd: emptyProjectDir }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Cannot combine reserved values "all" or "none" with specific tool IDs'); + }); + }); }); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 7fe0d954..8ea6e76b 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -620,6 +620,98 @@ describe('InitCommand', () => { }); }); + describe('non-interactive mode', () => { + it('should select all available tools with --tools all option', async () => { + const nonInteractiveCommand = new InitCommand({ tools: 'all' }); + + await nonInteractiveCommand.execute(testDir); + + // Should create configurations for all available tools + const claudePath = path.join(testDir, 'CLAUDE.md'); + const cursorProposal = path.join( + testDir, + '.cursor/commands/openspec-proposal.md' + ); + const windsurfProposal = path.join( + testDir, + '.windsurf/workflows/openspec-proposal.md' + ); + + expect(await fileExists(claudePath)).toBe(true); + expect(await fileExists(cursorProposal)).toBe(true); + expect(await fileExists(windsurfProposal)).toBe(true); + }); + + it('should select specific tools with --tools option', async () => { + const nonInteractiveCommand = new InitCommand({ tools: 'claude,cursor' }); + + await nonInteractiveCommand.execute(testDir); + + const claudePath = path.join(testDir, 'CLAUDE.md'); + const cursorProposal = path.join( + testDir, + '.cursor/commands/openspec-proposal.md' + ); + const windsurfProposal = path.join( + testDir, + '.windsurf/workflows/openspec-proposal.md' + ); + + expect(await fileExists(claudePath)).toBe(true); + expect(await fileExists(cursorProposal)).toBe(true); + expect(await fileExists(windsurfProposal)).toBe(false); // Not selected + }); + + it('should skip tool configuration with --tools none option', async () => { + const nonInteractiveCommand = new InitCommand({ tools: 'none' }); + + await nonInteractiveCommand.execute(testDir); + + const claudePath = path.join(testDir, 'CLAUDE.md'); + const cursorProposal = path.join( + testDir, + '.cursor/commands/openspec-proposal.md' + ); + + // Should still create AGENTS.md but no tool-specific files + const rootAgentsPath = path.join(testDir, 'AGENTS.md'); + expect(await fileExists(rootAgentsPath)).toBe(true); + expect(await fileExists(claudePath)).toBe(false); + expect(await fileExists(cursorProposal)).toBe(false); + }); + + it('should throw error for invalid tool names', async () => { + const nonInteractiveCommand = new InitCommand({ tools: 'invalid-tool' }); + + await expect(nonInteractiveCommand.execute(testDir)).rejects.toThrow( + /Invalid tool\(s\): invalid-tool\. Available values: / + ); + }); + + it('should handle comma-separated tool names with spaces', async () => { + const nonInteractiveCommand = new InitCommand({ tools: 'claude, cursor' }); + + await nonInteractiveCommand.execute(testDir); + + const claudePath = path.join(testDir, 'CLAUDE.md'); + const cursorProposal = path.join( + testDir, + '.cursor/commands/openspec-proposal.md' + ); + + expect(await fileExists(claudePath)).toBe(true); + expect(await fileExists(cursorProposal)).toBe(true); + }); + + it('should reject combining reserved keywords with explicit tool ids', async () => { + const nonInteractiveCommand = new InitCommand({ tools: 'all,claude' }); + + await expect(nonInteractiveCommand.execute(testDir)).rejects.toThrow( + /Cannot combine reserved values "all" or "none" with specific tool IDs/ + ); + }); + }); + describe('error handling', () => { it('should provide helpful error for insufficient permissions', async () => { // This is tricky to test cross-platform, but we can test the error message