11import path from 'path' ;
22import { select } from '@inquirer/prompts' ;
3+ import chalk from 'chalk' ;
34import ora from 'ora' ;
45import { FileSystemUtils } from '../utils/file-system.js' ;
56import { TemplateManager , ProjectContext } from './templates/index.js' ;
67import { ToolRegistry } from './configurators/registry.js' ;
78import { 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
1011export 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