Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ The output looks like:
| `~/.codex/config.toml` | Codex |
| `~/.claude/settings.json` | Claude Code |
| `~/.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
17 changes: 14 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. The `opencode-xdg` directory name is historical: ucode used
# to export it as XDG_CONFIG_HOME. The path stays as it is so MCP servers that
# `ucode mcp add` already registered survive an upgrade.
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,15 @@ 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)
# Name the config file instead of a redirect of XDG_CONFIG_HOME. A redirect
# hides all of `~/.config/opencode`: permissions, the user's MCP servers,
# `disabled_providers`, global skills, agents, commands, plugins, tui.json
# and the global AGENTS.md. OPENCODE_CONFIG is a layer above the user's
# global config, and opencode merges the layers — it does not replace them.
# Objects merge one key at a time and the later scalar wins, so ucode keeps
# control of `model` and `provider`. Do not put a top-level array in this
# file: opencode replaces an array, it does not merge it.
env["OPENCODE_CONFIG"] = str(OPENCODE_CONFIG_PATH)
return env


Expand Down
29 changes: 23 additions & 6 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,10 +29,9 @@ def test_package(self):
def test_display(self):
assert opencode.SPEC["display"] == "OpenCode"

def test_config_path_is_under_ucode_xdg_home(self):
assert opencode.SPEC["config_path"] == (
opencode.OPENCODE_XDG_CONFIG_HOME / "opencode" / "opencode.json"
)
def test_config_path_is_outside_the_user_config_dir(self):
assert opencode.SPEC["config_path"] == opencode.OPENCODE_CONFIG_DIR / "opencode.json"
assert Path.home() / ".config" not in opencode.OPENCODE_CONFIG_PATH.parents

Comment on lines +32 to 35

class TestRenderOverlay:
Expand Down Expand Up @@ -307,10 +307,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["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 env["XDG_CONFIG_HOME"] == str(opencode.OPENCODE_XDG_CONFIG_HOME)
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