Skip to content

Commit 42abcd7

Browse files
grokifyclaude
andcommitted
feat(commands): add markdown command file support with YAML frontmatter
ReadCanonicalFile and ReadCanonicalDir now auto-detect format: - .md files or files starting with "---" parsed as markdown - Infer command name from filename if not set - Fall back to JSON for other files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1be8e02 commit 42abcd7

File tree

1 file changed

+160
-3
lines changed

1 file changed

+160
-3
lines changed

commands/core/adapter.go

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
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.
127129
func 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.
161179
func 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

Comments
 (0)