Skip to content

Commit 0f75fae

Browse files
authored
Merge pull request #47 from retran/feat/opencode-tool-support
feat(init): add OpenCode tool support
2 parents 87638fd + 0eab8df commit 0f75fae

File tree

10 files changed

+902
-8
lines changed

10 files changed

+902
-8
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
>
55
> **Do not edit a project with mxcli while it is open in Studio Pro.** Studio Pro maintains in-memory caches that cannot be updated externally. Close the project in Studio Pro first, run mxcli, then re-open the project.
66
7-
A command-line tool that enables AI coding assistants ([Claude Code](https://claude.ai/claude-code), Cursor, Continue.dev, Windsurf, Aider, and others) to read, understand, and modify Mendix application projects.
7+
A command-line tool that enables AI coding assistants ([Claude Code](https://claude.ai/claude-code), OpenCode, Cursor, Continue.dev, Windsurf, Aider, and others) to read, understand, and modify Mendix application projects.
88

99
**[Read the documentation](https://mendixlabs.github.io/mxcli/)** | **[Try it in the Playground](https://codespaces.new/mendixlabs/mxcli-playground)** -- no install needed, runs in your browser
1010

@@ -125,6 +125,7 @@ claude # or use Cursor, Continue.dev, etc.
125125
| Tool | Config File | Description |
126126
|------|------------|-------------|
127127
| **Claude Code** | `.claude/`, `CLAUDE.md` | Full integration with skills and commands |
128+
| **OpenCode** | `.opencode/`, `opencode.json` | Skills, commands, and lint rules |
128129
| **Cursor** | `.cursorrules` | Compact MDL reference and command guide |
129130
| **Continue.dev** | `.continue/config.json` | Custom commands and slash commands |
130131
| **Windsurf** | `.windsurfrules` | Codeium's AI with MDL rules |

cmd/mxcli/cmd_add_tool.go

Lines changed: 98 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
)
@@ -34,6 +36,7 @@ Supported Tools:
3436
- continue Continue.dev with custom commands
3537
- windsurf Windsurf (Codeium) with MDL rules
3638
- aider Aider with project configuration
39+
- opencode OpenCode AI agent with MDL commands and skills
3740
`,
3841
Args: cobra.RangeArgs(0, 2),
3942
Run: func(cmd *cobra.Command, args []string) {
@@ -131,6 +134,101 @@ Supported Tools:
131134
fmt.Println(" Run 'mxcli init' first to create universal documentation.")
132135
}
133136

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+
134232
fmt.Println("\n✓ Tool support added!")
135233
fmt.Printf("\nNext steps:\n")
136234
fmt.Printf(" 1. Open project in %s\n", toolConfig.Name)

cmd/mxcli/init.go

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Supported Tools:
5252
- continue Continue.dev with custom commands
5353
- windsurf Windsurf (Codeium) with MDL rules
5454
- aider Aider with project configuration
55+
- opencode OpenCode AI agent with MDL commands and skills
5556
5657
All tools receive universal documentation in AGENTS.md and .ai-context/
5758
`,
@@ -143,6 +144,35 @@ All tools receive universal documentation in AGENTS.md and .ai-context/
143144
}
144145
}
145146

147+
// Create .opencode directory for OpenCode-specific content (if OpenCode is selected)
148+
var opencodeCommandsDir, opencodeSkillsDir string
149+
if slices.Contains(tools, "opencode") {
150+
opencodeDir := filepath.Join(absDir, ".opencode")
151+
opencodeCommandsDir = filepath.Join(opencodeDir, "commands")
152+
opencodeSkillsDir = filepath.Join(opencodeDir, "skills")
153+
154+
if err := os.MkdirAll(opencodeCommandsDir, 0755); err != nil {
155+
fmt.Fprintf(os.Stderr, "Error creating .opencode/commands directory: %v\n", err)
156+
os.Exit(1)
157+
}
158+
if err := os.MkdirAll(opencodeSkillsDir, 0755); err != nil {
159+
fmt.Fprintf(os.Stderr, "Error creating .opencode/skills directory: %v\n", err)
160+
os.Exit(1)
161+
}
162+
163+
// Lint rules stay in .claude/lint-rules/ (read by mxcli lint).
164+
// Ensure that directory exists even when claude tool is not selected.
165+
if !slices.Contains(tools, "claude") {
166+
if lintRulesDir == "" {
167+
lintRulesDir = filepath.Join(absDir, ".claude", "lint-rules")
168+
}
169+
if err := os.MkdirAll(lintRulesDir, 0755); err != nil {
170+
fmt.Fprintf(os.Stderr, "Error creating .claude/lint-rules directory: %v\n", err)
171+
os.Exit(1)
172+
}
173+
}
174+
}
175+
146176
// Write universal skills to .ai-context/skills/
147177
skillCount := 0
148178
err = fs.WalkDir(skillsFS, "skills", func(path string, d fs.DirEntry, err error) error {
@@ -254,6 +284,102 @@ All tools receive universal documentation in AGENTS.md and .ai-context/
254284
fmt.Printf(" Created %d lint rule files in .claude/lint-rules/\n", lintRuleCount)
255285
}
256286
}
287+
288+
// OpenCode-specific: write commands, lint rules, and skills
289+
if toolName == "opencode" && opencodeCommandsDir != "" {
290+
cmdCount := 0
291+
err = fs.WalkDir(commandsFS, "commands", func(path string, d fs.DirEntry, err error) error {
292+
if err != nil {
293+
return err
294+
}
295+
if d.IsDir() {
296+
return nil
297+
}
298+
content, err := commandsFS.ReadFile(path)
299+
if err != nil {
300+
return err
301+
}
302+
targetPath := filepath.Join(opencodeCommandsDir, d.Name())
303+
if err := os.WriteFile(targetPath, content, 0644); err != nil {
304+
return err
305+
}
306+
cmdCount++
307+
return nil
308+
})
309+
if err != nil {
310+
fmt.Fprintf(os.Stderr, " Error writing OpenCode commands: %v\n", err)
311+
} else {
312+
fmt.Printf(" Created %d command files in .opencode/commands/\n", cmdCount)
313+
}
314+
315+
lintRuleCount := 0
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++
336+
return nil
337+
})
338+
if err != nil {
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)
342+
}
343+
}
344+
345+
skillCount2 := 0
346+
err = fs.WalkDir(skillsFS, "skills", func(path string, d fs.DirEntry, err error) error {
347+
if err != nil {
348+
return err
349+
}
350+
if d.IsDir() {
351+
return nil
352+
}
353+
// Skip README
354+
if d.Name() == "README.md" {
355+
return nil
356+
}
357+
content, err := skillsFS.ReadFile(path)
358+
if err != nil {
359+
return err
360+
}
361+
// Derive skill name from filename (strip .md)
362+
skillName := strings.TrimSuffix(d.Name(), ".md")
363+
// Create per-skill subdirectory
364+
skillDir := filepath.Join(opencodeSkillsDir, skillName)
365+
if err := os.MkdirAll(skillDir, 0755); err != nil {
366+
return err
367+
}
368+
// Wrap content with OpenCode frontmatter
369+
wrapped := wrapSkillContent(skillName, content)
370+
targetPath := filepath.Join(skillDir, "SKILL.md")
371+
if err := os.WriteFile(targetPath, wrapped, 0644); err != nil {
372+
return err
373+
}
374+
skillCount2++
375+
return nil
376+
})
377+
if err != nil {
378+
fmt.Fprintf(os.Stderr, " Error writing OpenCode skills: %v\n", err)
379+
} else {
380+
fmt.Printf(" Created %d skill directories in .opencode/skills/\n", skillCount2)
381+
}
382+
}
257383
}
258384

