Skip to content

Commit 0eab8df

Browse files
committed
fix(init): address PR review comments
- Add opencode to --tool flag help text - Fix extractSkillDescription comment (returns 'MDL skill', not the skill name) - Add yamlSingleQuote() helper; use it in wrapSkillContent to safely escape skill names and descriptions in YAML frontmatter - Update init_test.go assertions to match new single-quoted frontmatter format - Fix opencode.json doc example to match generateOpenCodeConfig actual output - Remove false panic claim from runInit test helper comment - Wire full OpenCode sidecar into cmd_add_tool.go (commands, lint-rules, skills) - Guard OpenCode lint-rule write with slices.Contains(tools, "claude") to prevent duplicate writes when both tools are selected or --all-tools is used - Capture and handle fs.WalkDir errors in cmd_add_tool.go OpenCode sidecar - Move success message in cmd_add_tool.go to after the OpenCode sidecar block - Add .opencode/skills/**/SKILL.md to generateOpenCodeConfig instructions - Update opencode.md doc example and prose to match generated config - Rename other-ai-tools.md heading to '# Other AI tools' - Fix stale link text in opencode.md to match renamed heading - Nil out vsixData in runInit test helper to prevent external CLI side effects - Fix lint-rule guard to use resolved 'tools' slice instead of 'initTools'
1 parent 7cf3bb0 commit 0eab8df

File tree

7 files changed

+221
-39
lines changed

7 files changed

+221
-39
lines changed

cmd/mxcli/cmd_add_tool.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ package main
55

66
import (
77
"fmt"
8+
"io/fs"
89
"os"
910
"path/filepath"
11+
"strings"
1012

1113
"github.com/spf13/cobra"
1214
)
@@ -132,6 +134,101 @@ Supported Tools:
132134
fmt.Println(" Run 'mxcli init' first to create universal documentation.")
133135
}
134136

