Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ control the installation.
| `~/.codex/ucode.config.toml` (or legacy `~/.codex/config.toml`) | Codex |
| `~/.claude/ucode-settings.json` | Claude Code settings generated by ucode |
| `~/.gemini/.env` | Gemini CLI |
| `~/.config/opencode/opencode.json` | OpenCode |
| `~/.ucode/opencode-xdg/opencode/opencode.json` | OpenCode — a config layer above your own `~/.config/opencode/opencode.json`, which OpenCode still reads |
| `~/.copilot/.env` | GitHub Copilot CLI |
| `~/.pi/agent/models.json` | Pi |
| `~/.cursor/mcp.json` | Cursor Agent (MCP servers only) |
Expand Down
12 changes: 9 additions & 3 deletions src/ucode/agents/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@
from ucode.state import mark_tool_managed, save_state
from ucode.telemetry import agent_version, ucode_version

OPENCODE_XDG_CONFIG_HOME = APP_DIR / "opencode-xdg"
OPENCODE_CONFIG_DIR = OPENCODE_XDG_CONFIG_HOME / "opencode"
# ucode keeps its config outside `~/.config/opencode`, so it never writes to the
# user's own config. Moving this path drops the `mcp` entries that `ucode mcp add`
# wrote here; re-running the command does not restore them, because ucode writes
# only when the server list changes.
OPENCODE_CONFIG_DIR = APP_DIR / "opencode-xdg" / "opencode"
OPENCODE_CONFIG_PATH = OPENCODE_CONFIG_DIR / "opencode.json"
OPENCODE_BACKUP_PATH = APP_DIR / "opencode-config.backup.json"

Expand Down Expand Up @@ -260,7 +263,10 @@ def _refresh_forever(state: dict, stop_event: threading.Event) -> None:
def build_runtime_env(token: str, state: dict | None = None) -> dict[str, str]:
env = os.environ.copy()
env["OAUTH_TOKEN"] = token
env["XDG_CONFIG_HOME"] = str(OPENCODE_XDG_CONFIG_HOME)
# opencode merges this file over the user's global config. It replaces a
# top-level array instead of merging it, so this file carries no top-level
# array — one would wipe the user's `disabled_providers`.
env["OPENCODE_CONFIG"] = str(OPENCODE_CONFIG_PATH)
return env


Expand Down
27 changes: 23 additions & 4 deletions tests/test_agent_opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import patch

from ucode.agents import opencode
Expand All @@ -28,9 +29,10 @@ def test_package(self):
def test_display(self):
assert opencode.SPEC["display"] == "OpenCode"

def test_config_path_is_under_ucode_xdg_home(self):
def test_config_path_stays_at_the_registered_location(self):
# A change here orphans the MCP servers that `ucode mcp add` already wrote.
assert opencode.SPEC["config_path"] == (
opencode.OPENCODE_XDG_CONFIG_HOME / "opencode" / "opencode.json"
Path.home() / ".ucode" / "opencode-xdg" / "opencode" / "opencode.json"
)


Expand Down Expand Up @@ -307,10 +309,27 @@ def test_sets_oauth_token_for_mcp(self):

assert env["OAUTH_TOKEN"] == "tok"

def test_sets_ucode_xdg_config_home(self):
def test_names_the_ucode_config_file(self):
env = opencode.build_runtime_env("tok")

assert env["XDG_CONFIG_HOME"] == str(opencode.OPENCODE_XDG_CONFIG_HOME)
assert env["OPENCODE_CONFIG"] == str(opencode.OPENCODE_CONFIG_PATH)

# A redirect of XDG_CONFIG_HOME hides the whole of ~/.config/opencode:
# permissions, the user's MCP servers, skills, agents and tui.json. So ucode
# must neither add the variable nor overwrite the value the user set.
def test_passes_the_users_xdg_config_home_through(self, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", "/sentinel/xdg")

env = opencode.build_runtime_env("tok")

assert env["XDG_CONFIG_HOME"] == "/sentinel/xdg"

def test_adds_no_xdg_config_home(self, monkeypatch):
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)

env = opencode.build_runtime_env("tok")

assert "XDG_CONFIG_HOME" not in env


class TestOpencodeDefaultModel:
Expand Down
10 changes: 6 additions & 4 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,12 +784,14 @@ def test_launch_opencode_per_model(
pytest.skip("No OpenCode models available on this workspace")

monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path)
xdg = tmp_path / "opencode-xdg"
config_path = xdg / "opencode" / "opencode.json"
config_path = tmp_path / "opencode-xdg" / "opencode" / "opencode.json"
backup_path = tmp_path / "opencode-config.backup.json"
monkeypatch.setattr(opencode, "OPENCODE_XDG_CONFIG_HOME", xdg)
monkeypatch.setattr(opencode, "OPENCODE_CONFIG_PATH", config_path)
monkeypatch.setattr(opencode, "OPENCODE_BACKUP_PATH", backup_path)
# ucode no longer redirects XDG_CONFIG_HOME, so the spawned opencode
# would read the developer's own ~/.config/opencode. Send it to an empty
# directory to keep the test hermetic.
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "empty-xdg"))

import sys
import time
Expand Down Expand Up @@ -863,11 +865,11 @@ def test_launch_deepseek_v4_pro(

monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path)
xdg = tmp_path / "opencode-xdg"
monkeypatch.setattr(opencode, "OPENCODE_XDG_CONFIG_HOME", xdg)
monkeypatch.setattr(opencode, "OPENCODE_CONFIG_PATH", xdg / "opencode" / "opencode.json")
monkeypatch.setattr(
opencode, "OPENCODE_BACKUP_PATH", tmp_path / "opencode-config.backup.json"
)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "empty-xdg"))
monkeypatch.setattr("ucode.state.save_state", lambda state: None)
monkeypatch.setattr(
"ucode.agents.opencode.get_databricks_token",
Expand Down
12 changes: 9 additions & 3 deletions tests/test_e2e_user_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,6 @@ def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_serv
from ucode.agents import opencode

_require_binary("opencode")
# Redirect via XDG_CONFIG_HOME so the spawned opencode reads from
# tmp_path instead of the developer's real ~/.config/opencode.
xdg = tmp_path / "xdg"
opencode_dir = xdg / "opencode"
opencode_dir.mkdir(parents=True)
Expand Down Expand Up @@ -272,7 +270,15 @@ def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_serv
)
opencode.write_tool_config(state, "test-claude-model", token="test-token")

env = {**os.environ, "OAUTH_TOKEN": "test-token", "XDG_CONFIG_HOME": str(xdg)}
# OPENCODE_CONFIG names ucode's file. XDG_CONFIG_HOME goes to an empty
# directory so the spawned opencode does not read the developer's own
# ~/.config/opencode.
env = {
**os.environ,
"OAUTH_TOKEN": "test-token",
"OPENCODE_CONFIG": str(config_path),
"XDG_CONFIG_HOME": str(tmp_path / "empty-xdg"),
}
result = _run_until_first_request(opencode.validate_cmd("opencode"), env)

req = capture_server.first_request_with_path_prefix("/ai-gateway/anthropic")
Expand Down