Skip to content

Commit cc9d540

Browse files
tommy-caTabishB
andauthored
feat: add non-interactive options to openspec init (#122)
* 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 * feat(init): add single --tools flag for non-interactive init * test(init): verify --tools help lists available ids * Revert manual spec.md edits The canonical spec shouldn't be edited directly when a change delta already captures the update. Archiving that delta will sync the spec. --------- Co-authored-by: Tabish Bidiwale <[email protected]> Co-authored-by: Tabish Bidiwale <[email protected]>
1 parent 108bcd6 commit cc9d540

File tree

7 files changed

+345
-6
lines changed

7 files changed

+345
-6
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
## Why
2+
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.
3+
4+
## What Changes
5+
- Replace the multiple flag design with a single `--tools` option that accepts `all`, `none`, or a comma-separated list of tool IDs
6+
- Update InitCommand to bypass interactive prompts when `--tools` is supplied and apply single-flag validation rules
7+
- Document the non-interactive behavior via the CLI init spec delta (scenarios for `all`, `none`, list parsing, and invalid entries)
8+
- Generate CLI help text dynamically from `AI_TOOLS` so supported tools stay in sync
9+
10+
## Impact
11+
- Affected specs: `specs/cli-init/spec.md`
12+
- Affected code: `src/cli/index.ts`, `src/core/init.ts`
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Delta for CLI Init Specification
2+
3+
## ADDED Requirements
4+
### Requirement: Non-Interactive Mode
5+
The command SHALL support non-interactive operation through command-line options for automation and CI/CD use cases.
6+
7+
#### Scenario: Select all tools non-interactively
8+
- **WHEN** run with `--tools all`
9+
- **THEN** automatically select every available AI tool without prompting
10+
- **AND** proceed with initialization using the selected tools
11+
12+
#### Scenario: Select specific tools non-interactively
13+
- **WHEN** run with `--tools claude,cursor`
14+
- **THEN** parse the comma-separated tool IDs and validate against available tools
15+
- **AND** proceed with initialization using only the specified valid tools
16+
17+
#### Scenario: Skip tool configuration non-interactively
18+
- **WHEN** run with `--tools none`
19+
- **THEN** skip AI tool configuration entirely
20+
- **AND** only create the OpenSpec directory structure and template files
21+
22+
#### Scenario: Invalid tool specification
23+
- **WHEN** run with `--tools` containing any IDs not present in the AI tool registry
24+
- **THEN** exit with code 1 and display available values (`all`, `none`, or the supported tool IDs)
25+
26+
#### Scenario: Help text lists available tool IDs
27+
- **WHEN** displaying CLI help for `openspec init`
28+
- **THEN** show the `--tools` option description with the valid values derived from the AI tool registry
29+
30+
## MODIFIED Requirements
31+
### Requirement: Interactive Mode
32+
The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.
33+
34+
#### Scenario: Displaying interactive menu
35+
- **WHEN** run in fresh or extend mode without non-interactive options
36+
- **THEN** present a looping select menu that lets users toggle tools with Enter and finish via a "Done" option
37+
- **AND** label already configured tools with "(already configured)" while keeping disabled options marked "coming soon"
38+
- **AND** change the prompt copy in extend mode to "Which AI tools would you like to add or refresh?"
39+
- **AND** display inline instructions clarifying that Enter toggles a tool and selecting "Done" confirms the list
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## 1. CLI Option Registration
2+
- [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.
3+
- [x] 1.2 Populate the `--tools` help text dynamically from the `AI_TOOLS` registry.
4+
5+
## 2. InitCommand Modifications
6+
- [x] 2.1 Accept the single tools option in the InitCommand constructor and plumb it through existing flows.
7+
- [x] 2.2 Update tool selection logic to shortcut prompts for `all`, `none`, and explicit lists.
8+
- [x] 2.3 Fail fast with exit code 1 and a helpful message when the parsed list contains unsupported tool IDs.
9+
10+
## 3. Specification Updates
11+
- [x] 3.1 Capture the non-interactive scenarios (`all`, `none`, list, invalid) in the change delta without modifying `specs/cli-init/spec.md` directly.
12+
- [x] 3.2 Document that CLI help reflects the available tool IDs managed by `AI_TOOLS`.
13+
14+
## 4. Testing
15+
- [x] 4.1 Add unit coverage for parsing `--tools` values, including invalid entries.
16+
- [x] 4.2 Add integration coverage ensuring non-interactive runs generate the expected files and exit codes.
17+
- [x] 4.3 Verify the interactive flow remains unchanged when `--tools` is omitted.

src/cli/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import ora from 'ora';
44
import path from 'path';
55
import { promises as fs } from 'fs';
66
import { InitCommand } from '../core/init.js';
7+
import { AI_TOOLS } from '../core/config.js';
78
import { UpdateCommand } from '../core/update.js';
89
import { ListCommand } from '../core/list.js';
910
import { ArchiveCommand } from '../core/archive.js';
@@ -33,10 +34,14 @@ program.hook('preAction', (thisCommand) => {
3334
}
3435
});
3536

37+
const availableToolIds = AI_TOOLS.filter((tool) => tool.available).map((tool) => tool.value);
38+
const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}`;
39+
3640
program
3741
.command('init [path]')
3842
.description('Initialize OpenSpec in your project')
39-
.action(async (targetPath = '.') => {
43+
.option('--tools <tools>', toolsOptionDescription)
44+
.action(async (targetPath = '.', options?: { tools?: string }) => {
4045
try {
4146
// Validate that the path is a valid directory
4247
const resolvedPath = path.resolve(targetPath);
@@ -57,7 +62,9 @@ program
5762
}
5863
}
5964

60-
const initCommand = new InitCommand();
65+
const initCommand = new InitCommand({
66+
tools: options?.tools,
67+
});
6168
await initCommand.execute(targetPath);
6269
} catch (error) {
6370
console.log(); // Empty line for spacing

src/core/init.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -369,13 +369,16 @@ const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
369369

370370
type InitCommandOptions = {
371371
prompt?: ToolSelectionPrompt;
372+
tools?: string;
372373
};
373374

374375
export class InitCommand {
375376
private readonly prompt: ToolSelectionPrompt;
377+
private readonly toolsArg?: string;
376378

377379
constructor(options: InitCommandOptions = {}) {
378380
this.prompt = options.prompt ?? ((config) => toolSelectionWizard(config));
381+
this.toolsArg = options.tools;
379382
}
380383

381384
async execute(targetPath: string): Promise<void> {
@@ -470,13 +473,86 @@ export class InitCommand {
470473
existingTools: Record<string, boolean>,
471474
extendMode: boolean
472475
): Promise<OpenSpecConfig> {
473-
const selectedTools = await this.promptForAITools(
474-
existingTools,
475-
extendMode
476-
);
476+
const selectedTools = await this.getSelectedTools(existingTools, extendMode);
477477
return { aiTools: selectedTools };
478478
}
479479

480+
private async getSelectedTools(
481+
existingTools: Record<string, boolean>,
482+
extendMode: boolean
483+
): Promise<string[]> {
484+
const nonInteractiveSelection = this.resolveToolsArg();
485+
if (nonInteractiveSelection !== null) {
486+
return nonInteractiveSelection;
487+
}
488+
489+
// Fall back to interactive mode
490+
return this.promptForAITools(existingTools, extendMode);
491+
}
492+
493+
private resolveToolsArg(): string[] | null {
494+
if (typeof this.toolsArg === 'undefined') {
495+
return null;
496+
}
497+
498+
const raw = this.toolsArg.trim();
499+
if (raw.length === 0) {
500+
throw new Error(
501+
'The --tools option requires a value. Use "all", "none", or a comma-separated list of tool IDs.'
502+
);
503+
}
504+
505+
const availableTools = AI_TOOLS.filter((tool) => tool.available);
506+
const availableValues = availableTools.map((tool) => tool.value);
507+
const availableSet = new Set(availableValues);
508+
const availableList = ['all', 'none', ...availableValues].join(', ');
509+
510+
const lowerRaw = raw.toLowerCase();
511+
if (lowerRaw === 'all') {
512+
return availableValues;
513+
}
514+
515+
if (lowerRaw === 'none') {
516+
return [];
517+
}
518+
519+
const tokens = raw
520+
.split(',')
521+
.map((token) => token.trim())
522+
.filter((token) => token.length > 0);
523+
524+
if (tokens.length === 0) {
525+
throw new Error(
526+
'The --tools option requires at least one tool ID when not using "all" or "none".'
527+
);
528+
}
529+
530+
const normalizedTokens = tokens.map((token) => token.toLowerCase());
531+
532+
if (normalizedTokens.some((token) => token === 'all' || token === 'none')) {
533+
throw new Error('Cannot combine reserved values "all" or "none" with specific tool IDs.');
534+
}
535+
536+
const invalidTokens = tokens.filter(
537+
(_token, index) => !availableSet.has(normalizedTokens[index])
538+
);
539+
540+
if (invalidTokens.length > 0) {
541+
throw new Error(
542+
`Invalid tool(s): ${invalidTokens.join(', ')}. Available values: ${availableList}`
543+
);
544+
}
545+
546+
const deduped: string[] = [];
547+
for (const token of normalizedTokens) {
548+
if (!deduped.includes(token)) {
549+
deduped.push(token);
550+
}
551+
}
552+
553+
return deduped;
554+
}
555+
480556
private async promptForAITools(
481557
existingTools: Record<string, boolean>,
482558
extendMode: boolean

test/cli-e2e/basic.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import { promises as fs } from 'fs';
33
import path from 'path';
44
import { tmpdir } from 'os';
55
import { runCLI, cliProjectRoot } from '../helpers/run-cli.js';
6+
import { AI_TOOLS } from '../../src/core/config.js';
7+
8+
async function fileExists(filePath: string): Promise<boolean> {
9+
try {
10+
await fs.access(filePath);
11+
return true;
12+
} catch {
13+
return false;
14+
}
15+
}
616

717
const tempRoots: string[] = [];
818

@@ -26,6 +36,20 @@ describe('openspec CLI e2e basics', () => {
2636
expect(result.exitCode).toBe(0);
2737
expect(result.stdout).toContain('Usage: openspec');
2838
expect(result.stderr).toBe('');
39+
40+
});
41+
42+
it('shows dynamic tool ids in init help', async () => {
43+
const result = await runCLI(['init', '--help']);
44+
expect(result.exitCode).toBe(0);
45+
46+
const expectedTools = AI_TOOLS.filter((tool) => tool.available)
47+
.map((tool) => tool.value)
48+
.join(', ');
49+
const normalizedOutput = result.stdout.replace(/\s+/g, ' ').trim();
50+
expect(normalizedOutput).toContain(
51+
`Use "all", "none", or a comma-separated list of: ${expectedTools}`
52+
);
2953
});
3054

3155
it('reports the package version', async () => {
@@ -53,4 +77,76 @@ describe('openspec CLI e2e basics', () => {
5377
expect(result.exitCode).toBe(1);
5478
expect(result.stderr).toContain("Unknown item 'does-not-exist'");
5579
});
80+
81+
describe('init command non-interactive options', () => {
82+
it('initializes with --tools all option', async () => {
83+
const projectDir = await prepareFixture('tmp-init');
84+
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
85+
await fs.mkdir(emptyProjectDir, { recursive: true });
86+
87+
const result = await runCLI(['init', '--tools', 'all'], { cwd: emptyProjectDir });
88+
expect(result.exitCode).toBe(0);
89+
expect(result.stdout).toContain('Tool summary:');
90+
91+
// Check that tool configurations were created
92+
const claudePath = path.join(emptyProjectDir, 'CLAUDE.md');
93+
const cursorProposal = path.join(emptyProjectDir, '.cursor/commands/openspec-proposal.md');
94+
expect(await fileExists(claudePath)).toBe(true);
95+
expect(await fileExists(cursorProposal)).toBe(true);
96+
});
97+
98+
it('initializes with --tools list option', async () => {
99+
const projectDir = await prepareFixture('tmp-init');
100+
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
101+
await fs.mkdir(emptyProjectDir, { recursive: true });
102+
103+
const result = await runCLI(['init', '--tools', 'claude'], { cwd: emptyProjectDir });
104+
expect(result.exitCode).toBe(0);
105+
expect(result.stdout).toContain('Tool summary:');
106+
107+
const claudePath = path.join(emptyProjectDir, 'CLAUDE.md');
108+
const cursorProposal = path.join(emptyProjectDir, '.cursor/commands/openspec-proposal.md');
109+
expect(await fileExists(claudePath)).toBe(true);
110+
expect(await fileExists(cursorProposal)).toBe(false); // Not selected
111+
});
112+
113+
it('initializes with --tools none option', async () => {
114+
const projectDir = await prepareFixture('tmp-init');
115+
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
116+
await fs.mkdir(emptyProjectDir, { recursive: true });
117+
118+
const result = await runCLI(['init', '--tools', 'none'], { cwd: emptyProjectDir });
119+
expect(result.exitCode).toBe(0);
120+
expect(result.stdout).toContain('Tool summary:');
121+
122+
const claudePath = path.join(emptyProjectDir, 'CLAUDE.md');
123+
const cursorProposal = path.join(emptyProjectDir, '.cursor/commands/openspec-proposal.md');
124+
const rootAgentsPath = path.join(emptyProjectDir, 'AGENTS.md');
125+
126+
expect(await fileExists(rootAgentsPath)).toBe(true);
127+
expect(await fileExists(claudePath)).toBe(false);
128+
expect(await fileExists(cursorProposal)).toBe(false);
129+
});
130+
131+
it('returns error for invalid tool names', async () => {
132+
const projectDir = await prepareFixture('tmp-init');
133+
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
134+
await fs.mkdir(emptyProjectDir, { recursive: true });
135+
136+
const result = await runCLI(['init', '--tools', 'invalid-tool'], { cwd: emptyProjectDir });
137+
expect(result.exitCode).toBe(1);
138+
expect(result.stderr).toContain('Invalid tool(s): invalid-tool');
139+
expect(result.stderr).toContain('Available values:');
140+
});
141+
142+
it('returns error when combining reserved keywords with explicit ids', async () => {
143+
const projectDir = await prepareFixture('tmp-init');
144+
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
145+
await fs.mkdir(emptyProjectDir, { recursive: true });
146+
147+
const result = await runCLI(['init', '--tools', 'all,claude'], { cwd: emptyProjectDir });
148+
expect(result.exitCode).toBe(1);
149+
expect(result.stderr).toContain('Cannot combine reserved values "all" or "none" with specific tool IDs');
150+
});
151+
});
56152
});

0 commit comments

Comments
 (0)