@@ -6,6 +6,43 @@ const csv = require('csv-parse/sync');
66const { BMAD_FOLDER_NAME } = require ( './shared/path-utils' ) ;
77const { getInstalledCanonicalIds, isBmadOwnedEntry } = require ( './shared/installed-skills' ) ;
88
9+ // Reserved OpenCode slash commands. A skill whose canonicalId collides with
10+ // one of these is skipped during command-pointer generation so it doesn't
11+ // shadow a built-in.
12+ const RESERVED_OPENCODE_COMMANDS = new Set ( [
13+ 'review' ,
14+ 'commit' ,
15+ 'init' ,
16+ 'help' ,
17+ 'skills' ,
18+ 'fast' ,
19+ 'compact' ,
20+ 'clear' ,
21+ 'undo' ,
22+ 'redo' ,
23+ 'edit' ,
24+ 'editor' ,
25+ 'exit' ,
26+ 'quit' ,
27+ 'theme' ,
28+ 'config' ,
29+ 'model' ,
30+ 'session' ,
31+ ] ) ;
32+
33+ // Wrap a description for safe insertion into single-line YAML frontmatter.
34+ // Leaves plain values untouched; double-quotes (and escapes) anything that
35+ // could break YAML parsing or span multiple lines.
36+ function yamlSafeSingleLine ( value ) {
37+ const collapsed = String ( value )
38+ . replaceAll ( / [ \r \n ] + / g, ' ' )
39+ . trim ( ) ;
40+ const needsQuoting = / [: # ' " \\ ] / . test ( collapsed ) || / ^ [ ! & * ? | > % @ ` ] / . test ( collapsed ) ;
41+ if ( ! needsQuoting ) return collapsed ;
42+ const escaped = collapsed . replaceAll ( '\\' , '\\\\' ) . replaceAll ( '"' , String . raw `\"` ) ;
43+ return `"${ escaped } "` ;
44+ }
45+
946/**
1047 * Config-driven IDE setup handler
1148 *
@@ -128,11 +165,76 @@ class ConfigDrivenIdeSetup {
128165 results . skills = await this . installVerbatimSkills ( projectDir , bmadDir , targetPath , config ) ;
129166 results . skillDirectories = this . skillWriteTracker . size ;
130167
168+ if ( config . commands_target_dir ) {
169+ results . commands = await this . installCommandPointers ( projectDir , bmadDir , config , options ) ;
170+ }
171+
131172 await this . printSummary ( results , target_dir , options ) ;
132173 this . skillWriteTracker = null ;
133174 return { success : true , results } ;
134175 }
135176
177+ /**
178+ * Generate per-skill command pointer files for IDEs that surface commands
179+ * separately from skills (e.g. OpenCode's `.opencode/commands/<name>.md`).
180+ *
181+ * Each pointer is a tiny markdown file whose body is `@skills/<canonicalId>`
182+ * so invoking `/<canonicalId>` routes the user straight to the skill instead
183+ * of forcing them through a `/skills` menu.
184+ *
185+ * Skips:
186+ * - Names that collide with reserved built-in slash commands.
187+ * - Existing files (treated as hand-tuned) unless options.forceCommands.
188+ *
189+ * @param {string } projectDir
190+ * @param {string } bmadDir
191+ * @param {Object } config - Installer config; reads commands_target_dir.
192+ * @param {Object } options - Setup options. forceCommands overwrites existing files.
193+ * @returns {Promise<Object> } { created, skippedExisting, skippedCollision, fallbackDescription }
194+ */
195+ async installCommandPointers ( projectDir , bmadDir , config , options = { } ) {
196+ const result = { created : 0 , skippedExisting : 0 , skippedCollision : 0 , fallbackDescription : 0 } ;
197+
198+ const csvPath = path . join ( bmadDir , '_config' , 'skill-manifest.csv' ) ;
199+ if ( ! ( await fs . pathExists ( csvPath ) ) ) return result ;
200+
201+ const commandsPath = path . join ( projectDir , config . commands_target_dir ) ;
202+ await fs . ensureDir ( commandsPath ) ;
203+
204+ const csvContent = await fs . readFile ( csvPath , 'utf8' ) ;
205+ const records = csv . parse ( csvContent , { columns : true , skip_empty_lines : true } ) ;
206+
207+ for ( const record of records ) {
208+ const canonicalId = record . canonicalId ;
209+ if ( ! canonicalId ) continue ;
210+
211+ if ( RESERVED_OPENCODE_COMMANDS . has ( canonicalId ) ) {
212+ result . skippedCollision ++ ;
213+ continue ;
214+ }
215+
216+ const commandFile = path . join ( commandsPath , `${ canonicalId } .md` ) ;
217+
218+ if ( ( await fs . pathExists ( commandFile ) ) && ! options . forceCommands ) {
219+ result . skippedExisting ++ ;
220+ continue ;
221+ }
222+
223+ let description = ( record . description || '' ) . trim ( ) ;
224+ if ( ! description ) {
225+ description = `Run the ${ canonicalId } skill` ;
226+ result . fallbackDescription ++ ;
227+ }
228+
229+ const body = `---\ndescription: ${ yamlSafeSingleLine ( description ) } \n---\n\n@skills/${ canonicalId } \n` ;
230+
231+ await fs . writeFile ( commandFile , body , 'utf8' ) ;
232+ result . created ++ ;
233+ }
234+
235+ return result ;
236+ }
237+
136238 /**
137239 * Install verbatim native SKILL.md directories from skill-manifest.csv.
138240 * Copies the entire source directory as-is into the IDE skill directory.
@@ -256,6 +358,13 @@ class ConfigDrivenIdeSetup {
256358 if ( this . installerConfig ?. target_dir ) {
257359 await this . cleanupTarget ( projectDir , this . installerConfig . target_dir , options , removalSet ) ;
258360 }
361+
362+ // Clean generated command pointer files in commands_target_dir.
363+ // Mirrors target_dir cleanup so uninstalls and skill removals don't
364+ // leave dangling /<canonicalId> commands pointing at missing skills.
365+ if ( this . installerConfig ?. commands_target_dir ) {
366+ await this . cleanupCommandPointers ( projectDir , this . installerConfig . commands_target_dir , options , removalSet ) ;
367+ }
259368 }
260369
261370 /**
@@ -346,6 +455,51 @@ class ConfigDrivenIdeSetup {
346455 }
347456 }
348457
458+ /**
459+ * Cleanup generated command pointer files for entries in removalSet.
460+ * Symmetric counterpart to installCommandPointers — removes <canonicalId>.md
461+ * files whose canonicalId is in the set. Removes the commands directory
462+ * entirely if it ends up empty.
463+ * @param {string } projectDir
464+ * @param {string } commandsTargetDir - Relative dir (e.g. .opencode/commands)
465+ * @param {Object } options
466+ * @param {Set<string> } removalSet - canonicalIds whose pointer files to remove
467+ */
468+ async cleanupCommandPointers ( projectDir , commandsTargetDir , options = { } , removalSet = new Set ( ) ) {
469+ if ( ! removalSet || removalSet . size === 0 ) return ;
470+
471+ const commandsPath = path . join ( projectDir , commandsTargetDir ) ;
472+ if ( ! ( await fs . pathExists ( commandsPath ) ) ) return ;
473+
474+ let entries ;
475+ try {
476+ entries = await fs . readdir ( commandsPath ) ;
477+ } catch {
478+ return ;
479+ }
480+
481+ for ( const entry of entries ) {
482+ if ( typeof entry !== 'string' || ! entry . endsWith ( '.md' ) ) continue ;
483+ const canonicalId = entry . slice ( 0 , - 3 ) ;
484+ if ( ! removalSet . has ( canonicalId ) ) continue ;
485+ try {
486+ await fs . remove ( path . join ( commandsPath , entry ) ) ;
487+ } catch {
488+ // Skip files we can't remove.
489+ }
490+ }
491+
492+ // Remove the commands directory if we emptied it.
493+ try {
494+ const remaining = await fs . readdir ( commandsPath ) ;
495+ if ( remaining . length === 0 ) {
496+ await fs . remove ( commandsPath ) ;
497+ }
498+ } catch {
499+ // Directory may already be gone.
500+ }
501+ }
502+
349503 /**
350504 * Cleanup a specific target directory.
351505 * When removalSet is provided, only removes entries in that set.
0 commit comments