259385
// Write universal AGENTS.md
@@ -312,8 +438,8 @@ All tools receive universal documentation in AGENTS.md and .ai-context/
312438
}
313439
}
314440

315-
// Install VS Code extension if Claude is selected
316-
if slices.Contains(tools, "claude") {
441+
// Install VS Code extension if Claude or OpenCode is selected
442+
if slices.Contains(tools, "claude") || slices.Contains(tools, "opencode") {
317443
installVSCodeExtension(absDir)
318444
}
319445

@@ -340,6 +466,41 @@ All tools receive universal documentation in AGENTS.md and .ai-context/
340466
},
341467
}
342468

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+
478+
// wrapSkillContent prepends OpenCode-compatible YAML frontmatter to a skill file.
479+
// OpenCode requires each skill to live in its own subdirectory as SKILL.md and
480+
// the file must start with YAML frontmatter containing name, description, and
481+
// compatibility fields.
482+
func wrapSkillContent(skillName string, content []byte) []byte {
483+
description := extractSkillDescription(content)
484+
frontmatter := fmt.Sprintf("---\nname: %s\ndescription: %s\ncompatibility: opencode\n---\n\n", yamlSingleQuote(skillName), yamlSingleQuote(description))
485+
return append([]byte(frontmatter), content...)
486+
}
487+
488+
// extractSkillDescription returns a one-line description for the skill by
489+
// finding the first top-level markdown heading (# ...) and stripping a leading
490+
// "Skill: " prefix if present. Falls back to "MDL skill" if no heading is
491+
// found.
492+
func extractSkillDescription(content []byte) string {
493+
for _, line := range strings.Split(string(content), "\n") {
494+
line = strings.TrimSpace(line)
495+
if strings.HasPrefix(line, "# ") {
496+
desc := strings.TrimPrefix(line, "# ")
497+
desc = strings.TrimPrefix(desc, "Skill: ")
498+
return strings.TrimSpace(desc)
499+
}
500+
}
501+
return "MDL skill"
502+
}
503+
343504
func findMprFile(dir string) string {
344505
entries, err := os.ReadDir(dir)
345506
if err != nil {
@@ -1000,7 +1161,7 @@ func init() {
10001161
rootCmd.AddCommand(initCmd)
10011162

10021163
// Add flags for tool selection
1003-
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)")
10041165
initCmd.Flags().BoolVar(&initAllTools, "all-tools", false, "Initialize for all supported AI tools")
10051166
initCmd.Flags().BoolVar(&initListTools, "list-tools", false, "List supported AI tools and exit")
10061167
}

0 commit comments

Comments
 (0)