Skip to content

Commit 20b2fee

Browse files
committed
feat(init): support multi-select extend flow
1 parent e7fff31 commit 20b2fee

File tree

6 files changed

+235
-74
lines changed

6 files changed

+235
-74
lines changed

openspec/changes/add-multi-agent-init/specs/cli-init/spec.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,20 @@ The command SHALL perform safety checks to prevent overwriting existing structur
44

55
#### Scenario: Detecting existing initialization
66
- **WHEN** the `openspec/` directory already exists
7-
- **THEN** inform the user that OpenSpec is already initialized and skip recreating the base structure
7+
- **THEN** inform the user that OpenSpec is already initialized, skip recreating the base structure, and enter an extend mode
88
- **AND** continue to the AI tool selection step so additional tools can be configured
99
- **AND** display the existing-initialization error message only when the user declines to add any AI tools
1010

11+
### Requirement: Interactive Mode
12+
The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.
13+
14+
#### Scenario: Displaying interactive menu
15+
- **WHEN** run in fresh or extend mode
16+
- **THEN** present a looping select menu that lets users toggle tools with Enter and finish via a "Done" option
17+
- **AND** label already configured tools with "(already configured)" while keeping disabled options marked "coming soon"
18+
- **AND** change the prompt copy in extend mode to "Which AI tools would you like to add or refresh?"
19+
- **AND** display inline instructions clarifying that Enter toggles a tool and selecting "Done" confirms the list
20+
1121
## ADDED Requirements
1222
### Requirement: Additional AI Tool Initialization
1323
`openspec init` SHALL allow users to add configuration files for new AI coding assistants after the initial setup.
@@ -18,3 +28,18 @@ The command SHALL perform safety checks to prevent overwriting existing structur
1828
- **THEN** generate that tool's configuration files with OpenSpec markers the same way as during first-time initialization
1929
- **AND** leave existing tool configuration files unchanged except for managed sections that need refreshing
2030
- **AND** exit with code 0 and display a success summary highlighting the newly added tool files
31+
32+
### Requirement: Success Output Enhancements
33+
`openspec init` SHALL summarize tool actions when initialization or extend mode completes.
34+
35+
#### Scenario: Showing tool summary
36+
- **WHEN** the command completes successfully
37+
- **THEN** display a categorized summary of tools that were created, refreshed, or skipped (including already-configured skips)
38+
- **AND** personalize the "Next steps" header using the names of the selected tools, defaulting to a generic label when none remain
39+
40+
### Requirement: Exit Code Adjustments
41+
`openspec init` SHALL treat extend mode with no selected tools as a guarded error.
42+
43+
#### Scenario: Preventing empty extend runs
44+
- **WHEN** OpenSpec is already initialized and the user selects no additional tools
45+
- **THEN** exit with code 1 after showing the existing-initialization guidance message
Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
# Implementation Tasks
22

33
## 1. Extend Init Guard
4-
- [ ] 1.1 Detect existing OpenSpec structures at the start of `openspec init` and enter an extend mode instead of failing.
5-
- [ ] 1.2 Log that core scaffolding will be skipped while still protecting against missing write permissions.
4+
- [x] 1.1 Detect existing OpenSpec structures at the start of `openspec init` and enter an extend mode instead of failing.
5+
- [x] 1.2 Log that core scaffolding will be skipped while still protecting against missing write permissions.
66

77
## 2. Update AI Tool Selection
8-
- [ ] 2.1 Present AI tool choices even in extend mode, indicating which tools are already configured.
9-
- [ ] 2.2 Ensure disabled "coming soon" tools remain non-selectable.
8+
- [x] 2.1 Present AI tool choices even in extend mode, indicating which tools are already configured.
9+
- [x] 2.2 Ensure disabled "coming soon" tools remain non-selectable.
1010

1111
## 3. Generate Additional Tool Files
12-
- [ ] 3.1 Create configuration files for newly selected tools while leaving untouched tools unaffected apart from marker-managed sections.
13-
- [ ] 3.2 Summarize created, refreshed, and skipped tools before exiting with the appropriate code.
12+
- [x] 3.1 Create configuration files for newly selected tools while leaving untouched tools unaffected apart from marker-managed sections.
13+
- [x] 3.2 Summarize created, refreshed, and skipped tools before exiting with the appropriate code.
1414

1515
## 4. Verification
16-
- [ ] 4.1 Add tests covering rerunning `openspec init` to add another tool and the scenario where the user declines to add anything.
16+
- [x] 4.1 Add tests covering rerunning `openspec init` to add another tool and the scenario where the user declines to add anything.

src/core/init.ts

