Skip to content

Commit 409f600

Browse files
Copilotalexec
andauthored
Add task file support for .opencode/command directories (#94)
* Initial plan * Add support for OpenCode commands in .opencode/command and ~/.config/opencode/command Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Update documentation for OpenCode command task support Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Address feedback: remove ~/.config/opencode, make task_name optional, .opencode/command is tasks-only Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexec <1142830+alexec@users.noreply.github.com>
1 parent 71b8d96 commit 409f600

6 files changed

Lines changed: 116 additions & 55 deletions

File tree

docs/reference/file-formats.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,11 @@ coding-context-cli \
160160

161161
Task files must be in one of these directories:
162162
- `./.agents/tasks/`
163+
- `./.cursor/commands/`
164+
- `./.opencode/command/`
163165
- `~/.agents/tasks/`
164166

165-
The filename itself doesn't matter; only the `task_name` frontmatter field is used for selection.
167+
The filename itself doesn't matter if `task_name` is specified in frontmatter. If `task_name` is not specified, the filename (without `.md` extension) is used as the task name.
166168

167169
## Rule Files
168170

docs/reference/search-paths.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,15 @@ See [How to Use Remote Directories](../how-to/use-remote-directories) for comple
3737
Task files are searched in the following directories, in order of precedence:
3838

3939
1. `./.agents/tasks/`
40-
2. `~/.agents/tasks/`
40+
2. `./.cursor/commands/`
41+
3. `./.opencode/command/`
42+
4. `~/.agents/tasks/`
4143

4244
### Discovery Rules
4345

4446
- All `.md` files in these directories are examined
45-
- The filename doesn't matter; only the `task_name` frontmatter field
47+
- If `task_name` is present in frontmatter, it's used for task identification
48+
- If `task_name` is absent, the filename (without `.md` extension) is used as the task name
4649
- First match wins (unless selectors create ambiguity)
4750
- Searches stop when a matching task is found
4851
- Remote directories (via `-d` flag) are searched before local directories
@@ -51,13 +54,20 @@ Task files are searched in the following directories, in order of precedence:
5154

5255
```
5356
Project structure:
54-
./.agents/tasks/fix-bug.md (task_name: fix-bug)
55-
~/.agents/tasks/code-review.md (task_name: code-review)
57+
./.agents/tasks/fix-bug.md (task_name: fix-bug)
58+
./.opencode/command/review-code.md (task_name: review-code)
59+
./.opencode/command/deploy.md (no task_name, uses filename)
60+
~/.agents/tasks/code-review.md (task_name: code-review)
5661
5762
Commands:
5863
coding-context-cli fix-bug → Uses ./.agents/tasks/fix-bug.md
64+
coding-context-cli review-code → Uses ./.opencode/command/review-code.md
65+
coding-context-cli deploy → Uses ./.opencode/command/deploy.md
5966
coding-context-cli code-review → Uses ~/.agents/tasks/code-review.md
6067
```
68+
coding-context-cli code-review → Uses ~/.agents/tasks/code-review.md
69+
coding-context-cli deploy → Uses ~/.config/opencode/command/deploy.md
70+
```
6171
6272
## Rule File Search Paths
6373
@@ -125,10 +135,10 @@ The CLI automatically discovers rules from configuration files for these AI codi
125135
|-------|----------------|
126136
| **Anthropic Claude** | `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` |
127137
| **Codex** | `AGENTS.md`, `.codex/AGENTS.md` |
128-
| **Cursor** | `.cursor/rules/`, `.cursorrules` |
138+
| **Cursor** | `.cursor/rules/`, `.cursorrules`, `.cursor/commands/` (tasks) |
129139
| **Augment** | `.augment/rules/`, `.augment/guidelines.md` |
130140
| **Windsurf** | `.windsurf/rules/`, `.windsurfrules` |
131-
| **OpenCode.ai** | `.opencode/agent/`, `.opencode/command/`, `.opencode/rules/` |
141+
| **OpenCode.ai** | `.opencode/agent/`, `.opencode/command/` (tasks), `.opencode/rules/` |
132142
| **GitHub Copilot** | `.github/copilot-instructions.md`, `.github/agents/` |
133143
| **Google Gemini** | `GEMINI.md`, `.gemini/styleguide.md` |
134144
| **Generic** | `AGENTS.md`, `.agents/rules/` |

integration_test.go

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -422,15 +422,11 @@ echo "Bootstrap executed successfully"
422422
func TestOpenCodeRulesSupport(t *testing.T) {
423423
tmpDir := t.TempDir()
424424
openCodeAgentDir := filepath.Join(tmpDir, ".opencode", "agent")
425-
openCodeCommandDir := filepath.Join(tmpDir, ".opencode", "command")
426425
tasksDir := filepath.Join(tmpDir, ".agents", "tasks")
427426

428427
if err := os.MkdirAll(openCodeAgentDir, 0o755); err != nil {
429428
t.Fatalf("failed to create opencode agent dir: %v", err)
430429
}
431-
if err := os.MkdirAll(openCodeCommandDir, 0o755); err != nil {
432-
t.Fatalf("failed to create opencode command dir: %v", err)
433-
}
434430
if err := os.MkdirAll(tasksDir, 0o755); err != nil {
435431
t.Fatalf("failed to create tasks dir: %v", err)
436432
}
@@ -445,16 +441,6 @@ This agent helps with documentation.
445441
t.Fatalf("failed to write agent file: %v", err)
446442
}
447443

448-
// Create a command rule file in .opencode/command
449-
commandFile := filepath.Join(openCodeCommandDir, "commit.md")
450-
commandContent := `# Commit Command
451-
452-
This command helps create commits.
453-
`
454-
if err := os.WriteFile(commandFile, []byte(commandContent), 0o644); err != nil {
455-
t.Fatalf("failed to write command file: %v", err)
456-
}
457-
458444
// Create a task file
459445
taskFile := filepath.Join(tasksDir, "test-opencode.md")
460446
taskContent := `---
@@ -476,17 +462,45 @@ This is a test task.
476462
t.Errorf("OpenCode agent rule content not found in stdout")
477463
}
478464