137+
// OpenCode sidecar: commands, skills, lint-rules (same as mxcli init)
138+
if toolName == "opencode" {
139+
opencodeDir := filepath.Join(absDir, ".opencode")
140+
opencodeCommandsDir := filepath.Join(opencodeDir, "commands")
141+
opencodeSkillsDir := filepath.Join(opencodeDir, "skills")
142+
lintRulesDir := filepath.Join(absDir, ".claude", "lint-rules")
143+
144+
for _, dir := range []string{opencodeCommandsDir, opencodeSkillsDir, lintRulesDir} {
145+
if err := os.MkdirAll(dir, 0755); err != nil {
146+
fmt.Fprintf(os.Stderr, " Error creating directory %s: %v\n", dir, err)
147+
}
148+
}
149+
150+
cmdCount := 0
151+
if err := fs.WalkDir(commandsFS, "commands", func(path string, d fs.DirEntry, err error) error {
152+
if err != nil || d.IsDir() {
153+
return err
154+
}
155+
content, err := commandsFS.ReadFile(path)
156+
if err != nil {
157+
return err
158+
}
159+
targetPath := filepath.Join(opencodeCommandsDir, d.Name())
160+
if _, statErr := os.Stat(targetPath); statErr == nil {
161+
return nil // skip existing
162+
}
163+
if err := os.WriteFile(targetPath, content, 0644); err != nil {
164+
return err
165+
}
166+
cmdCount++
167+
return nil
168+
}); err != nil {
169+
fmt.Fprintf(os.Stderr, " Error writing OpenCode commands: %v\n", err)
170+
} else if cmdCount > 0 {
171+
fmt.Printf(" Created %d command files in .opencode/commands/\n", cmdCount)
172+
}
173+
174+
lintCount := 0
175+
if err := fs.WalkDir(lintRulesFS, "lint-rules", func(path string, d fs.DirEntry, err error) error {
176+
if err != nil || d.IsDir() {
177+
return err
178+
}
179+
content, err := lintRulesFS.ReadFile(path)
180+
if err != nil {
181+
return err
182+
}
183+
targetPath := filepath.Join(lintRulesDir, d.Name())
184+
if _, statErr := os.Stat(targetPath); statErr == nil {
185+
return nil // skip existing
186+
}
187+
if err := os.WriteFile(targetPath, content, 0644); err != nil {
188+
return err
189+
}
190+
lintCount++
191+
return nil
192+
}); err != nil {
193+
fmt.Fprintf(os.Stderr, " Error writing lint rules: %v\n", err)
194+
} else if lintCount > 0 {
195+
fmt.Printf(" Created %d lint rule files in .claude/lint-rules/\n", lintCount)
196+
}
197+
198+
skillCount := 0
199+
if err := fs.WalkDir(skillsFS, "skills", func(path string, d fs.DirEntry, err error) error {
200+
if err != nil || d.IsDir() {
201+
return err
202+
}
203+
if d.Name() == "README.md" {
204+
return nil
205+
}
206+
content, err := skillsFS.ReadFile(path)
207+
if err != nil {
208+
return err
209+
}
210+
skillName := strings.TrimSuffix(d.Name(), ".md")
211+
skillDir := filepath.Join(opencodeSkillsDir, skillName)
212+
if err := os.MkdirAll(skillDir, 0755); err != nil {
213+
return err
214+
}
215+
targetPath := filepath.Join(skillDir, "SKILL.md")
216+
if _, statErr := os.Stat(targetPath); statErr == nil {
217+
return nil // skip existing
218+
}
219+
wrapped := wrapSkillContent(skillName, content)
220+
if err := os.WriteFile(targetPath, wrapped, 0644); err != nil {
221+
return err
222+
}
223+
skillCount++
224+
return nil
225+
}); err != nil {
226+
fmt.Fprintf(os.Stderr, " Error writing OpenCode skills: %v\n", err)
227+
} else if skillCount > 0 {
228+
fmt.Printf(" Created %d skill directories in .opencode/skills/\n", skillCount)
229+
}
230+
}
231+
135232
fmt.Println("\n✓ Tool support added!")
136233
fmt.Printf("\nNext steps:\n")
137234
fmt.Printf(" 1. Open project in %s\n", toolConfig.Name)

cmd/mxcli/init.go

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -313,28 +313,33 @@ All tools receive universal documentation in AGENTS.md and .ai-context/
313313
}
314314

315315
lintRuleCount := 0
316-
err = fs.WalkDir(lintRulesFS, "lint-rules", func(path string, d fs.DirEntry, err error) error {
317-
if err != nil {
318-
return err
319-
}
320-
if d.IsDir() {
316+
// Only write lint rules from the OpenCode path when Claude is not also
317+
// being initialised — the Claude path already writes the same files to
318+
// .claude/lint-rules/ and we don't want duplicate log output or writes.
319+
if !slices.Contains(tools, "claude") {
320+
err = fs.WalkDir(lintRulesFS, "lint-rules", func(path string, d fs.DirEntry, err error) error {
321+
if err != nil {
322+
return err
323+
}
324+
if d.IsDir() {
325+
return nil
326+
}
327+
content, err := lintRulesFS.ReadFile(path)
328+
if err != nil {
329+
return err
330+
}
331+
targetPath := filepath.Join(lintRulesDir, d.Name())
332+
if err := os.WriteFile(targetPath, content, 0644); err != nil {
333+
return err
334+
}
335+
lintRuleCount++
321336
return nil
322-
}
323-
content, err := lintRulesFS.ReadFile(path)
337+
})
324338
if err != nil {
325-
return err
339+
fmt.Fprintf(os.Stderr, " Error writing lint rules: %v\n", err)
340+
} else {
341+
fmt.Printf(" Created %d lint rule files in .claude/lint-rules/\n", lintRuleCount)
326342
}
327-
targetPath := filepath.Join(lintRulesDir, d.Name())
328-
if err := os.WriteFile(targetPath, content, 0644); err != nil {
329-
return err
330-
}
331-
lintRuleCount++
332-
return nil
333-
})
334-
if err != nil {
335-
fmt.Fprintf(os.Stderr, " Error writing lint rules: %v\n", err)
336-
} else {
337-
fmt.Printf(" Created %d lint rule files in .claude/lint-rules/\n", lintRuleCount)
338343
}
339344

340345
skillCount2 := 0
@@ -461,19 +466,28 @@ All tools receive universal documentation in AGENTS.md and .ai-context/
461466
},
462467
}
463468