Lines changed: 146 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import path from 'path';
22
import { select } from '@inquirer/prompts';
3+
import chalk from 'chalk';
34
import ora from 'ora';
45
import { FileSystemUtils } from '../utils/file-system.js';
56
import { TemplateManager, ProjectContext } from './templates/index.js';
67
import { ToolRegistry } from './configurators/registry.js';
78
import { SlashCommandRegistry } from './configurators/slash/registry.js';
8-
import { OpenSpecConfig, AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
9+
import { OpenSpecConfig, AI_TOOLS, OPENSPEC_DIR_NAME, AIToolOption } from './config.js';
910

1011
export class InitCommand {
1112
async execute(targetPath: string): Promise<void> {
@@ -14,60 +15,136 @@ export class InitCommand {
1415
const openspecPath = path.join(projectPath, openspecDir);
1516

1617
// Validation happens silently in the background
17-
await this.validate(projectPath, openspecPath);
18+
const extendMode = await this.validate(projectPath, openspecPath);
19+
const existingToolStates = await this.getExistingToolStates(projectPath);
1820

1921
// Get configuration (after validation to avoid prompts if validation fails)
20-
const config = await this.getConfiguration();
22+
const config = await this.getConfiguration(existingToolStates, extendMode);
23+
24+
if (config.aiTools.length === 0) {
25+
if (extendMode) {
26+
throw new Error(
27+
`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
28+
`Use 'openspec update' to update the structure.`
29+
);
30+
}
31+
32+
throw new Error('You must select at least one AI tool to configure.');
33+
}
34+
35+
const availableTools = AI_TOOLS.filter(tool => tool.available);
36+
const selectedIds = new Set(config.aiTools);
37+
const selectedTools = availableTools.filter(tool => selectedIds.has(tool.value));
38+
const created = selectedTools.filter(tool => !existingToolStates[tool.value]);
39+
const refreshed = selectedTools.filter(tool => existingToolStates[tool.value]);
40+
const skippedExisting = availableTools.filter(tool => !selectedIds.has(tool.value) && existingToolStates[tool.value]);
41+
const skipped = availableTools.filter(tool => !selectedIds.has(tool.value) && !existingToolStates[tool.value]);
2142

2243
// Step 1: Create directory structure
23-
const structureSpinner = ora({ text: 'Creating OpenSpec structure...', stream: process.stdout }).start();
24-
await this.createDirectoryStructure(openspecPath);
25-
await this.generateFiles(openspecPath, config);
26-
structureSpinner.succeed('OpenSpec structure created');
44+
if (!extendMode) {
45+
const structureSpinner = ora({ text: 'Creating OpenSpec structure...', stream: process.stdout }).start();
46+
await this.createDirectoryStructure(openspecPath);
47+
await this.generateFiles(openspecPath, config);
48+
structureSpinner.succeed('OpenSpec structure created');
49+
} else {
50+
ora({ stream: process.stdout }).info('OpenSpec already initialized. Skipping base scaffolding.');
51+
}
2752

2853
// Step 2: Configure AI tools
2954
const toolSpinner = ora({ text: 'Configuring AI tools...', stream: process.stdout }).start();
3055
await this.configureAITools(projectPath, openspecDir, config.aiTools);
3156
toolSpinner.succeed('AI tools configured');
3257

3358
// Success message
34-
this.displaySuccessMessage(openspecDir, config);
59+
this.displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode);
3560
}
3661

37-
private async validate(projectPath: string, openspecPath: string): Promise<void> {
38-
// Check if OpenSpec already exists
39-
if (await FileSystemUtils.directoryExists(openspecPath)) {
40-
throw new Error(
41-
`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
42-
`Use 'openspec update' to update the structure.`
43-
);
44-
}
62+
private async validate(projectPath: string, _openspecPath: string): Promise<boolean> {
63+
const extendMode = await FileSystemUtils.directoryExists(_openspecPath);
4564

4665
// Check write permissions
4766
if (!await FileSystemUtils.ensureWritePermissions(projectPath)) {
4867
throw new Error(`Insufficient permissions to write to ${projectPath}`);
4968
}
69+
return extendMode;
70+
}
5071

72+
private async getConfiguration(existingTools: Record<string, boolean>, extendMode: boolean): Promise<OpenSpecConfig> {
73+
const selectedTools = await this.promptForAITools(existingTools, extendMode);
74+
return { aiTools: selectedTools };
5175
}
5276

53-
private async getConfiguration(): Promise<OpenSpecConfig> {
54-
const config: OpenSpecConfig = {
55-
aiTools: []
56-
};
77+
private async promptForAITools(existingTools: Record<string, boolean>, extendMode: boolean): Promise<string[]> {
78+
const selected = new Set<string>();
79+
const availableIds = new Set(AI_TOOLS.filter(tool => tool.available).map(tool => tool.value));
80+
const baseMessage = extendMode
81+
? 'Which AI tools would you like to add or refresh?'
82+
: 'Which AI tools do you use?';
83+
84+
while (true) {
85+
const doneLabel = selected.size > 0
86+
? chalk.cyan(`Done (${selected.size} selected)`)
87+
: chalk.cyan('Done');
88+
89+
const choices = AI_TOOLS.map((tool) => {
90+
const isSelected = selected.has(tool.value);
91+
const indicator = isSelected ? chalk.green('[x]') : '[ ]';
92+
const configuredLabel = existingTools[tool.value] ? chalk.gray(' (already configured)') : '';
93+
const label = `${indicator} ${tool.name}${configuredLabel}`;
94+
return {
95+
name: isSelected ? chalk.bold(label) : label,
96+
value: tool.value,
97+
disabled: tool.available ? false : 'coming soon'
98+
};
99+
});
100+
101+
choices.push({ name: doneLabel, value: '__done__', disabled: false });
102+
103+
const message = `${baseMessage}\n${chalk.dim('Press Enter to toggle or choose "Done" when finished.')}`;
104+
const answer = await select<string>({
105+
message,
106+
choices,
107+
loop: false
108+
});
109+
110+
if (answer === '__done__') {
111+
break;
112+
}
57113

58-
// Single-select for better UX
59-
const selectedTool = await select({
60-
message: 'Which AI tool do you use?',
61-
choices: AI_TOOLS.map(tool => ({
62-
name: tool.available ? tool.name : `${tool.name} (coming soon)`,
63-
value: tool.value,
64-
disabled: !tool.available
65-
}))
66-
});
67-
68-
config.aiTools = [selectedTool as string];
114+
if (!availableIds.has(answer)) {
115+
continue;
116+
}
117+
118+
if (selected.has(answer)) {
119+
selected.delete(answer);
120+
} else {
121+
selected.add(answer);
122+
}
123+
}
124+
125+
return AI_TOOLS
126+
.filter(tool => tool.available && selected.has(tool.value))
127+
.map(tool => tool.value);
128+
}
129+
130+
private async getExistingToolStates(projectPath: string): Promise<Record<string, boolean>> {
131+
const states: Record<string, boolean> = {};
132+
for (const tool of AI_TOOLS) {
133+
states[tool.value] = await this.isToolConfigured(projectPath, tool.value);
134+
}
135+
return states;
136+
}
69137

70-
return config;
138+
private async isToolConfigured(projectPath: string, toolId: string): Promise<boolean> {
139+
const configFile = ToolRegistry.get(toolId)?.configFileName;
140+
if (configFile && await FileSystemUtils.fileExists(path.join(projectPath, configFile))) return true;
141+
142+
const slashConfigurator = SlashCommandRegistry.get(toolId);
143+
if (!slashConfigurator) return false;
144+
for (const target of slashConfigurator.getTargets()) {
145+
if (await FileSystemUtils.fileExists(path.join(projectPath, target.path))) return true;
146+
}
147+
return false;
71148
}
72149

73150
private async createDirectoryStructure(openspecPath: string): Promise<void> {
@@ -114,15 +191,33 @@ export class InitCommand {
114191
}
115192
}
116193

117-
private displaySuccessMessage(openspecDir: string, config: OpenSpecConfig): void {
194+
private displaySuccessMessage(
195+
selectedTools: AIToolOption[],
196+
created: AIToolOption[],
197+
refreshed: AIToolOption[],
198+
skippedExisting: AIToolOption[],
199+
skipped: AIToolOption[],
200+
extendMode: boolean
201+
): void {
118202
console.log(); // Empty line for spacing
119-
ora().succeed('OpenSpec initialized successfully!');
120-
121-
// Get the selected tool name for display
122-
const selectedToolId = config.aiTools[0];
123-
const selectedTool = AI_TOOLS.find(t => t.value === selectedToolId);
124-
const toolName = selectedTool?.successLabel ?? selectedTool?.name ?? 'your AI assistant';
125-
203+
ora().succeed(extendMode ? 'OpenSpec tool configuration updated!' : 'OpenSpec initialized successfully!');
204+
205+
console.log('\nTool summary:');
206+
const summaryLines = [
207+
created.length ? `- Created: ${this.formatToolNames(created)}` : null,
208+
refreshed.length ? `- Refreshed: ${this.formatToolNames(refreshed)}` : null,
209+
skippedExisting.length ? `- Skipped (already configured): ${this.formatToolNames(skippedExisting)}` : null,
210+
skipped.length ? `- Skipped: ${this.formatToolNames(skipped)}` : null
211+
].filter((line): line is string => Boolean(line));
212+
for (const line of summaryLines) {
213+
console.log(line);
214+
}
215+
216+
console.log('\nUse `openspec update` to refresh shared OpenSpec instructions in the future.');
217+
218+
// Get the selected tool name(s) for display
219+
const toolName = this.formatToolNames(selectedTools);
220+
126221
console.log(`\nNext steps - Copy these prompts to ${toolName}:\n`);
127222
console.log('────────────────────────────────────────────────────────────');
128223
console.log('1. Populate your project context:');
@@ -136,4 +231,15 @@ export class InitCommand {
136231
console.log(' and how I should work with you on this project"');
137232
console.log('────────────────────────────────────────────────────────────\n');
138233
}
234+
235+
private formatToolNames(tools: AIToolOption[]): string {
236+
const names = tools
237+
.map((tool) => tool.successLabel ?? tool.name)
238+
.filter((name): name is string => Boolean(name));
239+
240+
if (names.length === 0) return 'your AI assistant';
241+
if (names.length === 1) return names[0];
242+
const last = names.pop();
243+
return `${names.join(', ')}${names.length ? ', and ' : ''}${last}`;
244+
}
139245
}

0 commit comments

Comments
 (0)