479-
// Check that command rule content is present
480-
if !strings.Contains(output, "# Commit Command") {
481-
t.Errorf("OpenCode command rule content not found in stdout")
482-
}
483-
484465
// Check that task content is present
485466
if !strings.Contains(output, "# Test OpenCode Task") {
486467
t.Errorf("task content not found in stdout")
487468
}
488469
}
489470

471+
func TestOpenCodeCommandTaskSupport(t *testing.T) {
472+
tmpDir := t.TempDir()
473+
openCodeCommandDir := filepath.Join(tmpDir, ".opencode", "command")
474+
475+
if err := os.MkdirAll(openCodeCommandDir, 0o755); err != nil {
476+
t.Fatalf("failed to create opencode command dir: %v", err)
477+
}
478+
479+
// Create a task file in .opencode/command
480+
taskFile := filepath.Join(openCodeCommandDir, "fix-bug.md")
481+
taskContent := `---
482+
task_name: fix-bug
483+
---
484+
# Fix Bug Command
485+
486+
This is an OpenCode command task for fixing bugs.
487+
`
488+
if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil {
489+
t.Fatalf("failed to write task file: %v", err)
490+
}
491+
492+
// Run the program
493+
output := runTool(t, "-C", tmpDir, "fix-bug")
494+
495+
// Check that task content is present
496+
if !strings.Contains(output, "# Fix Bug Command") {
497+
t.Errorf("OpenCode command task content not found in stdout")
498+
}
499+
if !strings.Contains(output, "This is an OpenCode command task for fixing bugs.") {
500+
t.Errorf("OpenCode command task description not found in stdout")
501+
}
502+
}
503+
490504
func TestTaskSelectionByFrontmatter(t *testing.T) {
491505
tmpDir := t.TempDir()
492506
tasksDir := filepath.Join(tmpDir, ".agents", "tasks")
@@ -518,36 +532,36 @@ This task has a different filename than task_name.
518532
}
519533
}
520534

