Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
>
> **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.

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.
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.

**[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

Expand Down Expand Up @@ -125,6 +125,7 @@ claude # or use Cursor, Continue.dev, etc.
| Tool | Config File | Description |
|------|------------|-------------|
| **Claude Code** | `.claude/`, `CLAUDE.md` | Full integration with skills and commands |
| **OpenCode** | `.opencode/`, `opencode.json` | Skills, commands, and lint rules |
| **Cursor** | `.cursorrules` | Compact MDL reference and command guide |
| **Continue.dev** | `.continue/config.json` | Custom commands and slash commands |
| **Windsurf** | `.windsurfrules` | Codeium's AI with MDL rules |
Expand Down
1 change: 1 addition & 0 deletions cmd/mxcli/cmd_add_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Supported Tools:
- continue Continue.dev with custom commands
- windsurf Windsurf (Codeium) with MDL rules
- aider Aider with project configuration
- opencode OpenCode AI agent with MDL commands and skills
`,
Args: cobra.RangeArgs(0, 2),
Run: func(cmd *cobra.Command, args []string) {
Comment thread
retran marked this conversation as resolved.
Expand Down
151 changes: 149 additions & 2 deletions cmd/mxcli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Supported Tools:
- continue Continue.dev with custom commands
- windsurf Windsurf (Codeium) with MDL rules
- aider Aider with project configuration
- opencode OpenCode AI agent with MDL commands and skills

Comment thread
retran marked this conversation as resolved.
All tools receive universal documentation in AGENTS.md and .ai-context/
`,
Expand Down Expand Up @@ -143,6 +144,35 @@ All tools receive universal documentation in AGENTS.md and .ai-context/
}
}

// Create .opencode directory for OpenCode-specific content (if OpenCode is selected)
var opencodeCommandsDir, opencodeSkillsDir string
if slices.Contains(tools, "opencode") {
opencodeDir := filepath.Join(absDir, ".opencode")
opencodeCommandsDir = filepath.Join(opencodeDir, "commands")
opencodeSkillsDir = filepath.Join(opencodeDir, "skills")

if err := os.MkdirAll(opencodeCommandsDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating .opencode/commands directory: %v\n", err)
os.Exit(1)
}
if err := os.MkdirAll(opencodeSkillsDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating .opencode/skills directory: %v\n", err)
os.Exit(1)
}

// Lint rules stay in .claude/lint-rules/ (read by mxcli lint).
// Ensure that directory exists even when claude tool is not selected.
if !slices.Contains(tools, "claude") {
if lintRulesDir == "" {
lintRulesDir = filepath.Join(absDir, ".claude", "lint-rules")
}
if err := os.MkdirAll(lintRulesDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating .claude/lint-rules directory: %v\n", err)
os.Exit(1)
}
}
}

// Write universal skills to .ai-context/skills/
skillCount := 0
err = fs.WalkDir(skillsFS, "skills", func(path string, d fs.DirEntry, err error) error {
Expand Down Expand Up @@ -254,6 +284,97 @@ All tools receive universal documentation in AGENTS.md and .ai-context/
fmt.Printf(" Created %d lint rule files in .claude/lint-rules/\n", lintRuleCount)
}
}

// OpenCode-specific: write commands, lint rules, and skills
if toolName == "opencode" && opencodeCommandsDir != "" {
cmdCount := 0
err = fs.WalkDir(commandsFS, "commands", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
content, err := commandsFS.ReadFile(path)
if err != nil {
return err
}
targetPath := filepath.Join(opencodeCommandsDir, d.Name())
if err := os.WriteFile(targetPath, content, 0644); err != nil {
return err
}
cmdCount++
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, " Error writing OpenCode commands: %v\n", err)
} else {
fmt.Printf(" Created %d command files in .opencode/commands/\n", cmdCount)
}

lintRuleCount := 0
err = fs.WalkDir(lintRulesFS, "lint-rules", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
content, err := lintRulesFS.ReadFile(path)
if err != nil {
return err
}
targetPath := filepath.Join(lintRulesDir, d.Name())
if err := os.WriteFile(targetPath, content, 0644); err != nil {
return err
}
lintRuleCount++
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, " Error writing lint rules: %v\n", err)
} else {
fmt.Printf(" Created %d lint rule files in .claude/lint-rules/\n", lintRuleCount)
Comment thread
retran marked this conversation as resolved.
Outdated
}

skillCount2 := 0
err = fs.WalkDir(skillsFS, "skills", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Skip README
if d.Name() == "README.md" {
return nil
}
content, err := skillsFS.ReadFile(path)
if err != nil {
return err
}
// Derive skill name from filename (strip .md)
skillName := strings.TrimSuffix(d.Name(), ".md")
// Create per-skill subdirectory
skillDir := filepath.Join(opencodeSkillsDir, skillName)
if err := os.MkdirAll(skillDir, 0755); err != nil {
return err
}
// Wrap content with OpenCode frontmatter
wrapped := wrapSkillContent(skillName, content)
targetPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(targetPath, wrapped, 0644); err != nil {
return err
}
skillCount2++
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, " Error writing OpenCode skills: %v\n", err)
} else {
fmt.Printf(" Created %d skill directories in .opencode/skills/\n", skillCount2)
}
}
}

// Write universal AGENTS.md
Expand Down Expand Up @@ -312,8 +433,8 @@ All tools receive universal documentation in AGENTS.md and .ai-context/
}
}

// Install VS Code extension if Claude is selected
if slices.Contains(tools, "claude") {
// Install VS Code extension if Claude or OpenCode is selected
if slices.Contains(tools, "claude") || slices.Contains(tools, "opencode") {
installVSCodeExtension(absDir)
}

Expand All @@ -340,6 +461,32 @@ All tools receive universal documentation in AGENTS.md and .ai-context/
},
}

// wrapSkillContent prepends OpenCode-compatible YAML frontmatter to a skill file.
// OpenCode requires each skill to live in its own subdirectory as SKILL.md and
// the file must start with YAML frontmatter containing name, description, and
// compatibility fields.
func wrapSkillContent(skillName string, content []byte) []byte {
description := extractSkillDescription(content)
frontmatter := fmt.Sprintf("---\nname: %s\ndescription: %s\ncompatibility: opencode\n---\n\n", skillName, description)
return append([]byte(frontmatter), content...)
Comment thread
retran marked this conversation as resolved.
}

// extractSkillDescription returns a one-line description for the skill by
// finding the first top-level markdown heading (# ...) and stripping a leading
// "Skill: " prefix if present. Falls back to the skill name if no heading is
// found.
func extractSkillDescription(content []byte) string {
for _, line := range strings.Split(string(content), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# ") {
desc := strings.TrimPrefix(line, "# ")
desc = strings.TrimPrefix(desc, "Skill: ")
return strings.TrimSpace(desc)
}
}
return "MDL skill"
Comment thread
retran marked this conversation as resolved.
}

func findMprFile(dir string) string {
entries, err := os.ReadDir(dir)
if err != nil {
Expand Down
Loading
Loading