77 "os"
88 "path/filepath"
99 "sort"
10+ "strings"
1011 "sync"
1112)
1213
@@ -123,13 +124,30 @@ func Convert(data []byte, from, to string) ([]byte, error) {
123124 return DefaultRegistry .Convert (data , from , to )
124125}
125126
126- // ReadCanonicalFile reads a canonical command.json file.
127+ // ReadCanonicalFile reads a canonical command file (JSON or Markdown with YAML frontmatter).
128+ // The format is auto-detected based on file extension or content.
127129func ReadCanonicalFile (path string ) (* Command , error ) {
128130 data , err := os .ReadFile (path )
129131 if err != nil {
130132 return nil , & ReadError {Path : path , Err : err }
131133 }
132134
135+ // Detect format: if it starts with "---" or has .md extension, parse as markdown
136+ ext := filepath .Ext (path )
137+ if ext == ".md" || (len (data ) >= 3 && string (data [:3 ]) == "---" ) {
138+ cmd , err := ParseCommandMarkdown (data )
139+ if err != nil {
140+ return nil , & ParseError {Format : "markdown" , Path : path , Err : err }
141+ }
142+ // Infer name from filename if not set
143+ if cmd .Name == "" {
144+ base := filepath .Base (path )
145+ cmd .Name = strings .TrimSuffix (base , filepath .Ext (base ))
146+ }
147+ return cmd , nil
148+ }
149+
150+ // Fall back to JSON
133151 var cmd Command
134152 if err := json .Unmarshal (data , & cmd ); err != nil {
135153 return nil , & ParseError {Format : "canonical" , Path : path , Err : err }
@@ -157,7 +175,7 @@ func WriteCanonicalFile(cmd *Command, path string) error {
157175 return nil
158176}
159177
160- // ReadCanonicalDir reads all command.json files from a directory.
178+ // ReadCanonicalDir reads all command files ( .json or .md) from a directory.
161179func ReadCanonicalDir (dir string ) ([]* Command , error ) {
162180 entries , err := os .ReadDir (dir )
163181 if err != nil {
@@ -166,7 +184,12 @@ func ReadCanonicalDir(dir string) ([]*Command, error) {
166184
167185 var commands []* Command
168186 for _ , entry := range entries {
169- if entry .IsDir () || filepath .Ext (entry .Name ()) != ".json" {
187+ if entry .IsDir () {
188+ continue
189+ }
190+
191+ ext := filepath .Ext (entry .Name ())
192+ if ext != ".json" && ext != ".md" {
170193 continue
171194 }
172195
@@ -202,3 +225,137 @@ func WriteCommandsToDir(commands []*Command, dir string, adapterName string) err
202225
203226 return nil
204227}
228+
229+ // ParseCommandMarkdown parses a Markdown file with YAML frontmatter into a Command.
230+ // The frontmatter should contain: name, description, arguments, dependencies, process.
231+ // The body becomes the instructions.
232+ func ParseCommandMarkdown (data []byte ) (* Command , error ) {
233+ content := string (data )
234+
235+ if ! strings .HasPrefix (content , "---" ) {
236+ // No frontmatter, treat entire content as instructions
237+ return & Command {Instructions : strings .TrimSpace (content )}, nil
238+ }
239+
240+ parts := strings .SplitN (content , "---" , 3 )
241+ if len (parts ) < 3 {
242+ return & Command {Instructions : strings .TrimSpace (content )}, nil
243+ }
244+
245+ cmd := & Command {}
246+
247+ // Parse YAML frontmatter
248+ lines := strings .Split (strings .TrimSpace (parts [1 ]), "\n " )
249+ var currentKey string
250+ var listItems []string
251+
252+ for _ , line := range lines {
253+ trimmed := strings .TrimSpace (line )
254+ if trimmed == "" || strings .HasPrefix (trimmed , "#" ) {
255+ continue
256+ }
257+
258+ // Check if this is a list item (starts with -)
259+ if strings .HasPrefix (trimmed , "- " ) {
260+ if currentKey != "" {
261+ listItems = append (listItems , strings .TrimPrefix (trimmed , "- " ))
262+ }
263+ continue
264+ }
265+
266+ // Process any accumulated list items
267+ if currentKey != "" && len (listItems ) > 0 {
268+ switch currentKey {
269+ case "dependencies" :
270+ cmd .Dependencies = listItems
271+ case "process" :
272+ cmd .Process = listItems
273+ }
274+ listItems = nil
275+ }
276+
277+ // Parse key: value
278+ idx := strings .Index (trimmed , ":" )
279+ if idx <= 0 {
280+ continue
281+ }
282+ key := strings .TrimSpace (trimmed [:idx ])
283+ value := strings .TrimSpace (trimmed [idx + 1 :])
284+ value = strings .Trim (value , "\" '" )
285+
286+ currentKey = key
287+
288+ switch key {
289+ case "name" :
290+ cmd .Name = value
291+ case "description" :
292+ cmd .Description = value
293+ case "dependencies" :
294+ if value != "" {
295+ cmd .Dependencies = parseList (value )
296+ }
297+ // Otherwise wait for list items
298+ case "process" :
299+ if value != "" {
300+ cmd .Process = parseList (value )
301+ }
302+ // Otherwise wait for list items
303+ case "arguments" :
304+ // Arguments are handled specially - look for inline list or skip
305+ if value != "" {
306+ // Could be inline like: [version]
307+ cmd .Arguments = parseArguments (value )
308+ }
309+ }
310+ }
311+
312+ // Process any remaining list items
313+ if currentKey != "" && len (listItems ) > 0 {
314+ switch currentKey {
315+ case "dependencies" :
316+ cmd .Dependencies = listItems
317+ case "process" :
318+ cmd .Process = listItems
319+ }
320+ }
321+
322+ // Body becomes instructions
323+ cmd .Instructions = strings .TrimSpace (parts [2 ])
324+
325+ return cmd , nil
326+ }
327+
328+ // parseList parses a comma-separated or bracket-enclosed list.
329+ func parseList (s string ) []string {
330+ s = strings .Trim (s , "[]" )
331+ parts := strings .Split (s , "," )
332+ var result []string
333+ for _ , p := range parts {
334+ p = strings .TrimSpace (p )
335+ p = strings .Trim (p , "\" '" )
336+ if p != "" {
337+ result = append (result , p )
338+ }
339+ }
340+ return result
341+ }
342+
343+ // parseArguments parses an inline arguments list like [version, target].
344+ func parseArguments (s string ) []Argument {
345+ names := parseList (s )
346+ var args []Argument
347+ for _ , name := range names {
348+ // Check if required (no ? suffix)
349+ required := true
350+ if strings .HasSuffix (name , "?" ) {
351+ required = false
352+ name = strings .TrimSuffix (name , "?" )
353+ }
354+ args = append (args , Argument {
355+ Name : name ,
356+ Type : "string" ,
357+ Required : required ,
358+ })
359+ }
360+ return args
361+ }
0 commit comments