521-
func TestTaskMissingTaskNameError(t *testing.T) {
535+
func TestTaskWithoutTaskNameUsesFilename(t *testing.T) {
522536
tmpDir := t.TempDir()
523537
tasksDir := filepath.Join(tmpDir, ".agents", "tasks")
524538

525539
if err := os.MkdirAll(tasksDir, 0o755); err != nil {
526540
t.Fatalf("failed to create tasks dir: %v", err)
527541
}
528542

529-
// Create a task file WITHOUT task_name in frontmatter
530-
taskFile := filepath.Join(tasksDir, "bad-task.md")
543+
// Create a file WITHOUT task_name in frontmatter - should use filename
544+
taskFile := filepath.Join(tasksDir, "my-task.md")
531545
taskContent := `---
532546
description: A task without task_name
533547
---
534-
# Bad Task
548+
# My Task
535549
536-
This task is missing task_name in frontmatter.
550+
This file uses the filename as task_name.
537551
`
538552
if err := os.WriteFile(taskFile, []byte(taskContent), 0o644); err != nil {
539-
t.Fatalf("failed to write task file: %v", err)
553+
t.Fatalf("failed to write file: %v", err)
540554
}
541555

542-
// Run the program - should fail with an error
543-
output, err := runToolWithError("-C", tmpDir, "bad-task")
544-
if err == nil {
545-
t.Fatalf("expected program to fail, but it succeeded")
546-
}
556+
// Run the program - should succeed using filename as task name
557+
output := runTool(t, "-C", tmpDir, "my-task")
547558

548-
// Check that error message mentions missing task_name
549-
if !strings.Contains(output, "missing required 'task_name' field in frontmatter") {
550-
t.Errorf("expected error about missing task_name, got: %s", output)
559+
// Check that task content is present
560+
if !strings.Contains(output, "# My Task") {
561+
t.Errorf("task content not found in stdout")
562+
}
563+
if !strings.Contains(output, "This file uses the filename as task_name.") {
564+
t.Errorf("task description not found in stdout")
551565
}
552566
}
553567

main.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,19 @@ func (cc *codingContext) taskFileWalker(taskName string) func(path string, info
177177
return fmt.Errorf("failed to parse task file %s: %w", path, err)
178178
}
179179

180-
// Check if task_name is present in frontmatter
181-
if _, hasTaskName := frontmatter["task_name"]; !hasTaskName {
182-
return fmt.Errorf("task file %s is missing required 'task_name' field in frontmatter", path)
180+
// Get task_name from frontmatter, or use filename without .md extension
181+
fileTaskName, hasTaskName := frontmatter["task_name"]
182+
var taskNameStr string
183+
if hasTaskName {
184+
taskNameStr = fmt.Sprint(fileTaskName)
185+
} else {
186+
// Use filename without .md extension as task name
187+
taskNameStr = strings.TrimSuffix(filepath.Base(path), ".md")
188+
}
189+
190+
// Check if this file's task name matches the requested task name
191+
if taskNameStr != taskName {
192+
return nil
183193
}
184194

185195
// Check if file matches include selectors (task_name is already in includes)

main_test.go

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -235,16 +235,16 @@ func TestFindTaskFile(t *testing.T) {
235235
errContains: "no task file found",
236236
},
237237
{
238-
name: "task missing task_name field",
239-
taskName: "my_task",
238+
name: "task without task_name uses filename",
239+
taskName: "not-a-task",
240240
setupFiles: func(t *testing.T, tmpDir string) {
241241
taskDir := filepath.Join(tmpDir, ".agents", "tasks")
242-
createMarkdownFile(t, filepath.Join(taskDir, "task.md"),
242+
// Create a file without task_name - should use filename as task name
243+
createMarkdownFile(t, filepath.Join(taskDir, "not-a-task.md"),
243244
"env: prod",
244-
"# Task without name")
245+
"# Task using filename")
245246
},
246-
wantErr: true,
247-
errContains: "missing required 'task_name' field",
247+
wantErr: false,
248248
},
249249
{
250250
name: "task file found in downloaded directory",
@@ -285,6 +285,31 @@ func TestFindTaskFile(t *testing.T) {
285285
downloadedDirs: []string{"downloaded"}, // Relative path, will be joined with tmpDir
286286
wantErr: false,
287287
},
288+
{
289+
name: "task file found in .opencode/command directory",
290+
taskName: "opencode_task",
291+
setupFiles: func(t *testing.T, tmpDir string) {
292+
taskDir := filepath.Join(tmpDir, ".opencode", "command")
293+
createMarkdownFile(t, filepath.Join(taskDir, "opencode-task.md"),
294+
"task_name: opencode_task",
295+
"# OpenCode Task")
296+
},
297+
wantErr: false,
298+
},
299+
{
300+
name: "task file found in downloaded .opencode/command directory",
301+
taskName: "opencode_remote_task",
302+
setupFiles: func(t *testing.T, tmpDir string) {
303+
// Create task file in downloaded directory's .opencode/command
304+
downloadedDir := filepath.Join(tmpDir, "downloaded")
305+
taskDir := filepath.Join(downloadedDir, ".opencode", "command")
306+
createMarkdownFile(t, filepath.Join(taskDir, "remote.md"),
307+
"task_name: opencode_remote_task",
308+
"# OpenCode Remote Task")
309+
},
310+
downloadedDirs: []string{"downloaded"}, // Relative path, will be joined with tmpDir
311+
wantErr: false,
312+
},
288313
}
289314

290315
for _, tt := range tests {
@@ -1247,13 +1272,13 @@ func TestTaskFileWalker(t *testing.T) {
12471272
errContains: "multiple task files found",
12481273
},
12491274
{
1250-
name: "task missing task_name",
1251-
taskName: "test",
1275+
name: "task without task_name uses filename",
1276+
taskName: "task",
12521277
fileInfo: fileInfoMock{isDir: false, name: "task.md"},
12531278
filePath: "task.md",
12541279
fileContent: "---\nother: value\n---\n# Task",
1255-
wantErr: true,
1256-
errContains: "missing required 'task_name' field",
1280+
expectMatch: true,
1281+
wantErr: false,
12571282
},
12581283
}
12591284

paths.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ func allTaskSearchPaths(homeDir string) []string {
66
return []string{
77
filepath.Join(".agents", "tasks"),
88
filepath.Join(".cursor", "commands"),
9+
filepath.Join(".opencode", "command"),
910
filepath.Join(homeDir, ".agents", "tasks"),
1011
}
1112
}
@@ -19,7 +20,6 @@ func allRulePaths(homeDir string) []string {
1920
".augment/rules",
2021
".windsurf/rules",
2122
".opencode/agent",
22-
".opencode/command",
2323

2424
".github/copilot-instructions.md",
2525
".gemini/styleguide.md",
@@ -58,7 +58,6 @@ func downloadedRulePaths(dir string) []string {
5858
filepath.Join(dir, ".augment", "rules"),
5959
filepath.Join(dir, ".windsurf", "rules"),
6060
filepath.Join(dir, ".opencode", "agent"),
61-
filepath.Join(dir, ".opencode", "command"),
6261
filepath.Join(dir, ".github", "copilot-instructions.md"),
6362
filepath.Join(dir, ".gemini", "styleguide.md"),
6463
filepath.Join(dir, ".github", "agents"),
@@ -75,5 +74,6 @@ func downloadedTaskSearchPaths(dir string) []string {
7574
return []string{
7675
filepath.Join(dir, ".agents", "tasks"),
7776
filepath.Join(dir, ".cursor", "commands"),
77+
filepath.Join(dir, ".opencode", "command"),
7878
}
7979
}

0 commit comments

Comments
 (0)