Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 @@ -531,7 +531,7 @@ preferences are ignored and their files are left untouched. See the bundled
| Claude Code | `~/.claude/ucode-settings.json`, `~/.claude.json`, `/etc/claude-code/managed-settings.json` (Linux), `/Library/Application Support/ClaudeCode/managed-settings.json` (macOS), `C:\Program Files\ClaudeCode\managed-settings.json` (Windows, written only from an elevated terminal) |
| Gemini CLI | `~/.gemini/ucode.env`, `~/.ucode/.gemini-home/.gemini/settings.json` |
| OpenCode | `~/.ucode/opencode-xdg/opencode/opencode.json`, `~/.ucode/opencode-xdg/opencode/plugin/ucode-auth.js` |
| GitHub Copilot CLI | `~/.copilot/ucode.env`, `~/.copilot/ucode-mcp-config.json` |
| GitHub Copilot CLI | `~/.copilot/ucode.env`, `~/.copilot/mcp-config.json` (or `$COPILOT_HOME/mcp-config.json`) |
| Pi | `~/.ucode/pi-home/.pi/agent/models.json`, `~/.ucode/pi-home/.pi/agent/settings.json` |
| Antigravity CLI | `~/.gemini/config/skills.json` (adds `~/.agents/skills`), `~/.gemini/config/mcp_config.json` (ug's MCP servers only); the gateway is set per launch through `AGY_LLM_GATEWAY_*` variables |
| Cursor Agent | `~/.cursor/mcp.json` |
Expand Down
39 changes: 36 additions & 3 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from collections.abc import Callable
from pathlib import Path

from ucode.config_io import ToolSpec
from ucode.config_io import ToolSpec, is_dry_run
from ucode.constants import AGENT_CLAUDE, AGENT_CODEX, AGENT_COPILOT
from ucode.databricks import (
AnthropicModelCatalog,
Expand Down Expand Up @@ -680,10 +680,38 @@ def _availability_failure_detail(tool: str, state: dict) -> str:
return " (" + "; ".join(parts) + ")"


_vscode_with_copilot = True


def request_vscode_with_copilot(requested: bool = True) -> None:
"""Whether configuring Copilot in this run also sets up VS Code (``ug configure --no-vscode``
passes ``False``)."""
global _vscode_with_copilot
_vscode_with_copilot = requested


def configure_vscode(state: dict, *, provider: str | None, parent_schema: str | None) -> None:
"""Set up Copilot Chat in VS Code with the models the Copilot CLI was configured with."""
if not _vscode_with_copilot:
return
if is_dry_run():
print_note("Dry run: VS Code isn't changed.")
return
from . import vscode

vscode.configure(state, provider=provider, location=parent_schema)


def configure_single_tool(
tool: str, state: dict, *, parent_schema: str | None = None, provider: str | None = None
tool: str,
state: dict,
*,
parent_schema: str | None = None,
provider: str | None = None,
with_vscode: bool = False,
) -> dict:
"""Check availability, configure, and persist state for one tool only."""
"""Check availability, configure, and persist state for one tool only. ``with_vscode`` also
sets up VS Code after Copilot."""
parent_schema = parent_schema or _persisted_model_location(tool, state, provider)
provider = None if parent_schema else provider or get_provider_service(state, tool)
# A Model Provider Service or parent schema routes through the same gateway and pins no
Expand All @@ -698,6 +726,8 @@ def configure_single_tool(
)
with managed_write_batch(_managed_settings_displays([tool])):
state = _configure_one(tool, state, provider, parent_schema=parent_schema)
if with_vscode and tool == AGENT_COPILOT:
configure_vscode(state, provider=provider, parent_schema=parent_schema)
available_tools = list(set((state.get("available_tools") or []) + [tool]))
state["available_tools"] = available_tools
save_state(state)
Expand Down Expand Up @@ -782,6 +812,9 @@ def configure_selected_tools(
)
continue
configured.append(tool)
# `ug configure` sets up Copilot Chat in VS Code along with the Copilot CLI.
if tool == AGENT_COPILOT:
configure_vscode(state, provider=provider, parent_schema=parent_schema)

existing = state.get("available_tools") or []
state["available_tools"] = sorted(set(existing) | set(configured))
Expand Down
122 changes: 91 additions & 31 deletions src/ucode/agents/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@
from ucode.config_io import (
APP_DIR,
ToolSpec,
apply_json_mcp_diff,
backup_existing_file,
is_dry_run,
parse_dotenv,
read_json_safe,
write_dotenv,
Expand Down Expand Up @@ -88,9 +88,11 @@

COPILOT_CONFIG_DIR = Path.home() / ".copilot"
COPILOT_ENV_PATH = COPILOT_CONFIG_DIR / "ucode.env"
COPILOT_MCP_CONFIG_PATH = COPILOT_CONFIG_DIR / "ucode-mcp-config.json"
COPILOT_MCP_CONFIG_PATH = COPILOT_CONFIG_DIR / "mcp-config.json"
COPILOT_BACKUP_PATH = APP_DIR / "copilot-ucode-env.backup"
COPILOT_MCP_BACKUP_PATH = APP_DIR / "copilot-ucode-mcp-config.backup.json"
# Older ug versions kept their servers in a side file loaded with `--additional-mcp-config`.
LEGACY_MCP_CONFIG_PATH = COPILOT_CONFIG_DIR / "ucode-mcp-config.json"
LEGACY_MCP_BACKUP_PATH = APP_DIR / "copilot-ucode-mcp-config.backup.json"
COPILOT_PROXY_LOG_PATH = APP_DIR / "logs" / "copilot-proxy.log"
_REGISTRY_PREFIX = "ug-providers-"
_REGISTRY_LOCK_NAME = "ug-providers.lock"
Expand Down Expand Up @@ -800,35 +802,90 @@ def build_mcp_server_entry(argv: list[str]) -> dict:
}


def write_mcp_server_config(name: str, argv: list[str]) -> bool:
backup_existing_file(COPILOT_MCP_CONFIG_PATH, COPILOT_MCP_BACKUP_PATH)
existing = read_json_safe(COPILOT_MCP_CONFIG_PATH)
mcp_servers = existing.get("mcpServers")
if not isinstance(mcp_servers, dict):
mcp_servers = {}
removed = name in mcp_servers
mcp_servers[name] = build_mcp_server_entry(argv)
existing["mcpServers"] = mcp_servers
write_json_file(COPILOT_MCP_CONFIG_PATH, existing)
def copilot_mcp_config_path() -> Path:
"""The user config Copilot reads ``mcpServers`` from (and ``copilot mcp add`` writes)."""
copilot_home = os.environ.get("COPILOT_HOME")
return Path(copilot_home) / "mcp-config.json" if copilot_home else COPILOT_MCP_CONFIG_PATH


def _read_mcp_config_for_rewrite(path: Path) -> dict | None:
"""``path`` parsed for a rewrite: ``{}`` when absent, ``None`` when it isn't a JSON object, so
a caller never overwrites it. Copilot also accepts JSON with comments, which lands here."""
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None


def remove_legacy_mcp_config() -> bool:
"""Delete the legacy side file and its backup (everything in them is ug's)."""
existed = LEGACY_MCP_CONFIG_PATH.exists() or LEGACY_MCP_BACKUP_PATH.exists()
if is_dry_run():
return existed
for path in (LEGACY_MCP_CONFIG_PATH, LEGACY_MCP_BACKUP_PATH):
try:
path.unlink(missing_ok=True)
except OSError as exc:
raise RuntimeError(f"Failed to remove {path}") from exc
return existed


def _read_legacy_mcp_servers() -> dict[str, dict]:
"""The servers in the legacy side file. Every entry there was written by ug, so it moves into
the user config as-is."""
servers = read_json_safe(LEGACY_MCP_CONFIG_PATH).get("mcpServers")
if not isinstance(servers, dict):
return {}
return {name: entry for name, entry in servers.items() if isinstance(entry, dict)}


def _apply_mcp_diff(add: dict[str, dict], remove: set[str]) -> set[str]:
path = copilot_mcp_config_path()
config = _read_mcp_config_for_rewrite(path)
if config is None:
raise RuntimeError(
f"{path} isn't plain JSON (it may have comments). Remove the comments, then try "
"again; VS Code's local agent also skips the file while it has them."
)
legacy = _read_legacy_mcp_servers()
upserts = {name: entry for name, entry in legacy.items() if name not in remove} | add
servers = config.get("mcpServers")
if not isinstance(servers, dict):
servers = {}
removed = {name for name in remove if name in servers or name in legacy}
if upserts or removed:
for name in remove:
servers.pop(name, None)
servers.update(upserts)
config["mcpServers"] = servers
write_json_file(path, config)
remove_legacy_mcp_config()
return removed


def migrate_legacy_mcp_config() -> None:
"""Move the servers in the legacy side file into the user config, once."""
if LEGACY_MCP_CONFIG_PATH.exists() or LEGACY_MCP_BACKUP_PATH.exists():
_apply_mcp_diff({}, set())


def write_mcp_server_config(name: str, argv: list[str]) -> bool:
"""Add (or replace) a stdio (`ug mcp-proxy`) MCP server in Copilot's user config. Returns
True when an entry of that name was replaced."""
return bool(_apply_mcp_diff({name: build_mcp_server_entry(argv)}, {name}))


def remove_mcp_server_config(name: str) -> bool:
existing = read_json_safe(COPILOT_MCP_CONFIG_PATH)
mcp_servers = existing.get("mcpServers")
if not isinstance(mcp_servers, dict) or name not in mcp_servers:
return False
mcp_servers.pop(name)
existing["mcpServers"] = mcp_servers
write_json_file(COPILOT_MCP_CONFIG_PATH, existing)
return True
return bool(_apply_mcp_diff({}, {name}))


def write_user_mcp_servers(add: dict[str, dict], remove: set[str]) -> set[str]:
"""Apply ``add``/``remove`` to Copilot's `mcpServers` in a single read-modify-write. Returns the names actually removed."""
return apply_json_mcp_diff(
COPILOT_MCP_CONFIG_PATH, "mcpServers", add, remove, backup_path=COPILOT_MCP_BACKUP_PATH
)
"""Apply ``add``/``remove`` to Copilot's user-config ``mcpServers`` in a single
read-modify-write. Returns the names actually removed."""
return _apply_mcp_diff(add, remove)


def scrub_stored_credentials() -> None:
Expand Down Expand Up @@ -951,6 +1008,10 @@ def launch(state: dict, tool_args: list[str], *, options: LaunchOptions) -> None
managed_list=not (provider_service or model_location),
)
scrub_stored_credentials()
try:
migrate_legacy_mcp_config()
except RuntimeError as exc:
print_warning(f"ug's MCP servers aren't available in this session: {exc}")
Comment thread
xi-db marked this conversation as resolved.
Resolved

# Only Copilot launches need the proxy (and its `httpx` import).
from ucode import gateway_proxy
Expand Down Expand Up @@ -986,7 +1047,7 @@ def launch(state: dict, tool_args: list[str], *, options: LaunchOptions) -> None
with session_registry(config) as registry_path:
env = build_runtime_env(registry_path, start_model)
proc = subprocess_cross_os.popen(
[SPEC["binary"], *mcp_config_args(), *tool_args], env=env
[SPEC["binary"], *MCP_LAUNCH_ARGS, *tool_args], env=env
)
returncode = _wait_for_exit(proc)
finally:
Expand All @@ -1000,14 +1061,13 @@ def launch(state: dict, tool_args: list[str], *, options: LaunchOptions) -> None
def validate_cmd(binary: str) -> list[str]:
return [
binary,
*mcp_config_args(),
*MCP_LAUNCH_ARGS,
"--prompt",
"say hi in 5 words or less",
"--allow-all-tools",
]


def mcp_config_args() -> list[str]:
if not COPILOT_MCP_CONFIG_PATH.exists():
return []
return ["--additional-mcp-config", f"@{COPILOT_MCP_CONFIG_PATH}"]
# Offline mode empties Copilot's allowlist of servers whose `instructions` reach the model, so
# without this the gateway's servers lose theirs.
MCP_LAUNCH_ARGS = ("--allow-all-mcp-server-instructions",)
11 changes: 6 additions & 5 deletions src/ucode/agents/vscode.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from ucode.os_compatibility import subprocess_cross_os
from ucode.os_compatibility.system_cross_os import windows_program_files_dir
from ucode.string_utils import model_picker_label
from ucode.ui import console, print_heading, print_kv, print_warning
from ucode.ui import console, print_heading, print_kv, print_note, print_warning

EXTENSION_DIR = Path(__file__).resolve().parent.parent / "vscode" / "extension"
EXTENSION_ID = "databricks.unity-gateway"
Expand Down Expand Up @@ -251,9 +251,10 @@ def configure(state: dict, *, provider: str | None = None, location: str | None
after a warning, if any step fails."""
code = find_code()
if code is None:
print_warning(
"VS Code's `code` command wasn't found. Install VS Code (on macOS, also run "
"\"Shell Command: Install 'code' command in PATH\"), then rerun `ug configure --agents vscode`."
print_note(
"VS Code's `code` command wasn't found, so VS Code was skipped. To add it, install VS "
"Code (on macOS, also run \"Shell Command: Install 'code' command in PATH\"), then "
"rerun `ug configure --agents copilot`."
)
return False
try:
Expand All @@ -276,7 +277,7 @@ def configure(state: dict, *, provider: str | None = None, location: str | None
f"{install_extension(code, config)} with {len(models)} models{source}",
)
except RuntimeError as exc:
print_warning(f"{exc} Rerun `ug configure --agents vscode` to try again.")
print_warning(f"{exc} Rerun `ug configure --agents copilot` to try again.")
return False
print_heading("Next, in VS Code:")
for line in next_steps():
Expand Down
Loading
Loading