Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 1 addition & 3 deletions cmd/gortex/doctor_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,7 @@ func TestDoctorRuntimeReportsInstallStateForEveryHost(t *testing.T) {
{codex.Name, true, filepath.Join(home, ".codex", "config.toml")},
{hooks.AgentClaudeCode, true, ""},
{copilotcli.Name, true, filepath.Join(home, ".copilot", "hooks", "gortex.json")},
// OpenCode registers no user-level MCP server: `gortex init` puts
// the stanza in the repo's own opencode.json.
{opencode.Name, false, opencode.PluginPath(home)},
{opencode.Name, true, opencode.PluginPath(home)},
} {
t.Run(tc.agent, func(t *testing.T) {
got, ok := byAgent[tc.agent]
Expand Down
17 changes: 17 additions & 0 deletions cmd/gortex/testdata/agent-render/opencode.txt
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,23 @@ description: "Preview an edit's blast radius on the shadow graph before writing.
- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`.
- Call `capabilities({domain: "<tool>", operation: "<operation>", detail: "schema"})` only when an operation's exact arguments are unclear.
- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match.
=== global/home/.config/opencode/opencode.json ===
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"gortex": {
"command": [
"gortex",
"mcp"
],
"enabled": true,
"environment": {
"GORTEX_INDEX_WORKERS": "8"
},
"type": "local"
}
}
}
=== global/home/.config/opencode/plugin/gortex.js ===
// Gortex plugin for OpenCode (1.18.18).
//
Expand Down
2 changes: 1 addition & 1 deletion docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ commands accept `--agents=<csv>` to constrain setup and
| `kimi` | `.kimi-code/mcp.json` (project) or `~/.kimi-code/mcp.json` + `~/.kimi-code/config.toml` (`UserPromptSubmit` / `PreToolUse` / `Stop` / `SubagentStart` hooks) | both | https://www.kimi.com/code/docs/en/kimi-code-cli/customization/hooks.html |
| `kiro` | `.kiro/settings/mcp.json` + steering/hooks or user-level | both | https://kiro.dev/docs/mcp/configuration |
| `oh-my-pi` | `.omp/mcp.json` | project | https://github.com/can1357/oh-my-pi/blob/main/docs/mcp-config.md |
| `opencode` | `opencode.json` (or existing `opencode.jsonc`), `AGENTS.md` communities block, repo `.opencode/skills/gortex-*`, `~/.config/opencode/skills/gortex-*`, `~/.config/opencode/commands/gortex-*.md`, `~/.config/opencode/plugin/gortex.js` | both | https://opencode.ai/docs/mcp |
| `opencode` | `opencode.json` (or existing `opencode.jsonc`) and `~/.config/opencode/opencode.json` MCP stanzas, `AGENTS.md` communities block, repo `.opencode/skills/gortex-*`, `~/.config/opencode/skills/gortex-*`, `~/.config/opencode/commands/gortex-*.md`, `~/.config/opencode/plugin/gortex.js` | both | https://opencode.ai/docs/mcp |
| `openclaw` | `~/.openclaw/openclaw.json` (`mcp.servers.gortex`) | user | https://docs.openclaw.ai/cli/mcp |
| `pi` | `.pi/extensions/gortex/index.ts` (project) or `~/.pi/agent/extensions/gortex/index.ts`; `AGENTS.md` communities block only when `--skills` | both | https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md |
| `vscode` | `.vscode/mcp.json` (`servers` key, 1.102+), `.github/copilot-instructions.md` communities block | project | https://code.visualstudio.com/docs/copilot/chat/mcp-servers |
Expand Down
115 changes: 87 additions & 28 deletions internal/agents/opencode/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
Expand Down Expand Up @@ -89,9 +90,77 @@ func projectConfigPath(root string) string {
return filepath.Join(root, "opencode.json")
}

// GlobalConfigPath is the user-level config OpenCode reads for every
// project, `~/.config/opencode/opencode.json`. An existing `.jsonc` wins
// for the same reason it does per-repo: a hand-authored, comment-bearing
// config keeps its extension, and writing the sibling `.json` would
// leave the user with two configs and no clue which one OpenCode reads.
//
// $OPENCODE_CONFIG overrides the location entirely. It is honoured only
// when Home is the machine's real home, so the render fence and the
// adapter tests — which set a sandbox Home but inherit the process
// environment — cannot be steered into the developer's real config by an
// exported variable.
func GlobalConfigPath(home string) string {
if override := strings.TrimSpace(os.Getenv("OPENCODE_CONFIG")); override != "" && isRealUserHome(home) {
return override
}
dir := filepath.Join(home, ".config", "opencode")
for _, name := range []string{"opencode.jsonc", "opencode.json"} {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p
}
}
return filepath.Join(dir, "opencode.json")
}

// isRealUserHome reports whether home is the machine's actual home, so
// environment overrides apply to a real install but never to a sandbox.
func isRealUserHome(home string) bool {
real, err := os.UserHomeDir()
return err == nil && real != "" && real == home
}

// upsertMCPServer returns the mutation both scopes share. Project and
// user configs take the identical `mcp.gortex` entry, and OpenCode's
// schema differs enough from the canonical shape (a `command` array
// rather than command+args, `environment` rather than `env`) that a
// second copy of it would be a standing invitation to drift.
func upsertMCPServer(opts agents.ApplyOpts) func(map[string]any, bool) (bool, error) {
return 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
}
}

func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Mode != agents.ModeGlobal {
if env.Mode == agents.ModeGlobal {
if env.Home != "" {
p.Files = append(p.Files, agents.FileAction{
Path: GlobalConfigPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"mcp"},
})
}
} else {
p.Files = append(p.Files, agents.FileAction{
Path: projectConfigPath(env.Root), Action: agents.ActionWouldMerge, Keys: []string{"mcp"},
})
Expand Down Expand Up @@ -133,28 +202,7 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result,
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)
action, err := agents.MergeJSON(env.Stderr, path, upsertMCPServer(opts), opts)
if err != nil {
return res, err
}
Expand Down Expand Up @@ -188,18 +236,29 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result,
}

// applyGlobal handles `gortex install`: the codebase-agnostic artifacts
// that belong to the user, not to any one repo — the curated playbook and
// slash-command packs, plus the enforcement bridge.
// that belong to the user, not to any one repo — the MCP server, the
// curated playbook and slash-command packs, and the enforcement bridge.
//
// There is no user-level MCP stanza to write here: `gortex init` puts the
// server in the repo's own opencode.json, which is where a per-project
// daemon scope belongs.
// The MCP registration is the load-bearing one and it must stay here.
// This function also installs 21 skills and a plugin that tell the model
// to reach for the Gortex tools on every turn; without a server entry
// those tools are not mounted, so the user gets an OpenCode that has
// been taught to ask for something it cannot call. Registering per-repo
// from `gortex init` alone is not a substitute — a user who runs only
// `gortex install`, which is the documented machine-wide step, would
// never get a server at all.
func (a *Adapter) applyGlobal(env agents.Env, opts agents.ApplyOpts, res *agents.Result) error {
if env.Home == "" {
return fmt.Errorf("opencode: global mode requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex install] setting up OpenCode integration...")

mcpAction, err := agents.MergeJSON(env.Stderr, GlobalConfigPath(env.Home), upsertMCPServer(opts), opts)
if err != nil {
return fmt.Errorf("opencode global config: %w", err)
}
res.Files = append(res.Files, mcpAction)

pluginActions, err := applyPlugin(env, opts)
if err != nil {
return fmt.Errorf("opencode plugin: %w", err)
Expand Down
176 changes: 176 additions & 0 deletions internal/agents/opencode/global_mcp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package opencode

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/zzet/gortex/internal/agents"
)

// global_mcp_test.go covers the failure this package shipped once and
// must never ship again.
//
// `gortex install` runs in ModeGlobal. It installs 21 skills, 20 slash
// commands and an enforcement plugin, every one of which tells the model
// to reach for the Gortex tools — and it used to register no MCP server
// at that scope, on the theory that `gortex init` would put one in each
// repo. The result on a real machine was an OpenCode that had been
// taught to ask for tools that were not mounted, and the only visible
// symptom was the agent reporting a Gortex integration failure.
//
// The invariant these tests hold: if global mode installs guidance, it
// installs the server that guidance depends on.

// TestGlobalInstallRegistersTheMCPServer is the direct regression.
func TestGlobalInstallRegistersTheMCPServer(t *testing.T) {
env, _ := globalEnv(t)

if _, err := New().Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
t.Fatalf("apply: %v", err)
}

entry := gortexServerEntry(t, GlobalConfigPath(env.Home))
if entry["type"] != "local" {
t.Errorf("type = %v, want local", entry["type"])
}
if entry["enabled"] != true {
t.Errorf("enabled = %v, want true", entry["enabled"])
}
command, ok := entry["command"].([]any)
if !ok || len(command) != 2 || command[0] != "gortex" || command[1] != "mcp" {
t.Errorf("command = %v, want [gortex mcp]", entry["command"])
}
}

// TestGlobalInstallNeverShipsGuidanceWithoutTools is the invariant stated
// as a test, so a future change that adds another user-level teaching
// surface cannot reintroduce the same asymmetry by accident.
func TestGlobalInstallNeverShipsGuidanceWithoutTools(t *testing.T) {
env, _ := globalEnv(t)

if _, err := New().Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
t.Fatalf("apply: %v", err)
}

guidance := map[string]string{
"curated skills": globalSkillsDir(env.Home),
"slash commands": globalCommandsDir(env.Home),
"enforcement plugin": filepath.Dir(PluginPath(env.Home)),
}
installed := false
for label, dir := range guidance {
entries, err := os.ReadDir(dir)
if err != nil || len(entries) == 0 {
continue
}
installed = true
t.Logf("%s installed (%d entries)", label, len(entries))
}
if !installed {
t.Skip("global mode installed no guidance surface; nothing to depend on a server")
}

// Guidance is present, so a server entry must be too.
gortexServerEntry(t, GlobalConfigPath(env.Home))
}

// TestGlobalInstallPreservesAUserConfig pins the merge: we add one key to
// a file the user owns and touch nothing else in it.
func TestGlobalInstallPreservesAUserConfig(t *testing.T) {
env, _ := globalEnv(t)
writeOpenCodeFile(t, filepath.Join(globalConfigDir(env.Home), "opencode.json"), userGlobalOpenCodeConfig)

if _, err := New().Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
t.Fatalf("apply: %v", err)
}

root := readOpenCodeConfig(t, GlobalConfigPath(env.Home))
section, ok := root["mcp"].(map[string]any)
if !ok {
t.Fatalf("mcp section missing: %v", root)
}
if _, exists := section["gortex"]; !exists {
t.Error("our server was not added")
}
if _, exists := section["other"]; !exists {
t.Error("the user's own server was dropped")
}
}

// TestGlobalConfigPathPrefersAnExistingJSONC: a user whose config is
// comment-bearing keeps that file. Writing the sibling .json instead
// would leave two configs and register the server in the one OpenCode
// does not read — the same invisible failure in a new costume.
func TestGlobalConfigPathPrefersAnExistingJSONC(t *testing.T) {
home := t.TempDir()
jsonc := filepath.Join(home, ".config", "opencode", "opencode.jsonc")
writeOpenCodeFile(t, jsonc, "{\n // mine\n \"$schema\": \"https://opencode.ai/config.json\"\n}\n")

if got := GlobalConfigPath(home); got != jsonc {
t.Errorf("GlobalConfigPath = %q, want the existing %q", got, jsonc)
}
}

// TestGlobalInstallMergesIntoAJSONCConfig is the end-to-end of the above:
// the comment-bearing file is the one that gains the server.
func TestGlobalInstallMergesIntoAJSONCConfig(t *testing.T) {
env, _ := globalEnv(t)
jsonc := filepath.Join(globalConfigDir(env.Home), "opencode.jsonc")
writeOpenCodeFile(t, jsonc, "{\n // hand written\n \"$schema\": \"https://opencode.ai/config.json\"\n}\n")

if _, err := New().Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
t.Fatalf("apply: %v", err)
}

gortexServerEntry(t, jsonc)
if _, err := os.Stat(filepath.Join(globalConfigDir(env.Home), "opencode.json")); err == nil {
t.Error("wrote a sibling opencode.json; the .jsonc was the file OpenCode reads")
}
}

// TestGlobalPlanNamesTheConfig keeps doctor and --print-config honest:
// both read Plan(), so a path Apply writes but Plan omits is invisible.
func TestGlobalPlanNamesTheConfig(t *testing.T) {
env, _ := globalEnv(t)

plan, err := New().Plan(env)
if err != nil {
t.Fatalf("plan: %v", err)
}
want := GlobalConfigPath(env.Home)
for _, f := range plan.Files {
if f.Path == want {
return
}
}
t.Errorf("Plan did not name %s; files=%v", want, plan.Files)
}

func gortexServerEntry(t *testing.T, path string) map[string]any {
t.Helper()
root := readOpenCodeConfig(t, path)
section, ok := root["mcp"].(map[string]any)
if !ok {
t.Fatalf("no mcp section in %s: %v", path, root)
}
entry, ok := section["gortex"].(map[string]any)
if !ok {
t.Fatalf("no gortex server registered in %s: %v", path, section)
}
return entry
}

func readOpenCodeConfig(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var root map[string]any
if err := json.Unmarshal(agents.StripJSONComments(data), &root); err != nil {
t.Fatalf("parse %s: %v\n%s", path, err, data)
}
return root
}
10 changes: 8 additions & 2 deletions internal/agents/opencode/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"

"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/skillpack"
)

Expand Down Expand Up @@ -77,11 +78,16 @@ func Inspect(home string) InstallState {
return state
}

state.ConfigPath = filepath.Join(globalConfigDir(home), "opencode.json")
// Resolve the same file the writer targets, and strip comments before
// parsing. A hand-authored config is usually `opencode.jsonc`, and
// reading a fixed `opencode.json` with a bare json.Unmarshal reports
// exactly the machine this probe exists to catch — server installed,
// doctor says it is not — as a clean bill of health.
state.ConfigPath = GlobalConfigPath(home)
if data, err := os.ReadFile(state.ConfigPath); err == nil {
state.ConfigPresent = true
root := map[string]any{}
if json.Unmarshal(data, &root) == nil {
if json.Unmarshal(agents.StripJSONComments(data), &root) == nil {
if servers, ok := root["mcp"].(map[string]any); ok {
_, state.MCPServer = servers["gortex"]
}
Expand Down
Loading
Loading