Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 8ac33b9b94 Merge branch 'main' into feature/add-non-interactive-init-options 2025-10-13 19:49:25 +11:00
Tabish Bidiwale 3a89274f5b Merge branch 'main' into feature/add-non-interactive-init-options 2025-10-13 19:45:23 +11:00
Tommy K 852481208f test(init): verify --tools help lists available ids 2025-10-10 03:21:29 +02:00
Tommy K 0318e60ff7 feat(init): add single --tools flag for non-interactive init 2025-10-10 03:02:02 +02:00
Tommy K 16088aa50a feat: add non-interactive options to openspec init
- Add --tools, --all-tools, and --skip-tools CLI options
- Enable automated initialization for CI/CD pipelines
- Maintain backward compatibility with interactive mode
- Add comprehensive validation and error handling
- Update cli-init spec with non-interactive requirements
- Add unit and integration tests for new functionality

Closes change proposal: add-non-interactive-init-options
2025-10-10 03:02:02 +02:00
8 changed files with 370 additions and 7 deletions
@@ -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`
@@ -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
@@ -0,0 +1,17 @@
## 1. CLI Option Registration
- [x] 1.1 Replace the multiple flag design with a single `--tools <value>` 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.
+25 -1
View File
@@ -86,12 +86,36 @@ This project uses OpenSpec to manage AI assistant workflows.
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
- **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
### Requirement: Non-Interactive Mode
The command SHALL support non-interactive operation through command-line options for automation and CI/CD use cases.
#### Scenario: All tools selection
- **WHEN** run with `--all-tools` option
- **THEN** automatically select all available AI tools without prompting
- **AND** proceed with initialization using the selected tools
#### Scenario: Specific tools selection
- **WHEN** run with `--tools <comma-separated-list>` option
- **THEN** parse the comma-separated tool IDs and validate against available tools
- **AND** proceed with initialization using only the specified valid tools
- **AND** display a warning for any invalid tool IDs
#### Scenario: No tools selection
- **WHEN** run with `--skip-tools` option
- **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 invalid tool IDs
- **THEN** exit with code 1 and display available tool options
- **AND** show error message listing valid tool IDs
### Requirement: Safety Checks
The command SHALL perform safety checks to prevent overwriting existing structures and ensure proper permissions.
+9 -2
View File
@@ -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 <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
+80 -4
View File
@@ -369,13 +369,16 @@ const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
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<void> {
@@ -470,13 +473,86 @@ export class InitCommand {
existingTools: Record<string, boolean>,
extendMode: boolean
): Promise<OpenSpecConfig> {
const selectedTools = await this.promptForAITools(
existingTools,
extendMode
);
const selectedTools = await this.getSelectedTools(existingTools, extendMode);
return { aiTools: selectedTools };
}
private async getSelectedTools(
existingTools: Record<string, boolean>,
extendMode: boolean
): Promise<string[]> {
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<string, boolean>,
extendMode: boolean
+96
View File
@@ -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<boolean> {
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');
});
});
});
+92
View File
@@ -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