fix(agents): register the OpenCode MCP server at user scope
gortex install gave OpenCode 21 skills, 20 slash commands and an enforcement plugin — every one of which tells the model to reach for the Gortex tools — and registered no MCP server at that scope. The reasoning, written into the code, was that gortex init would put a server in each repo. A user who runs only the documented machine-wide step therefore got an OpenCode taught to ask for tools that were not mounted, and the sole symptom was the agent reporting a Gortex integration failure. Global mode now merges the same mcp.gortex entry into ~/.config/opencode/opencode.json, preferring an existing .jsonc for the reason the per-repo path already does: writing the sibling .json would leave two configs and register the server in the one OpenCode does not read. Project and user scope share one mutator so the two entries cannot drift. Inspect had the same blind spot from the other side — it hardcoded opencode.json and parsed it without stripping comments, so on a hand-authored config doctor reported no server on a machine that had one. It now resolves through the same helper the writer uses. The invariant is now a test: if global mode installs guidance, it installs the server that guidance depends on. Removing the fix fails four tests, one of which prints the user's exact situation — 21 skills, 20 commands, a plugin, no server.
This commit is contained in:
@@ -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]
|
||||
|
||||
+17
@@ -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).
|
||||
//
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -44,6 +44,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/agents"
|
||||
"github.com/zzet/gortex/internal/agents/internalutil"
|
||||
@@ -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"},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/agents"
|
||||
"github.com/zzet/gortex/internal/agents/skillpack"
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -18,17 +18,19 @@ package opencode
|
||||
// checked: `plugin/gortex.js` is deleted only when it carries PluginMarker,
|
||||
// so a same-named plugin somebody else wrote is left alone.
|
||||
//
|
||||
// # Stop-line: no MCP stanza and no instructions block at user scope
|
||||
// The user-level `mcp.gortex` entry is stripped from
|
||||
// ~/.config/opencode/opencode.json, leaving every other server and every
|
||||
// other top-level key untouched. The file itself is never deleted: it is
|
||||
// the user's config, and we only ever added one key to it.
|
||||
//
|
||||
// applyGlobal writes neither, so RemoveGlobal removes neither. OpenCode's
|
||||
// gortex MCP entry lives in the repo's own opencode.json (written by
|
||||
// `gortex init`, removed by the repo-level half of `gortex uninstall`) and
|
||||
// the community routing block lives in the repo's AGENTS.md. A `mcp.gortex`
|
||||
// entry in `~/.config/opencode/opencode.json` was put there by the user,
|
||||
// and a cleanup command that deletes config it never wrote is a cleanup
|
||||
// command nobody runs twice.
|
||||
// # Stop-line: no instructions block at user scope
|
||||
//
|
||||
// applyGlobal writes none, so RemoveGlobal removes none. OpenCode's
|
||||
// community routing block lives in the repo's AGENTS.md and comes out
|
||||
// with the repo-level half of `gortex uninstall`.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -61,9 +63,51 @@ func (a *Adapter) RemoveGlobal(env agents.Env, opts agents.ApplyOpts) (removed i
|
||||
removed += packRemoved
|
||||
failures = append(failures, packFailures...)
|
||||
|
||||
// 3. The user-level mcp.gortex entry.
|
||||
mcpRemoved, mcpFailures := removeGlobalMCPServer(env, opts)
|
||||
removed += mcpRemoved
|
||||
failures = append(failures, mcpFailures...)
|
||||
|
||||
return removed, failures
|
||||
}
|
||||
|
||||
// removeGlobalMCPServer strips `mcp.gortex` from the user config. The
|
||||
// `mcp` map itself is dropped only when our entry was the last one in it,
|
||||
// so a user who also runs other servers keeps their section — and the
|
||||
// config file is never deleted, because everything else in it is theirs.
|
||||
//
|
||||
// agents.RemoveMCPServer cannot be reused: it is hardcoded to the
|
||||
// canonical `mcpServers` key, and OpenCode spells the section `mcp`.
|
||||
func removeGlobalMCPServer(env agents.Env, opts agents.ApplyOpts) (removed int, failures []string) {
|
||||
path := GlobalConfigPath(env.Home)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
|
||||
section, ok := root["mcp"].(map[string]any)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if _, exists := section["gortex"]; !exists {
|
||||
return false, nil
|
||||
}
|
||||
delete(section, "gortex")
|
||||
if len(section) == 0 {
|
||||
delete(root, "mcp")
|
||||
} else {
|
||||
root["mcp"] = section
|
||||
}
|
||||
return true, nil
|
||||
}, opts)
|
||||
if err != nil {
|
||||
return 0, []string{fmt.Sprintf("opencode: %s: %v", path, err)}
|
||||
}
|
||||
if action.Action == agents.ActionSkip {
|
||||
return 0, nil
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// GlobalArtifacts lists the user-level OpenCode paths that currently carry
|
||||
// a Gortex footprint, sorted. It applies the SAME ownership tests
|
||||
// RemoveGlobal does — a customised skill is absent from this list exactly
|
||||
@@ -79,6 +123,12 @@ func GlobalArtifacts(home string) []string {
|
||||
if path := PluginPath(home); opencodeFileContains(path, PluginMarker) {
|
||||
present = append(present, path)
|
||||
}
|
||||
// The config is listed only when our server entry is actually in it,
|
||||
// so the preview matches what removal will do rather than naming a
|
||||
// file that will be left byte-identical.
|
||||
if path := GlobalConfigPath(home); hasGortexMCPServer(path) {
|
||||
present = append(present, path)
|
||||
}
|
||||
for path, shipped := range ownedPackFiles(home) {
|
||||
if isShippedOpenCodeFile(path, shipped) {
|
||||
present = append(present, path)
|
||||
@@ -212,3 +262,26 @@ func opencodeFileContains(path, needle string) bool {
|
||||
}
|
||||
return strings.Contains(string(data), needle)
|
||||
}
|
||||
|
||||
// hasGortexMCPServer reports whether the config at path actually carries
|
||||
// our server entry. It parses rather than substring-matching: "gortex"
|
||||
// appears in a user's config for plenty of innocent reasons, and the
|
||||
// uninstall preview must not claim a file it will then leave alone.
|
||||
// An unreadable or malformed config reads as "nothing of ours", which is
|
||||
// the safe direction — removal will skip it too.
|
||||
func hasGortexMCPServer(path string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(agents.StripJSONComments(data), &root); err != nil {
|
||||
return false
|
||||
}
|
||||
section, ok := root["mcp"].(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, exists := section["gortex"]
|
||||
return exists
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -8,11 +9,10 @@ import (
|
||||
"github.com/zzet/gortex/internal/agents"
|
||||
)
|
||||
|
||||
// userGlobalOpenCodeConfig is the user's own ~/.config/opencode/opencode.json.
|
||||
// Gortex never writes at this scope (its MCP stanza goes in the repo's
|
||||
// opencode.json), so RemoveGlobal must leave the file byte-identical —
|
||||
// including a gortex-looking entry, which could only have been put there
|
||||
// by hand.
|
||||
// userGlobalOpenCodeConfig is the user's own ~/.config/opencode/opencode.json,
|
||||
// carrying a server of their own. `gortex install` adds `mcp.gortex`
|
||||
// alongside it and RemoveGlobal takes only that key back out — the file
|
||||
// itself, the user's own server, and every other key must survive.
|
||||
const userGlobalOpenCodeConfig = `{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
@@ -106,14 +106,29 @@ func TestOpenCodeRemoveGlobalLeavesNoGortexArtifact(t *testing.T) {
|
||||
t.Errorf("the user's skill changed:\n%s", skillAfter)
|
||||
}
|
||||
|
||||
// The user's global config is untouched — Gortex never wrote it, so a
|
||||
// cleanup that edited it would be deleting config it did not install.
|
||||
// The global config keeps everything except our own server entry. It
|
||||
// is re-marshalled on the way through, so compare the parsed shape
|
||||
// rather than the bytes.
|
||||
got, err := os.ReadFile(filepath.Join(globalConfigDir(env.Home), "opencode.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("the user's global config was deleted: %v", err)
|
||||
}
|
||||
if string(got) != userGlobalOpenCodeConfig {
|
||||
t.Errorf("the user's global config changed:\n%s", got)
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(got, &root); err != nil {
|
||||
t.Fatalf("global config is no longer valid JSON: %v\n%s", err, got)
|
||||
}
|
||||
section, ok := root["mcp"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("the user's mcp section was removed along with ours:\n%s", got)
|
||||
}
|
||||
if _, exists := section["gortex"]; exists {
|
||||
t.Errorf("the gortex server entry survived removal:\n%s", got)
|
||||
}
|
||||
if _, exists := section["other"]; !exists {
|
||||
t.Errorf("the user's own server entry was removed:\n%s", got)
|
||||
}
|
||||
if root["$schema"] != "https://opencode.ai/config.json" {
|
||||
t.Errorf("the user's $schema was lost:\n%s", got)
|
||||
}
|
||||
|
||||
if got := GlobalArtifacts(env.Home); len(got) != 0 {
|
||||
|
||||
@@ -337,6 +337,13 @@ func isJSONCPath(path string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// StripJSONComments is the exported form, for an adapter that has to
|
||||
// READ a host config MergeJSON is not writing — an uninstall preview
|
||||
// deciding whether a stanza is actually present, say. Reaching for
|
||||
// encoding/json directly on a host that permits JSONC silently reports
|
||||
// every commented config as unparseable.
|
||||
func StripJSONComments(b []byte) []byte { return stripJSONComments(b) }
|
||||
|
||||
// stripJSONComments rewrites JSONC / JSON5-style input into strict JSON
|
||||
// that encoding/json can parse: it drops `//` line comments, `/* */`
|
||||
// block comments, and trailing commas before `}` / `]`. String literals
|
||||
|
||||
Reference in New Issue
Block a user