chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/agents"
|
||||
"github.com/zzet/gortex/internal/agents/agentstest"
|
||||
)
|
||||
|
||||
// TestOpenCodeUsesMCPSectionKey verifies we write under "mcp",
|
||||
// not "mcpServers" — OpenCode's schema differs from the canonical
|
||||
// Claude / Cursor shape — and that the config lands in a root
|
||||
// `opencode.json`, the file OpenCode actually reads (not the legacy
|
||||
// `.opencode/config.json`).
|
||||
func TestOpenCodeUsesMCPSectionKey(t *testing.T) {
|
||||
env, _ := agentstest.NewEnv(t)
|
||||
// `.opencode/` is OpenCode's detection sentinel (it holds agents,
|
||||
// commands, skills) — present, but the MCP config does not live in it.
|
||||
if err := os.MkdirAll(filepath.Join(env.Root, ".opencode"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := New()
|
||||
|
||||
res, err := a.Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
// Two creates: opencode.json for MCP plus AGENTS.md for the
|
||||
// instructions block OpenCode reads on every task.
|
||||
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
|
||||
|
||||
// The MCP config must be the root opencode.json, not the ignored
|
||||
// .opencode/config.json.
|
||||
if _, err := os.Stat(filepath.Join(env.Root, ".opencode", "config.json")); err == nil {
|
||||
t.Fatalf("must not write the legacy .opencode/config.json (OpenCode ignores it)")
|
||||
}
|
||||
cfg := agentstest.ReadJSON(t, filepath.Join(env.Root, "opencode.json"))
|
||||
if _, ok := cfg["mcpServers"]; ok {
|
||||
t.Fatalf("should not write mcpServers (that's Claude/Cursor shape): %v", cfg)
|
||||
}
|
||||
mcp, ok := cfg["mcp"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing 'mcp' section: %v", cfg)
|
||||
}
|
||||
gortex, ok := mcp["gortex"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing 'mcp.gortex': %v", mcp)
|
||||
}
|
||||
// command must be an array (OpenCode-specific)
|
||||
if _, ok := gortex["command"].([]any); !ok {
|
||||
t.Fatalf("command should be an array: %v", gortex)
|
||||
}
|
||||
if cfg["$schema"] != SchemaURL {
|
||||
t.Fatalf("expected $schema=%q, got %v", SchemaURL, cfg["$schema"])
|
||||
}
|
||||
|
||||
agentstest.AssertIdempotent(t, a, env)
|
||||
}
|
||||
|
||||
// TestOpenCodeMergesIntoExistingJSON verifies that when a project
|
||||
// already has an opencode.json, we merge into it without clobbering
|
||||
// the user's own keys, rather than creating a competing file.
|
||||
func TestOpenCodeMergesIntoExistingJSON(t *testing.T) {
|
||||
env, _ := agentstest.NewEnv(t)
|
||||
cfgPath := filepath.Join(env.Root, "opencode.json")
|
||||
agentstest.WriteJSON(t, cfgPath, map[string]any{
|
||||
"$schema": SchemaURL,
|
||||
"theme": "tokyonight",
|
||||
"mcp": map[string]any{
|
||||
"other": map[string]any{
|
||||
"type": "local",
|
||||
"command": []string{"other-server"},
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
a := New()
|
||||
|
||||
if _, err := a.Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
|
||||
cfg := agentstest.ReadJSON(t, cfgPath)
|
||||
if cfg["theme"] != "tokyonight" {
|
||||
t.Fatalf("merge clobbered user's top-level key: %v", cfg)
|
||||
}
|
||||
mcp, ok := cfg["mcp"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing 'mcp' section: %v", cfg)
|
||||
}
|
||||
if _, ok := mcp["other"]; !ok {
|
||||
t.Fatalf("merge clobbered existing 'other' server: %v", mcp)
|
||||
}
|
||||
if _, ok := mcp["gortex"]; !ok {
|
||||
t.Fatalf("merge didn't add 'gortex': %v", mcp)
|
||||
}
|
||||
|
||||
agentstest.AssertIdempotent(t, a, env)
|
||||
}
|
||||
|
||||
// TestOpenCodeMergesIntoExistingJSONC verifies that an existing
|
||||
// opencode.jsonc — comments and trailing commas included — is the file
|
||||
// we merge into, the user's data survives, and no competing
|
||||
// opencode.json is created.
|
||||
func TestOpenCodeMergesIntoExistingJSONC(t *testing.T) {
|
||||
env, _ := agentstest.NewEnv(t)
|
||||
jsoncPath := filepath.Join(env.Root, "opencode.jsonc")
|
||||
const jsonc = `{
|
||||
// OpenCode project config
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"theme": "tokyonight", /* dark theme */
|
||||
"mcp": {
|
||||
"other": { "type": "local", "command": ["other-server"], "enabled": true },
|
||||
},
|
||||
}`
|
||||
if err := os.WriteFile(jsoncPath, []byte(jsonc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := New()
|
||||
|
||||
if _, err := a.Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
|
||||
// We must have merged into the .jsonc, not created a sibling .json.
|
||||
if _, err := os.Stat(filepath.Join(env.Root, "opencode.json")); err == nil {
|
||||
t.Fatalf("must not create opencode.json when opencode.jsonc already exists")
|
||||
}
|
||||
// A commented config is valid JSONC, not malformed — no backup.
|
||||
if _, err := os.Stat(jsoncPath + ".bak"); err == nil {
|
||||
t.Fatalf("a valid commented .jsonc must not be backed up as malformed")
|
||||
}
|
||||
|
||||
cfg := agentstest.ReadJSON(t, jsoncPath)
|
||||
if cfg["theme"] != "tokyonight" {
|
||||
t.Fatalf("merge dropped user's data keys: %v", cfg)
|
||||
}
|
||||
mcp, ok := cfg["mcp"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing 'mcp' section: %v", cfg)
|
||||
}
|
||||
if _, ok := mcp["other"]; !ok {
|
||||
t.Fatalf("merge clobbered existing 'other' server: %v", mcp)
|
||||
}
|
||||
if _, ok := mcp["gortex"]; !ok {
|
||||
t.Fatalf("merge didn't add 'gortex': %v", mcp)
|
||||
}
|
||||
|
||||
agentstest.AssertIdempotent(t, a, env)
|
||||
}
|
||||
Reference in New Issue
Block a user