469+
// yamlSingleQuote wraps s in YAML single quotes and escapes any internal
470+
// single quotes by doubling them, so the result is safe to embed in a YAML
471+
// value without further quoting.
472+
func yamlSingleQuote(s string) string {
473+
s = strings.ReplaceAll(s, "\n", " ")
474+
s = strings.ReplaceAll(s, "'", "''")
475+
return "'" + s + "'"
476+
}
477+
464478
// wrapSkillContent prepends OpenCode-compatible YAML frontmatter to a skill file.
465479
// OpenCode requires each skill to live in its own subdirectory as SKILL.md and
466480
// the file must start with YAML frontmatter containing name, description, and
467481
// compatibility fields.
468482
func wrapSkillContent(skillName string, content []byte) []byte {
469483
description := extractSkillDescription(content)
470-
frontmatter := fmt.Sprintf("---\nname: %s\ndescription: %s\ncompatibility: opencode\n---\n\n", skillName, description)
484+
frontmatter := fmt.Sprintf("---\nname: %s\ndescription: %s\ncompatibility: opencode\n---\n\n", yamlSingleQuote(skillName), yamlSingleQuote(description))
471485
return append([]byte(frontmatter), content...)
472486
}
473487

474488
// extractSkillDescription returns a one-line description for the skill by
475489
// finding the first top-level markdown heading (# ...) and stripping a leading
476-
// "Skill: " prefix if present. Falls back to the skill name if no heading is
490+
// "Skill: " prefix if present. Falls back to "MDL skill" if no heading is
477491
// found.
478492
func extractSkillDescription(content []byte) string {
479493
for _, line := range strings.Split(string(content), "\n") {
@@ -1147,7 +1161,7 @@ func init() {
11471161
rootCmd.AddCommand(initCmd)
11481162

11491163
// Add flags for tool selection
1150-
initCmd.Flags().StringSliceVar(&initTools, "tool", []string{}, "AI tool(s) to configure (claude, cursor, continue, windsurf, aider)")
1164+
initCmd.Flags().StringSliceVar(&initTools, "tool", []string{}, "AI tool(s) to configure (claude, opencode, cursor, continue, windsurf, aider)")
11511165
initCmd.Flags().BoolVar(&initAllTools, "all-tools", false, "Initialize for all supported AI tools")
11521166
initCmd.Flags().BoolVar(&initListTools, "list-tools", false, "List supported AI tools and exit")
11531167
}

cmd/mxcli/init_test.go

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,27 @@
33
package main
44

55
import (
6+
"io"
67
"os"
78
"path/filepath"
89
"strings"
910
"testing"
1011
)
1112

1213
// runInit is a test helper that sets initTools to the given list, invokes the
13-
// cobra Run closure against dir, then restores the original value. The
14-
// function panics if initListTools or initAllTools are left non-default from a
15-
// previous test.
14+
// cobra Run closure against dir, then restores the original values.
15+
// vsixData is set to nil during the call to prevent installVSCodeExtension from
16+
// invoking the external 'code' CLI or writing .vsix files on CI / dev machines.
1617
func runInit(t *testing.T, tools []string, dir string) {
1718
t.Helper()
18-
prev := initTools
19-
t.Cleanup(func() { initTools = prev })
19+
prevTools := initTools
20+
prevVsix := vsixData
21+
t.Cleanup(func() {
22+
initTools = prevTools
23+
vsixData = prevVsix
24+
})
2025
initTools = tools
26+
vsixData = nil // disable VS Code extension installation during tests
2127
initCmd.Run(initCmd, []string{dir})
2228
}
2329

@@ -72,11 +78,11 @@ func TestWrapSkillContent_FrontmatterPresent(t *testing.T) {
7278
if !strings.HasPrefix(text, "---\n") {
7379
t.Errorf("wrapped content should start with '---\\n', got: %q", text[:min(40, len(text))])
7480
}
75-
if !strings.Contains(text, "name: my-skill\n") {
76-
t.Error("frontmatter should contain 'name: my-skill'")
81+
if !strings.Contains(text, "name: 'my-skill'\n") {
82+
t.Error("frontmatter should contain 'name: 'my-skill''")
7783
}
78-
if !strings.Contains(text, "description: My Skill\n") {
79-
t.Error("frontmatter should contain 'description: My Skill'")
84+
if !strings.Contains(text, "description: 'My Skill'\n") {
85+
t.Error("frontmatter should contain 'description: 'My Skill''")
8086
}
8187
if !strings.Contains(text, "compatibility: opencode\n") {
8288
t.Error("frontmatter should contain 'compatibility: opencode'")
@@ -217,8 +223,8 @@ func TestInitOpenCode_EachSkillHasValidFrontmatter(t *testing.T) {
217223
if !strings.HasPrefix(text, "---\n") {
218224
t.Errorf("skill %q: SKILL.md should start with YAML frontmatter '---'", e.Name())
219225
}
220-
if !strings.Contains(text, "name: "+e.Name()) {
221-
t.Errorf("skill %q: SKILL.md frontmatter should contain 'name: %s'", e.Name(), e.Name())
226+
if !strings.Contains(text, "name: '"+e.Name()+"'") {
227+
t.Errorf("skill %q: SKILL.md frontmatter should contain 'name: '%s''", e.Name(), e.Name())
222228
}
223229
if !strings.Contains(text, "compatibility: opencode") {
224230
t.Errorf("skill %q: SKILL.md should contain 'compatibility: opencode'", e.Name())
@@ -334,3 +340,63 @@ func TestInitBothTools_CreatesAllFiles(t *testing.T) {
334340
t.Error(".claude/lint-rules/ should contain lint rule files")
335341
}
336342
}
343+
344+
// runInitAllTools is like runInit but exercises the --all-tools path.
345+
func runInitAllTools(t *testing.T, dir string) {
346+
t.Helper()
347+
prevTools := initTools
348+
prevAll := initAllTools
349+
prevVsix := vsixData
350+
t.Cleanup(func() {
351+
initTools = prevTools
352+
initAllTools = prevAll
353+
vsixData = prevVsix
354+
})
355+
initTools = []string{}
356+
initAllTools = true
357+
vsixData = nil
358+
initCmd.Run(initCmd, []string{dir})
359+
}
360+
361+
func TestInitAllTools_CreatesAllFilesWithoutDuplicateLintRules(t *testing.T) {
362+
dir := t.TempDir()
363+
// Capture stdout to count lint-rule log lines.
364+
origStdout := os.Stdout
365+
r, w, _ := os.Pipe()
366+
os.Stdout = w
367+
368+
runInitAllTools(t, dir)
369+
370+
w.Close()
371+
os.Stdout = origStdout
372+
outBytes, _ := io.ReadAll(r)
373+
output := string(outBytes)
374+
375+
// Claude artifacts
376+
if !fileExists(filepath.Join(dir, "CLAUDE.md")) {
377+
t.Error("CLAUDE.md should exist with --all-tools")
378+
}
379+
if !fileExists(filepath.Join(dir, ".claude", "settings.json")) {
380+
t.Error(".claude/settings.json should exist with --all-tools")
381+
}
382+
if n := countFilesInDir(filepath.Join(dir, ".claude", "commands")); n == 0 {
383+
t.Error(".claude/commands/ should contain command files with --all-tools")
384+
}
385+
386+
// OpenCode artifacts
387+
if !fileExists(filepath.Join(dir, "opencode.json")) {
388+
t.Error("opencode.json should exist with --all-tools")
389+
}
390+
if n := countSubDirs(filepath.Join(dir, ".opencode", "skills")); n == 0 {
391+
t.Error(".opencode/skills/ should contain skill dirs with --all-tools")
392+
}
393+
394+
// Lint rules must exist and the creation message must appear exactly once.
395+
if n := countFilesInDir(filepath.Join(dir, ".claude", "lint-rules")); n == 0 {
396+
t.Error(".claude/lint-rules/ should contain lint rule files with --all-tools")
397+
}
398+
lintMsgCount := strings.Count(output, "lint rule files in .claude/lint-rules/")
399+
if lintMsgCount != 1 {
400+
t.Errorf("lint rule creation message should appear exactly once, got %d:\n%s", lintMsgCount, output)
401+
}
402+
}

cmd/mxcli/tool_templates.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ func generateOpenCodeConfig(projectName, mprPath string) string {
347347
"$schema": "https://opencode.ai/config.json",
348348
"instructions": [
349349
"AGENTS.md",
350+
".opencode/skills/**/SKILL.md",
350351
".ai-context/skills/*.md"
351352
]
352353
}

docs-site/src/SUMMARY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
- [Working with AI Assistants](tutorial/ai-assistants.md)
2828
- [Claude Code Integration](tutorial/claude-code.md)
2929
- [OpenCode Integration](tutorial/opencode.md)
30-
- [Cursor / Continue.dev / Windsurf](tutorial/other-ai-tools.md)
30+
- [Other AI tools](tutorial/other-ai-tools.md)
3131
- [Skills and CLAUDE.md](tutorial/skills.md)
3232
- [The MDL + AI Workflow](tutorial/mdl-ai-workflow.md)
3333

docs-site/src/tutorial/opencode.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,16 @@ my-mendix-project/
4646

4747
### opencode.json
4848

49-
The `opencode.json` file is OpenCode's primary configuration. It points to `AGENTS.md` for instructions and to the skill files in `.opencode/skills/`:
49+
The `opencode.json` file is OpenCode's primary configuration. It points to `AGENTS.md` for instructions and to both the OpenCode-format skills in `.opencode/skills/` and the universal skill files in `.ai-context/skills/`:
5050

5151
```json
5252
{
53-
"instructions": ["AGENTS.md", ".ai-context/skills/*.md"],
54-
"model": "anthropic/claude-sonnet-4-5"
53+
"$schema": "https://opencode.ai/config.json",
54+
"instructions": [
55+
"AGENTS.md",
56+
".opencode/skills/**/SKILL.md",
57+
".ai-context/skills/*.md"
58+
]
5559
}
5660
```
5761

@@ -188,4 +192,4 @@ This creates `.opencode/`, `opencode.json`, and the lint rules without touching
188192

189193
## Next steps
190194

191-
To understand what the skill files contain and how they guide AI behavior, see [Skills and CLAUDE.md](skills.md). For other supported tools, see [Cursor / Continue.dev / Windsurf](other-ai-tools.md).
195+
To understand what the skill files contain and how they guide AI behavior, see [Skills and CLAUDE.md](skills.md). For other supported tools, see [Other AI tools (Cursor, Continue.dev, Windsurf, OpenCode)](other-ai-tools.md).

docs-site/src/tutorial/other-ai-tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Cursor / Continue.dev / Windsurf
1+
# Other AI tools
22

33
Claude Code is the default integration, but mxcli also supports OpenCode, Cursor, Continue.dev, Windsurf, and Aider. Each tool gets its own configuration file that teaches the AI about MDL syntax and mxcli commands.
44

0 commit comments

Comments
 (0)