mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-14 05:19:53 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac33b9b94 | ||
|
|
3a89274f5b | ||
|
|
852481208f | ||
|
|
0318e60ff7 | ||
|
|
16088aa50a |
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user