diff --git a/cmd/gortex/doctor_runtime_test.go b/cmd/gortex/doctor_runtime_test.go index 65cd01ab..47306748 100644 --- a/cmd/gortex/doctor_runtime_test.go +++ b/cmd/gortex/doctor_runtime_test.go @@ -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] diff --git a/cmd/gortex/testdata/agent-render/opencode.txt b/cmd/gortex/testdata/agent-render/opencode.txt index 1e6b34ac..f1e5ad33 100644 --- a/cmd/gortex/testdata/agent-render/opencode.txt +++ b/cmd/gortex/testdata/agent-render/opencode.txt @@ -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: "", 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). // diff --git a/docs/agents.md b/docs/agents.md index f4e02f4b..b406e7cb 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -36,7 +36,7 @@ commands accept `--agents=` 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 | diff --git a/internal/agents/opencode/adapter.go b/internal/agents/opencode/adapter.go index 0ee262d0..fdf03cb7 100644 --- a/internal/agents/opencode/adapter.go +++ b/internal/agents/opencode/adapter.go @@ -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) diff --git a/internal/agents/opencode/global_mcp_test.go b/internal/agents/opencode/global_mcp_test.go new file mode 100644 index 00000000..10cac44b --- /dev/null +++ b/internal/agents/opencode/global_mcp_test.go @@ -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 +} diff --git a/internal/agents/opencode/inspect.go b/internal/agents/opencode/inspect.go index e14e1110..82fbea81 100644 --- a/internal/agents/opencode/inspect.go +++ b/internal/agents/opencode/inspect.go @@ -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"] } diff --git a/internal/agents/opencode/remove.go b/internal/agents/opencode/remove.go index 9197809a..7e9252a2 100644 --- a/internal/agents/opencode/remove.go +++ b/internal/agents/opencode/remove.go @@ -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 +} diff --git a/internal/agents/opencode/remove_test.go b/internal/agents/opencode/remove_test.go index 88dcc26e..b6022733 100644 --- a/internal/agents/opencode/remove_test.go +++ b/internal/agents/opencode/remove_test.go @@ -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 { diff --git a/internal/agents/writer.go b/internal/agents/writer.go index 178ad2a4..c86e781a 100644 --- a/internal/agents/writer.go +++ b/internal/agents/writer.go @@ -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