@@ -4,7 +4,7 @@ import type { Command } from 'commander';
44import chalk from 'chalk' ;
55import { stringify , parse } from 'yaml' ;
66import { select , checkbox , confirm } from '@inquirer/prompts' ;
7- import { fetchRawContent , fetchContent , listDirectory } from '../utils/github.js' ;
7+ import { fetchRawContent , fetchContent , listDirectory , listContentDirectory } from '../utils/github.js' ;
88import { convert } from '../core/converter.js' ;
99import { isAssetType , parseAssetFrontmatter } from '../core/assets.js' ;
1010import { fileExists } from '../utils/fs.js' ;
@@ -65,38 +65,39 @@ export async function fetchRegistry(cwd: string): Promise<CachedRegistry | null>
6565 return null ;
6666 }
6767
68- const categories : CachedRegistry [ 'categories' ] = [ ] ;
68+ const dirs = topLevel . filter ( ( e ) => e . type === 'dir' ) ;
6969
70- for ( const entry of topLevel ) {
71- if ( entry . type !== 'dir' ) continue ;
72-
73- try {
74- const files = await listDirectory ( entry . name ) ;
75- const rules : Array < { name : string ; description : string } > = [ ] ;
76-
77- for ( const file of files ) {
78- if ( file . type !== 'file' ) continue ;
79- try {
80- const content = await fetchRawContent ( `${ entry . name } /${ file . name } ` ) ;
81- const fmMatch = / ^ - - - \n ( [ \s \S ] * ?) \n - - - / . exec ( content ) ;
82- if ( fmMatch ?. [ 1 ] ) {
83- const fm = parse ( fmMatch [ 1 ] ) as Record < string , unknown > ;
84- const description = typeof fm [ 'description' ] === 'string' ? fm [ 'description' ] : '' ;
85- rules . push ( { name : file . name , description } ) ;
86- }
87- } catch {
88- rules . push ( { name : file . name , description : '' } ) ;
89- }
90- }
70+ const categoryResults = await Promise . all (
71+ dirs . map ( async ( entry ) => {
72+ try {
73+ const files = await listDirectory ( entry . name ) ;
74+ const ruleFiles = files . filter ( ( f ) => f . type === 'file' ) ;
75+
76+ const rules = await Promise . all (
77+ ruleFiles . map ( async ( file ) => {
78+ try {
79+ const content = await fetchRawContent ( `${ entry . name } /${ file . name } ` ) ;
80+ const fmMatch = / ^ - - - \n ( [ \s \S ] * ?) \n - - - / . exec ( content ) ;
81+ if ( fmMatch ?. [ 1 ] ) {
82+ const fm = parse ( fmMatch [ 1 ] ) as Record < string , unknown > ;
83+ const description = typeof fm [ 'description' ] === 'string' ? fm [ 'description' ] : '' ;
84+ return { name : file . name , description } ;
85+ }
86+ return { name : file . name , description : '' } ;
87+ } catch {
88+ return { name : file . name , description : '' } ;
89+ }
90+ } ) ,
91+ ) ;
9192
92- if ( rules . length > 0 ) {
93- categories . push ( { name : entry . name , rules } ) ;
93+ return rules . length > 0 ? { name : entry . name , rules } : null ;
94+ } catch {
95+ return null ;
9496 }
95- } catch {
96- // Skip categories that fail to list
97- }
98- }
97+ } ) ,
98+ ) ;
9999
100+ const categories = categoryResults . filter ( ( c ) : c is NonNullable < typeof c > => c !== null ) ;
100101 const registry : CachedRegistry = { categories } ;
101102 await cache . set ( cwd , 'registry' , registry ) ;
102103 return registry ;
@@ -137,6 +138,38 @@ async function runList(categoryFilter: string | undefined): Promise<void> {
137138 }
138139
139140 console . log ( ` ${ chalk . dim ( `Add a rule: devw add <category>/<rule>` ) } ` ) ;
141+
142+ // Show available assets if not filtering by category
143+ if ( ! categoryFilter ) {
144+ const assetTypes = [ 'commands' , 'templates' , 'hooks' , 'presets' ] as const ;
145+ const assetResults = await Promise . allSettled (
146+ assetTypes . map ( ( dir ) => listContentDirectory ( dir ) ) ,
147+ ) ;
148+
149+ const hasAnyAssets = assetResults . some (
150+ ( r ) => r . status === 'fulfilled' && r . value . some ( ( e ) => e . type === 'file' ) ,
151+ ) ;
152+
153+ if ( hasAnyAssets ) {
154+ ui . newline ( ) ;
155+ ui . header ( 'Available assets' ) ;
156+ ui . newline ( ) ;
157+ for ( let i = 0 ; i < assetTypes . length ; i ++ ) {
158+ const type = assetTypes [ i ] ! ;
159+ const result = assetResults [ i ] ! ;
160+ if ( result . status !== 'fulfilled' ) continue ;
161+ const names = result . value . filter ( ( e ) => e . type === 'file' ) . map ( ( e ) => e . name ) ;
162+ if ( names . length === 0 ) continue ;
163+ const singular = type . replace ( / s $ / , '' ) ;
164+ console . log ( ` ${ chalk . cyan ( `${ singular } /` ) } ` ) ;
165+ for ( const name of names ) {
166+ console . log ( ` ${ chalk . white ( name ) } ` ) ;
167+ }
168+ ui . newline ( ) ;
169+ }
170+ console . log ( ` ${ chalk . dim ( `Add an asset: devw add command/<name>` ) } ` ) ;
171+ }
172+ }
140173}
141174
142175export function generateYamlOutput (
@@ -388,7 +421,94 @@ async function downloadAndInstall(
388421 return true ;
389422}
390423
424+ async function runInteractiveAsset ( cwd : string , options : AddOptions ) : Promise < void > {
425+ let assetType : AssetType | 'preset' ;
426+ try {
427+ assetType = await select < AssetType | 'preset' > ( {
428+ message : 'Asset type' ,
429+ choices : [
430+ { name : 'command — Slash commands for Claude Code' , value : 'command' } ,
431+ { name : 'template — Spec and document templates' , value : 'template' } ,
432+ { name : 'hook — Editor hooks (auto-format, etc.)' , value : 'hook' } ,
433+ { name : 'preset — Bundle of rules + assets' , value : 'preset' } ,
434+ ] ,
435+ } ) ;
436+ } catch {
437+ ui . error ( 'Cancelled' ) ;
438+ return ;
439+ }
440+
441+ ui . info ( `Fetching available ${ assetType } s from GitHub...` ) ;
442+
443+ let names : string [ ] ;
444+ try {
445+ const entries = await listContentDirectory ( `${ assetType } s` ) ;
446+ names = entries . filter ( ( e ) => e . type === 'file' ) . map ( ( e ) => e . name ) ;
447+ } catch ( err ) {
448+ const msg = err instanceof Error ? err . message : String ( err ) ;
449+ ui . error ( `Could not fetch ${ assetType } list: ${ msg } ` ) ;
450+ process . exitCode = 1 ;
451+ return ;
452+ }
453+
454+ if ( names . length === 0 ) {
455+ ui . warn ( `No ${ assetType } s available in registry` ) ;
456+ return ;
457+ }
458+
459+ let selected : string [ ] ;
460+ try {
461+ selected = await checkbox < string > ( {
462+ message : `Select ${ assetType } s to install` ,
463+ choices : names . map ( ( name ) => ( { name, value : name } ) ) ,
464+ } ) ;
465+ } catch {
466+ ui . error ( 'Cancelled' ) ;
467+ return ;
468+ }
469+
470+ if ( selected . length === 0 ) {
471+ ui . warn ( 'No assets selected' ) ;
472+ return ;
473+ }
474+
475+ let anyAdded = false ;
476+ for ( const name of selected ) {
477+ if ( assetType === 'preset' ) {
478+ const added = await installPreset ( cwd , name , options ) ;
479+ if ( added ) anyAdded = true ;
480+ } else {
481+ const added = await downloadAndInstallAsset ( cwd , assetType , name , options ) ;
482+ if ( added ) anyAdded = true ;
483+ }
484+ }
485+
486+ if ( anyAdded && ! options . noCompile ) {
487+ const { runCompileFromAdd } = await import ( './compile.js' ) ;
488+ await runCompileFromAdd ( ) ;
489+ }
490+ }
491+
391492async function runInteractive ( cwd : string , options : AddOptions ) : Promise < void > {
493+ let mode : 'rules' | 'assets' ;
494+ try {
495+ mode = await select < 'rules' | 'assets' > ( {
496+ message : 'What do you want to add?' ,
497+ choices : [
498+ { name : 'Rules — Install rules from the registry' , value : 'rules' } ,
499+ { name : 'Assets — Commands, templates, hooks, presets' , value : 'assets' } ,
500+ ] ,
501+ } ) ;
502+ } catch {
503+ ui . error ( 'Cancelled' ) ;
504+ return ;
505+ }
506+
507+ if ( mode === 'assets' ) {
508+ await runInteractiveAsset ( cwd , options ) ;
509+ return ;
510+ }
511+
392512 const registry = await fetchRegistry ( cwd ) ;
393513 if ( ! registry ) {
394514 process . exitCode = 1 ;
@@ -623,7 +743,15 @@ async function runAdd(ruleArg: string | undefined, options: AddOptions): Promise
623743 }
624744
625745 if ( ! ruleArg . includes ( '/' ) ) {
626- ui . error ( 'Block format is no longer supported' , 'Use: devw add <category>/<rule>. Run devw add --list to browse.' ) ;
746+ const dashIdx = ruleArg . indexOf ( '-' ) ;
747+ const hint =
748+ dashIdx > 0
749+ ? `devw add ${ ruleArg . slice ( 0 , dashIdx ) } /${ ruleArg . slice ( dashIdx + 1 ) } `
750+ : `devw add <category>/<rule>` ;
751+ ui . error (
752+ `Block format "${ ruleArg } " is no longer supported` ,
753+ `Use category/name format — e.g., ${ hint } . Run devw add --list to browse.` ,
754+ ) ;
627755 process . exitCode = 1 ;
628756 return ;
629757 }
0 commit comments