// Package opencode implements the Gortex init integration for // OpenCode. OpenCode reads its project config from `opencode.json` or // `opencode.jsonc` at the repo root (and `~/.config/opencode/` for the // global config); it does NOT read `.opencode/config.json` — Gortex // wrote there historically and OpenCode silently ignored it, so the MCP // server was never registered. We now write the MCP stanza to a root // `opencode.json` (or merge into an existing `opencode.json` / // `opencode.jsonc`). // // OpenCode uses a different MCP schema than the canonical // Claude / Cursor shape: // // { // "$schema": "https://opencode.ai/config.json", // "mcp": { // "gortex": { // "type": "local", // "command": ["gortex", "mcp", ...], // "environment": {...}, // "enabled": true // } // } // } // // We preserve any existing $schema reference and add one if absent. package opencode import ( "os" "os/exec" "path/filepath" "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/agents/internalutil" ) const Name = "opencode" const DocsURL = "https://opencode.ai/docs/mcp" const SchemaURL = "https://opencode.ai/config.json" type Adapter struct{} func New() *Adapter { return &Adapter{} } func (a *Adapter) Name() string { return Name } func (a *Adapter) DocsURL() string { return DocsURL } func (a *Adapter) Detect(env agents.Env) (bool, error) { if _, err := os.Stat(filepath.Join(env.Root, ".opencode")); err == nil { return true, nil } if p, err := exec.LookPath("opencode"); err == nil && p != "" { return true, nil } if env.Home != "" { if _, err := os.Stat(filepath.Join(env.Home, ".config", "opencode")); err == nil { return true, nil } } return false, nil } // projectConfigPath resolves the file the MCP stanza should be written // into. OpenCode reads `opencode.json` / `opencode.jsonc` from the repo // root, so we merge into an existing one (preferring `.jsonc`, since a // hand-authored, comment-bearing config keeps its extension) and // otherwise default to a fresh `opencode.json`. func projectConfigPath(root string) string { for _, name := range []string{"opencode.jsonc", "opencode.json"} { p := filepath.Join(root, name) if _, err := os.Stat(p); err == nil { return p } } return filepath.Join(root, "opencode.json") } func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) { p := &agents.Plan{Files: []agents.FileAction{ {Path: projectConfigPath(env.Root), Action: agents.ActionWouldMerge, Keys: []string{"mcp"}}, }} if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" { p.Files = append(p.Files, agents.FileAction{ Path: filepath.Join(env.Root, "AGENTS.md"), Action: agents.ActionWouldMerge, Keys: []string{"communities-block"}, }) } return p, nil } func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) { res := &agents.Result{Name: Name, DocsURL: DocsURL} if env.Mode == agents.ModeGlobal { return res, nil } detected, _ := a.Detect(env) res.Detected = detected if !detected && !opts.ForceDetect { internalutil.Logf(env.Stderr, "[gortex init] skip OpenCode setup (OpenCode not detected)") return res, nil } internalutil.Logf(env.Stderr, "[gortex init] setting up OpenCode integration...") path := projectConfigPath(env.Root) // Gortex used to write `.opencode/config.json`, which OpenCode does // not read. If that stale file is still around, point the user at // the config we're actually writing now. if legacy := filepath.Join(env.Root, ".opencode", "config.json"); legacy != path { if _, err := os.Stat(legacy); err == nil { internalutil.Logf(env.Stderr, "[gortex init] note: %s is no longer read by OpenCode; writing the MCP config to %s instead", legacy, filepath.Base(path)) } } action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) { mcpSection, ok := root["mcp"].(map[string]any) if !ok { mcpSection = make(map[string]any) } if _, exists := mcpSection["gortex"]; exists && !opts.Force { return false, nil } mcpSection["gortex"] = map[string]any{ "type": "local", "command": []string{"gortex", "mcp"}, "environment": map[string]string{ "GORTEX_INDEX_WORKERS": "8", }, "enabled": true, } root["mcp"] = mcpSection if _, hasSchema := root["$schema"]; !hasSchema { root["$schema"] = SchemaURL } return true, nil }, opts) if err != nil { return res, err } res.Files = append(res.Files, action) // AGENTS.md gets a marker-guarded community-routing block when // skills were generated (--skills, default on in `gortex init`). // The codex adapter targets the same file with the same markers // so a repo running both adapters converges on one block. if env.SkillsRouting != "" { agentsMdPath := filepath.Join(env.Root, "AGENTS.md") routingAction, err := agents.UpsertMarkedBlock(env.Stderr, agentsMdPath, env.SkillsRouting, agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts) if err != nil { return res, err } res.Files = append(res.Files, routingAction) } res.Configured = true return res, nil }