From 17430cc2dd8c80b0af4b46a04a7c148600b98f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yakup=20=C3=96zt=C3=BCrk?= Date: Thu, 4 Jun 2026 22:32:23 +0200 Subject: [PATCH 1/4] fix(windows): resolve Claude binary path and apiKeyHelper shell syntax on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that make `ucode claude` work on Windows without manual workarounds: 1. **WinError 2 — Claude binary not found** (`agents/claude.py`) Python's `subprocess` resolves PATH differently from cmd.exe and cannot find npm `.cmd` wrappers by name. Resolve `CLAUDE_BINARY` by walking from the `claude.cmd` wrapper to the actual `claude.exe` inside the npm package tree. Falls back to the wrapper path (or the bare `"claude"` string) if the .exe is absent, so non-Windows installs and non-standard npm layouts are unaffected. 2. **apiKeyHelper shell syntax error on Windows** (`databricks.py`) Claude Code runs the `apiKeyHelper` command via `cmd.exe` on Windows, which rejects the POSIX `[ -n "$VAR" ]` / `env -u` / `jq` syntax currently emitted by `build_auth_shell_command`. On `os.name == 'nt'` the function now returns a `python -c "..."` one-liner that handles the `DATABRICKS_BEARER` short- circuit and calls the Databricks CLI with `subprocess`, avoiding both the bash syntax and the `jq` dependency. POSIX behaviour is unchanged. 3. **Settings not visible to the Claude desktop / IDE extension** (`agents/claude.py`) `ucode claude` writes config to `~/.claude/ucode-settings.json` and passes `--settings` when launching the CLI, but the Claude desktop app and IDE extensions read `~/.claude/settings.json` by default. After writing the ucode-managed file, also merge the auth/env overlay into `settings.json` so the `apiKeyHelper` and `ANTHROPIC_*` vars are available in every launch path. A backup of the pre-existing `settings.json` is created alongside the existing ucode-settings backup. --- src/ucode/agents/claude.py | 32 +++++++++++++++++++++++++++++++- src/ucode/databricks.py | 25 ++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index d0d0380..714fc29 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -30,10 +30,31 @@ CLAUDE_CONFIG_DIR = Path.home() / ".claude" CLAUDE_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "ucode-settings.json" +CLAUDE_DEFAULT_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "settings.json" CLAUDE_BACKUP_PATH = APP_DIR / "claude-ucode-settings.backup.json" +CLAUDE_DEFAULT_BACKUP_PATH = APP_DIR / "claude-settings.backup.json" + +# On Windows, subprocess can fail to locate 'claude' via PATH because npm +# wrappers are .cmd files that Python's subprocess doesn't resolve the same +# way as cmd.exe. Resolve the actual .exe inside the npm package tree so +# the subprocess call is unambiguous on all platforms. +_claude_wrapper = shutil.which("claude.cmd") or shutil.which("claude") or shutil.which("claude.bat") +if _claude_wrapper: + _claude_root = Path(_claude_wrapper).parent + _claude_exe = ( + _claude_root + / "node_modules" + / "@anthropic-ai" + / "claude-code" + / "bin" + / "claude.exe" + ) + CLAUDE_BINARY = str(_claude_exe) if _claude_exe.exists() else _claude_wrapper +else: + CLAUDE_BINARY = "claude" SPEC: ToolSpec = { - "binary": "claude", + "binary": CLAUDE_BINARY, "package": "@anthropic-ai/claude-code", "display": "Claude Code", "config_path": CLAUDE_SETTINGS_PATH, @@ -215,6 +236,7 @@ def _unregister_web_search_mcp() -> None: def write_tool_config(state: dict, model: str) -> dict: backup_existing_file(CLAUDE_SETTINGS_PATH, CLAUDE_BACKUP_PATH) + backup_existing_file(CLAUDE_DEFAULT_SETTINGS_PATH, CLAUDE_DEFAULT_BACKUP_PATH) web_search_model = _resolve_web_search_model(state) overlay, managed_keys = render_overlay( state["workspace"], @@ -251,6 +273,14 @@ def write_tool_config(state: dict, model: str) -> dict: _remove_tracing_stop_hook(merged) write_json_file(CLAUDE_SETTINGS_PATH, merged) + # Mirror the auth/env overlay into ~/.claude/settings.json so Claude Code + # finds the apiKeyHelper and ANTHROPIC_* env vars regardless of whether it + # is launched via `ucode claude` (which passes --settings) or directly from + # the Claude desktop app / IDE extension (which reads settings.json by default). + existing_default = read_json_safe(CLAUDE_DEFAULT_SETTINGS_PATH) + merged_default = deep_merge_dict(existing_default, overlay) + write_json_file(CLAUDE_DEFAULT_SETTINGS_PATH, merged_default) + if web_search_model: _register_web_search_mcp(state["workspace"], web_search_model, state.get("profile")) diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index 90c1808..a74a0c7 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -13,6 +13,7 @@ import shlex import shutil import subprocess +import sys from pathlib import Path from typing import Literal, cast, overload from urllib import error as urllib_error @@ -956,7 +957,29 @@ def list_databricks_apps(workspace: str, profile: str | None = None) -> list[dic def build_auth_shell_command(workspace: str, profile: str | None = None) -> str: - workspace_arg = shlex.quote(workspace.rstrip("/")) + workspace_clean = workspace.rstrip("/") + + if os.name == "nt": + # On Windows, Claude Code runs apiKeyHelper via cmd.exe, which does not + # understand POSIX syntax ([ -n "$VAR" ], env -u, jq pipes). Instead we + # delegate to the Python interpreter that is running ucode — it is always + # available, handles DATABRICKS_BEARER short-circuit, and parses the JSON + # token response without requiring jq on PATH. + python_exe = sys.executable + databricks_exe = shutil.which("databricks") or "databricks" + profile_part = f", '--profile', {profile!r}" if profile else "" + helper_code = ( + "import json, os, subprocess, sys; " + "bearer=os.environ.get('DATABRICKS_BEARER'); " + "sys.exit(print(bearer) or 0) if bearer else None; " + f"cmd=[{databricks_exe!r},'auth','token','--host',{workspace_clean!r}" + f"{profile_part},'--force-refresh','--output','json']; " + "p=subprocess.run(cmd,capture_output=True,text=True,check=True,timeout=30); " + "print(json.loads(p.stdout)['access_token'])" + ) + return f'"{python_exe}" -c {helper_code!r}' + + workspace_arg = shlex.quote(workspace_clean) if profile: profile_arg = shlex.quote(profile) cli_command = ( From f146ad69c8df9abd1a8d50adb0b09c002ebb6b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yakup=20=C3=96zt=C3=BCrk?= Date: Mon, 22 Jun 2026 15:21:14 +0200 Subject: [PATCH 2/4] fix(windows): resolve Claude wrapper via `cmd /c`, not import-time probing Address review feedback on the Windows Claude support: - Keep SPEC["binary"] = "claude" stable; drop the import-time walk to claude.exe that made the spec environment-dependent and broke macOS/Linux tests. - Add claude_command(), which wraps an npm .cmd/.bat shim in `cmd /c` only on Windows and only at launch/validation time. CreateProcess can't execute a batch file directly, so passing the bare wrapper failed with WinError 2. - Split build_auth_shell_command into _build_posix_auth_command (unchanged) and _build_windows_auth_command. Windows no longer emits an inline `python -c` blob; it delegates to a stable `ucode internal print-databricks-token` subcommand that reuses get_databricks_token. Add focused tests for the per-platform binary resolution and auth command --- src/ucode/agents/claude.py | 98 +++++++++++++++-------- src/ucode/cli.py | 83 ++++++++++++++++++++ src/ucode/databricks.py | 63 ++++++++++----- tests/test_agent_claude.py | 157 ++++++++++++++++++++++++++++++++++++- tests/test_databricks.py | 84 ++++++++++++++++---- tests/test_state.py | 7 +- 6 files changed, 422 insertions(+), 70 deletions(-) diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index 714fc29..6f223eb 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -16,6 +16,7 @@ backup_existing_file, deep_merge_dict, read_json_safe, + restore_file, write_json_file, ) from ucode.databricks import ( @@ -30,31 +31,23 @@ CLAUDE_CONFIG_DIR = Path.home() / ".claude" CLAUDE_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "ucode-settings.json" -CLAUDE_DEFAULT_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "settings.json" CLAUDE_BACKUP_PATH = APP_DIR / "claude-ucode-settings.backup.json" +# Claude Code's *default* settings file. ucode never touches this unless the user +# opts in (see `state["claude_write_default_settings"]`): the `ucode claude` +# launcher passes `--settings ucode-settings.json`, but the Claude desktop app and +# the IDE / VS Code extension read settings.json directly and would otherwise not +# see the Databricks gateway auth. +CLAUDE_DEFAULT_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "settings.json" CLAUDE_DEFAULT_BACKUP_PATH = APP_DIR / "claude-settings.backup.json" - -# On Windows, subprocess can fail to locate 'claude' via PATH because npm -# wrappers are .cmd files that Python's subprocess doesn't resolve the same -# way as cmd.exe. Resolve the actual .exe inside the npm package tree so -# the subprocess call is unambiguous on all platforms. -_claude_wrapper = shutil.which("claude.cmd") or shutil.which("claude") or shutil.which("claude.bat") -if _claude_wrapper: - _claude_root = Path(_claude_wrapper).parent - _claude_exe = ( - _claude_root - / "node_modules" - / "@anthropic-ai" - / "claude-code" - / "bin" - / "claude.exe" - ) - CLAUDE_BINARY = str(_claude_exe) if _claude_exe.exists() else _claude_wrapper -else: - CLAUDE_BINARY = "claude" +# State key: when truthy, write_tool_config also mirrors the overlay into the +# default settings.json. Set by `ucode configure claude --write-default-settings`. +WRITE_DEFAULT_SETTINGS_KEY = "claude_write_default_settings" +# State key recording that ucode created settings.json itself (no pre-existing +# file to back up), so revert deletes rather than leaves it behind. +DEFAULT_SETTINGS_MANAGED_KEY = "claude_default_settings_managed" SPEC: ToolSpec = { - "binary": CLAUDE_BINARY, + "binary": "claude", "package": "@anthropic-ai/claude-code", "display": "Claude Code", "config_path": CLAUDE_SETTINGS_PATH, @@ -62,6 +55,22 @@ } +def claude_command(binary: str = "claude") -> list[str]: + """Return the argv prefix used to invoke the Claude CLI. + + On Windows, npm installs `claude` as a `.cmd`/`.bat` wrapper. Python's + subprocess/execvp resolves these differently from cmd.exe and `CreateProcess` + cannot execute a batch file directly, so a bare wrapper path fails. When the + resolved executable is such a wrapper, run it through `cmd /c`. On POSIX (and + when a real executable is found) the binary is returned unchanged, so the + default spec and macOS/Linux behaviour stay identical.""" + if os.name == "nt": + wrapper = shutil.which("claude.cmd") or shutil.which("claude.bat") or shutil.which(binary) + if wrapper and wrapper.lower().endswith((".cmd", ".bat")): + return ["cmd", "/c", wrapper] + return [binary] + + def is_update_available() -> tuple[str, str] | None: return available_npm_package_update(SPEC["package"]) @@ -236,7 +245,6 @@ def _unregister_web_search_mcp() -> None: def write_tool_config(state: dict, model: str) -> dict: backup_existing_file(CLAUDE_SETTINGS_PATH, CLAUDE_BACKUP_PATH) - backup_existing_file(CLAUDE_DEFAULT_SETTINGS_PATH, CLAUDE_DEFAULT_BACKUP_PATH) web_search_model = _resolve_web_search_model(state) overlay, managed_keys = render_overlay( state["workspace"], @@ -273,13 +281,12 @@ def write_tool_config(state: dict, model: str) -> dict: _remove_tracing_stop_hook(merged) write_json_file(CLAUDE_SETTINGS_PATH, merged) - # Mirror the auth/env overlay into ~/.claude/settings.json so Claude Code - # finds the apiKeyHelper and ANTHROPIC_* env vars regardless of whether it - # is launched via `ucode claude` (which passes --settings) or directly from - # the Claude desktop app / IDE extension (which reads settings.json by default). - existing_default = read_json_safe(CLAUDE_DEFAULT_SETTINGS_PATH) - merged_default = deep_merge_dict(existing_default, overlay) - write_json_file(CLAUDE_DEFAULT_SETTINGS_PATH, merged_default) + # Opt-in only: mirror the same overlay into the default ~/.claude/settings.json + # so the Claude desktop app / IDE extension (which ignore --settings) also pick + # up the Databricks gateway auth. Disabled by default to keep ucode's footprint + # limited to its own ucode-settings.json. + if state.get(WRITE_DEFAULT_SETTINGS_KEY): + state = _write_default_settings(state, overlay) if web_search_model: _register_web_search_mcp(state["workspace"], web_search_model, state.get("profile")) @@ -289,6 +296,33 @@ def write_tool_config(state: dict, model: str) -> dict: return state +def _write_default_settings(state: dict, overlay: dict) -> dict: + """Merge the ucode overlay into ~/.claude/settings.json, backing up any + pre-existing file first. Records whether ucode created the file (no prior + backup) so revert can delete rather than orphan it.""" + had_existing = CLAUDE_DEFAULT_SETTINGS_PATH.exists() + backup_existing_file(CLAUDE_DEFAULT_SETTINGS_PATH, CLAUDE_DEFAULT_BACKUP_PATH) + existing_default = read_json_safe(CLAUDE_DEFAULT_SETTINGS_PATH) + merged_default = deep_merge_dict(existing_default, overlay) + write_json_file(CLAUDE_DEFAULT_SETTINGS_PATH, merged_default) + # "Managed" (delete on revert) only when ucode created the file itself; if a + # file existed, the backup is what revert restores. + state[DEFAULT_SETTINGS_MANAGED_KEY] = not had_existing + return state + + +def revert_default_settings(state: dict) -> bool: + """Restore (or delete) ~/.claude/settings.json that the opt-in wrote. + + Returns True if anything was changed. No-op when ucode never wrote the + default settings (no backup and not ucode-managed).""" + managed = bool(state.get(DEFAULT_SETTINGS_MANAGED_KEY)) + restored = restore_file(CLAUDE_DEFAULT_SETTINGS_PATH, CLAUDE_DEFAULT_BACKUP_PATH, managed) + if restored: + state[DEFAULT_SETTINGS_MANAGED_KEY] = False + return restored + + def _is_tracing_stop_hook(hook: object) -> bool: if not isinstance(hook, dict): return False @@ -454,16 +488,16 @@ def default_model(state: dict) -> str | None: def launch(state: dict, tool_args: list[str]) -> None: - binary = SPEC["binary"] workspace = state.get("workspace") if workspace: os.environ["OAUTH_TOKEN"] = get_databricks_token(workspace, state.get("profile")) - os.execvp(binary, [binary, "--settings", str(CLAUDE_SETTINGS_PATH), *tool_args]) + cmd = claude_command(SPEC["binary"]) + os.execvp(cmd[0], [*cmd, "--settings", str(CLAUDE_SETTINGS_PATH), *tool_args]) def validate_cmd(binary: str) -> list[str]: return [ - binary, + *claude_command(binary), "--settings", str(CLAUDE_SETTINGS_PATH), "-p", diff --git a/src/ucode/cli.py b/src/ucode/cli.py index f32bddf..c17a560 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -22,6 +22,7 @@ validate_all_tools, validate_tool, ) +from ucode.agents import claude as claude_agent from ucode.agents import ( launch as launch_agent, ) @@ -280,6 +281,8 @@ def configure_workspace_command( print_err(f"{spec['display']}: {err}") managed = bool(state.get("managed_configs", {}).get(tool)) restore_file(spec["config_path"], spec["backup_path"], managed) + if tool == "claude": + claude_agent.revert_default_settings(state) available_tools = [t for t in (state.get("available_tools") or []) if t != tool] state["available_tools"] = available_tools save_state(state) @@ -432,6 +435,8 @@ def revert() -> int: pi_settings_restored = restore_file( PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH, bool(managed_configs.get("pi")) ) + # Undo the opt-in default ~/.claude/settings.json, if it was ever written. + claude_default_restored = claude_agent.revert_default_settings(state) clear_state() print_heading("Revert") @@ -439,6 +444,10 @@ def revert() -> int: for tool, spec in TOOL_SPECS.items(): print_kv(f"{spec['display']} config", "restored" if results[tool] else "unchanged") print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged") + print_kv( + "Claude default settings", + "restored" if claude_default_restored else "unchanged", + ) for client, spec in MCP_CLIENTS.items(): print_kv( f"{spec['display']} MCP config", @@ -462,6 +471,8 @@ def revert() -> int: app.add_typer(configure_app, name="configure", help="Configure workspace and tool settings.") mcp_app = typer.Typer(add_completion=False, no_args_is_help=True) app.add_typer(mcp_app, name="mcp", help="MCP servers exposed by ucode.") +internal_app = typer.Typer(add_completion=False, no_args_is_help=True, hidden=True) +app.add_typer(internal_app, name="internal", help="Internal helpers invoked by ucode itself.") @mcp_app.command("web-search") @@ -472,6 +483,20 @@ def mcp_web_search_cmd() -> None: serve() +@internal_app.command("print-databricks-token") +def print_databricks_token_cmd( + host: Annotated[str, typer.Option("--host", help="Databricks workspace URL.")], + profile: Annotated[str | None, typer.Option("--profile")] = None, +) -> None: + """Print a fresh Databricks access token to stdout. + + Used as the apiKeyHelper on Windows, where cmd.exe can't run the POSIX/jq + pipeline. Not intended for direct use.""" + from ucode.databricks import print_databricks_token + + print_databricks_token(host, profile) + + def _auto_configure_tool(tool: str) -> None: """First-time setup for a single tool — mirrors configure_workspace_command.""" existing = load_state() @@ -502,6 +527,8 @@ def _auto_configure_tool(tool: str) -> None: print_err(f"{spec['display']}: {err}") managed = bool(state.get("managed_configs", {}).get(tool)) restore_file(spec["config_path"], spec["backup_path"], managed) + if tool == "claude": + claude_agent.revert_default_settings(state) available_tools = [t for t in (state.get("available_tools") or []) if t != tool] state["available_tools"] = available_tools save_state(state) @@ -699,6 +726,62 @@ def configure_tracing( raise typer.Exit(130) from None +@configure_app.command("claude") +def configure_claude( + write_default_settings: Annotated[ + bool, + typer.Option( + "--write-default-settings/--no-write-default-settings", + help=( + "Also merge ucode's Claude config into ~/.claude/settings.json so the " + "Claude desktop app and the IDE / VS Code extension use the Databricks " + "gateway too — not just `ucode claude`. Off by default; ucode normally " + "manages only ~/.claude/ucode-settings.json. Use --no-write-default-settings " + "to turn it back off and restore the original file." + ), + ), + ] = False, +) -> None: + """Configure Claude Code, optionally writing the global ~/.claude/settings.json. + + By default ucode keeps its Claude config self-contained in ucode-settings.json + and injects it via `--settings` at launch. The desktop app and IDE extension + ignore that flag, so pass --write-default-settings to make them pick up the + Databricks gateway auth as well.""" + try: + state = ensure_provider_state("claude") + state = configure_shared_state( + state["workspace"], profile=state.get("profile"), tools=["claude"] + ) + # When turning the opt-in off, undo any previously-written global file + # *before* clearing the flag so revert_default_settings sees the managed + # marker, then rewrite the ucode-managed config without it. + if not write_default_settings: + claude_agent.revert_default_settings(state) + state[claude_agent.WRITE_DEFAULT_SETTINGS_KEY] = write_default_settings + save_state(state) + + state, model = resolve_launch_model("claude", state, None) + state = configure_tool("claude", state, model) + + if write_default_settings: + print_success( + "Claude configured. ~/.claude/settings.json now includes the Databricks " + "gateway auth, so the desktop app and IDE extension use it too." + ) + print_note( + "Undo with `ucode configure claude --no-write-default-settings` or `ucode revert`." + ) + else: + print_success("Claude configured (ucode-managed settings only).") + except RuntimeError as exc: + print_err(str(exc)) + raise typer.Exit(1) from None + except KeyboardInterrupt: + print_err("Interrupted.") + raise typer.Exit(130) from None + + @app.command("status") def status_cmd() -> None: """Show current workspace, tool configs, and saved model selections.""" diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index a74a0c7..edc10fd 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -13,7 +13,6 @@ import shlex import shutil import subprocess -import sys from pathlib import Path from typing import Literal, cast, overload from urllib import error as urllib_error @@ -957,28 +956,20 @@ def list_databricks_apps(workspace: str, profile: str | None = None) -> list[dic def build_auth_shell_command(workspace: str, profile: str | None = None) -> str: - workspace_clean = workspace.rstrip("/") + """Build the apiKeyHelper command an agent runs to print a fresh token. + The two platforms need different syntax: POSIX agents run the helper through + `sh`, while Claude Code on Windows runs it through cmd.exe, which can't parse + the POSIX `[ -n "$VAR" ]` / `env -u` / `jq` constructs. Each dialect lives in + its own builder so the quoting for each stays auditable; POSIX behaviour is + unchanged.""" + workspace_clean = workspace.rstrip("/") if os.name == "nt": - # On Windows, Claude Code runs apiKeyHelper via cmd.exe, which does not - # understand POSIX syntax ([ -n "$VAR" ], env -u, jq pipes). Instead we - # delegate to the Python interpreter that is running ucode — it is always - # available, handles DATABRICKS_BEARER short-circuit, and parses the JSON - # token response without requiring jq on PATH. - python_exe = sys.executable - databricks_exe = shutil.which("databricks") or "databricks" - profile_part = f", '--profile', {profile!r}" if profile else "" - helper_code = ( - "import json, os, subprocess, sys; " - "bearer=os.environ.get('DATABRICKS_BEARER'); " - "sys.exit(print(bearer) or 0) if bearer else None; " - f"cmd=[{databricks_exe!r},'auth','token','--host',{workspace_clean!r}" - f"{profile_part},'--force-refresh','--output','json']; " - "p=subprocess.run(cmd,capture_output=True,text=True,check=True,timeout=30); " - "print(json.loads(p.stdout)['access_token'])" - ) - return f'"{python_exe}" -c {helper_code!r}' + return _build_windows_auth_command(workspace_clean, profile) + return _build_posix_auth_command(workspace_clean, profile) + +def _build_posix_auth_command(workspace_clean: str, profile: str | None) -> str: workspace_arg = shlex.quote(workspace_clean) if profile: profile_arg = shlex.quote(profile) @@ -1000,6 +991,38 @@ def build_auth_shell_command(workspace: str, profile: str | None = None) -> str: ) +def _build_windows_auth_command(workspace_clean: str, profile: str | None) -> str: + """Windows apiKeyHelper: delegate to a stable `ucode` subcommand. + + cmd.exe can't run the POSIX/jq pipeline emitted for sh, and hand-rolling an + inline cmd.exe or `python -c` script that short-circuits DATABRICKS_BEARER + and parses JSON is fragile to quote. Instead we invoke + `ucode internal print-databricks-token`, which reuses the same token path + (DATABRICKS_BEARER short-circuit, profile resolution, JSON parsing) and + prints only the token. Values are double-quoted for cmd.exe.""" + ucode_exe = shutil.which("ucode") or "ucode" + parts = [ + f'"{ucode_exe}"', + "internal", + "print-databricks-token", + "--host", + f'"{workspace_clean}"', + ] + if profile: + parts += ["--profile", f'"{profile}"'] + return " ".join(parts) + + +def print_databricks_token(workspace: str, profile: str | None = None) -> None: + """Print a fresh Databricks access token to stdout — the Windows apiKeyHelper. + + Delegates to ``get_databricks_token`` so the DATABRICKS_BEARER short-circuit, + profile resolution, and non-interactive re-auth retry all apply identically + to the POSIX helper. Only the bare token is written to stdout (diagnostics go + to the debug log / stderr) so the agent reads a clean key.""" + print(get_databricks_token(workspace, profile, force_refresh=True)) + + def discover_claude_models(workspace: str, token: str) -> tuple[dict[str, str], str | None]: """Discover Claude families on this workspace's AI Gateway. diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index ea33c63..bc5948a 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -177,8 +177,48 @@ def test_returns_none_when_no_models(self): assert claude.default_model({"claude_models": {}}) is None +class TestClaudeCommand: + """`claude_command` resolves how the CLI is invoked per platform.""" + + def test_posix_returns_bare_binary(self, monkeypatch): + monkeypatch.setattr(os, "name", "posix") + assert claude.claude_command("claude") == ["claude"] + + def test_windows_wraps_cmd_wrapper_with_cmd_c(self, monkeypatch): + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr( + claude.shutil, "which", lambda name: r"C:\npm\claude.cmd" if "claude" in name else None + ) + assert claude.claude_command("claude") == ["cmd", "/c", r"C:\npm\claude.cmd"] + + def test_windows_wraps_bat_wrapper(self, monkeypatch): + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr( + claude.shutil, + "which", + lambda name: r"C:\npm\claude.bat" if name == "claude.bat" else None, + ) + assert claude.claude_command("claude") == ["cmd", "/c", r"C:\npm\claude.bat"] + + def test_windows_real_exe_left_unwrapped(self, monkeypatch): + # A resolved .exe (not a batch wrapper) is run directly. + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr( + claude.shutil, + "which", + lambda name: r"C:\npm\claude.exe" if name == "claude" else None, + ) + assert claude.claude_command("claude") == ["claude"] + + def test_windows_no_wrapper_falls_back_to_binary(self, monkeypatch): + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr(claude.shutil, "which", lambda name: None) + assert claude.claude_command("claude") == ["claude"] + + class TestClaudeValidateCmd: - def test_starts_with_binary(self): + def test_starts_with_binary(self, monkeypatch): + monkeypatch.setattr(os, "name", "posix") cmd = claude.validate_cmd("claude") assert cmd[0] == "claude" @@ -186,10 +226,20 @@ def test_has_p_flag(self): cmd = claude.validate_cmd("claude") assert "-p" in cmd - def test_uses_ucode_settings_file(self): + def test_uses_ucode_settings_file(self, monkeypatch): + monkeypatch.setattr(os, "name", "posix") cmd = claude.validate_cmd("claude") assert cmd[:3] == ["claude", "--settings", str(claude.CLAUDE_SETTINGS_PATH)] + def test_windows_validate_cmd_uses_cmd_wrapper(self, monkeypatch): + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr( + claude.shutil, "which", lambda name: r"C:\npm\claude.cmd" if "claude" in name else None + ) + cmd = claude.validate_cmd("claude") + assert cmd[:3] == ["cmd", "/c", r"C:\npm\claude.cmd"] + assert "--settings" in cmd + def test_has_max_turns(self): cmd = claude.validate_cmd("claude") assert "--max-turns" in cmd @@ -235,6 +285,79 @@ def test_explicit_override_used_over_codex_models(self, monkeypatch): assert calls == [("register", WS, "explicit-model")] +class TestWriteDefaultSettingsOptIn: + """The opt-in mirror of the overlay into ~/.claude/settings.json.""" + + def _patches(self, monkeypatch, written): + monkeypatch.setattr(claude, "backup_existing_file", lambda *a, **kw: True) + monkeypatch.setattr(claude, "read_json_safe", lambda path: {}) + monkeypatch.setattr(claude, "write_json_file", lambda path, payload: written.append(path)) + monkeypatch.setattr(claude, "save_state", lambda state: None) + monkeypatch.setattr(claude, "_register_web_search_mcp", lambda *a, **kw: True) + + def test_default_settings_not_written_when_flag_off(self, monkeypatch): + written: list = [] + self._patches(monkeypatch, written) + claude.write_tool_config({"workspace": WS}, "databricks-claude-sonnet-4") + assert claude.CLAUDE_DEFAULT_SETTINGS_PATH not in written + assert claude.CLAUDE_SETTINGS_PATH in written + + def test_default_settings_written_when_flag_on(self, monkeypatch): + written: list = [] + self._patches(monkeypatch, written) + state = {"workspace": WS, claude.WRITE_DEFAULT_SETTINGS_KEY: True} + claude.write_tool_config(state, "databricks-claude-sonnet-4") + assert claude.CLAUDE_DEFAULT_SETTINGS_PATH in written + + def test_marks_managed_when_file_did_not_exist(self, monkeypatch, tmp_path): + written: list = [] + self._patches(monkeypatch, written) + monkeypatch.setattr(claude, "CLAUDE_DEFAULT_SETTINGS_PATH", tmp_path / "settings.json") + state = {"workspace": WS, claude.WRITE_DEFAULT_SETTINGS_KEY: True} + state = claude.write_tool_config(state, "databricks-claude-sonnet-4") + # No pre-existing file → ucode owns it → delete on revert. + assert state[claude.DEFAULT_SETTINGS_MANAGED_KEY] is True + + def test_not_managed_when_file_existed(self, monkeypatch, tmp_path): + written: list = [] + self._patches(monkeypatch, written) + settings = tmp_path / "settings.json" + settings.write_text("{}", encoding="utf-8") + monkeypatch.setattr(claude, "CLAUDE_DEFAULT_SETTINGS_PATH", settings) + state = {"workspace": WS, claude.WRITE_DEFAULT_SETTINGS_KEY: True} + state = claude.write_tool_config(state, "databricks-claude-sonnet-4") + # A pre-existing file → restore from backup on revert, don't delete. + assert state[claude.DEFAULT_SETTINGS_MANAGED_KEY] is False + + +class TestRevertDefaultSettings: + def test_deletes_when_managed_and_no_backup(self, monkeypatch, tmp_path): + settings = tmp_path / "settings.json" + settings.write_text("{}", encoding="utf-8") + monkeypatch.setattr(claude, "CLAUDE_DEFAULT_SETTINGS_PATH", settings) + monkeypatch.setattr(claude, "CLAUDE_DEFAULT_BACKUP_PATH", tmp_path / "backup.json") + state = {claude.DEFAULT_SETTINGS_MANAGED_KEY: True} + assert claude.revert_default_settings(state) is True + assert not settings.exists() + assert state[claude.DEFAULT_SETTINGS_MANAGED_KEY] is False + + def test_restores_from_backup(self, monkeypatch, tmp_path): + settings = tmp_path / "settings.json" + settings.write_text('{"changed": true}', encoding="utf-8") + backup = tmp_path / "backup.json" + backup.write_text('{"original": true}', encoding="utf-8") + monkeypatch.setattr(claude, "CLAUDE_DEFAULT_SETTINGS_PATH", settings) + monkeypatch.setattr(claude, "CLAUDE_DEFAULT_BACKUP_PATH", backup) + state = {claude.DEFAULT_SETTINGS_MANAGED_KEY: False} + assert claude.revert_default_settings(state) is True + assert settings.read_text(encoding="utf-8") == '{"original": true}' + + def test_noop_when_nothing_written(self, monkeypatch, tmp_path): + monkeypatch.setattr(claude, "CLAUDE_DEFAULT_SETTINGS_PATH", tmp_path / "settings.json") + monkeypatch.setattr(claude, "CLAUDE_DEFAULT_BACKUP_PATH", tmp_path / "backup.json") + assert claude.revert_default_settings({}) is False + + class TestRegisterWebSearchMcp: def test_clears_existing_then_adds(self, monkeypatch): import ucode.mcp as mcp_mod @@ -331,6 +454,9 @@ def fake_execvp(binary: str, args: list[str]) -> None: exec_calls.append((binary, args)) raise RuntimeError("stop") + # Pin POSIX so the exec path is deterministic regardless of the host OS + # (on Windows claude_command would otherwise wrap the .cmd in `cmd /c`). + monkeypatch.setattr(os, "name", "posix") monkeypatch.delenv("OAUTH_TOKEN", raising=False) monkeypatch.setattr( claude, "get_databricks_token", lambda workspace, profile=None: "fresh-token" @@ -349,3 +475,30 @@ def fake_execvp(binary: str, args: list[str]) -> None: ["claude", "--settings", str(claude.CLAUDE_SETTINGS_PATH), "--debug"], ) ] + + def test_launch_wraps_cmd_wrapper_on_windows(self, monkeypatch): + exec_calls: list[tuple[str, list[str]]] = [] + + def fake_execvp(binary: str, args: list[str]) -> None: + exec_calls.append((binary, args)) + raise RuntimeError("stop") + + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr( + claude.shutil, "which", lambda name: r"C:\npm\claude.cmd" if "claude" in name else None + ) + monkeypatch.delenv("OAUTH_TOKEN", raising=False) + monkeypatch.setattr( + claude, "get_databricks_token", lambda workspace, profile=None: "fresh-token" + ) + monkeypatch.setattr(os, "execvp", fake_execvp) + + try: + claude.launch({"workspace": WS}, ["--debug"]) + except RuntimeError as exc: + assert str(exc) == "stop" + + binary, args = exec_calls[0] + assert binary == "cmd" + assert args[:3] == ["cmd", "/c", r"C:\npm\claude.cmd"] + assert args[-1] == "--debug" diff --git a/tests/test_databricks.py b/tests/test_databricks.py index fffdba5..c2e8955 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -132,18 +132,52 @@ def test_selects_opus_4_8_when_advertised(self, monkeypatch): class TestBuildAuthShellCommand: - def test_contains_workspace(self): + def test_dispatches_to_posix_off_windows(self, monkeypatch): + # On a POSIX host the public builder emits the sh/jq pipeline. + monkeypatch.setattr(os, "name", "posix") + cmd = build_auth_shell_command(WS) + assert "jq" in cmd + assert "DATABRICKS_BEARER" in cmd + + def test_dispatches_to_windows_on_nt(self, monkeypatch): + # On Windows the public builder delegates to the ucode subcommand. + monkeypatch.setattr(os, "name", "nt") cmd = build_auth_shell_command(WS) + assert "internal print-databricks-token" in cmd + assert "jq" not in cmd + + +class TestPosixAuthCommand: + """The POSIX apiKeyHelper — sh/jq pipeline. Tested directly so it runs on + any host regardless of os.name.""" + + def test_contains_workspace(self): + cmd = db_mod._build_posix_auth_command(WS, None) assert WS in cmd def test_parses_access_token(self): - cmd = build_auth_shell_command(WS) + cmd = db_mod._build_posix_auth_command(WS, None) assert "jq" in cmd assert ".access_token" in cmd assert "--force-refresh" in cmd assert "DATABRICKS_BEARER" in cmd assert "DATABRICKS_CONFIG_PROFILE" in cmd + def test_embeds_profile_when_provided(self): + cmd = db_mod._build_posix_auth_command(WS, "stablebox") + assert "--profile stablebox" in cmd + # We do not strip DATABRICKS_CONFIG_PROFILE when we are explicit about + # which profile to use — the --profile flag wins. + assert "env -u DATABRICKS_CONFIG_PROFILE" not in cmd + + def test_quotes_profile_shell_metacharacters(self): + cmd = db_mod._build_posix_auth_command(WS, "weird name; rm -rf /") + # shlex.quote should wrap the value so the rest of the command cannot + # be interpreted as a shell injection. + assert "rm -rf /" in cmd + assert "'weird name; rm -rf /'" in cmd + + @pytest.mark.skipif(os.name == "nt", reason="POSIX sh helper not runnable on Windows") def test_returns_token_when_auth_succeeds(self, tmp_path): # Fake databricks binary that always returns a valid token JSON. fake = tmp_path / "databricks" @@ -151,7 +185,7 @@ def test_returns_token_when_auth_succeeds(self, tmp_path): '#!/bin/sh\necho \'{"access_token": "good-token", "token_type": "Bearer"}\'\n' ) fake.chmod(0o755) - cmd = build_auth_shell_command(WS) + cmd = db_mod._build_posix_auth_command(WS, None) result = subprocess.run( ["sh", "-c", cmd], capture_output=True, @@ -164,11 +198,12 @@ def test_returns_token_when_auth_succeeds(self, tmp_path): ) assert result.stdout.strip() == "good-token" + @pytest.mark.skipif(os.name == "nt", reason="POSIX sh helper not runnable on Windows") def test_prefers_databricks_bearer(self, tmp_path): fake = tmp_path / "databricks" fake.write_text("#!/bin/sh\nexit 1\n") fake.chmod(0o755) - cmd = build_auth_shell_command(WS) + cmd = db_mod._build_posix_auth_command(WS, None) result = subprocess.run( ["sh", "-c", cmd], capture_output=True, @@ -181,19 +216,38 @@ def test_prefers_databricks_bearer(self, tmp_path): ) assert result.stdout.strip() == "bearer-token" + +class TestWindowsAuthCommand: + """The Windows apiKeyHelper — delegates to `ucode internal + print-databricks-token` (cmd.exe can't run the POSIX/jq pipeline).""" + + def test_invokes_ucode_subcommand(self): + cmd = db_mod._build_windows_auth_command(WS, None) + assert "internal print-databricks-token" in cmd + assert f'--host "{WS}"' in cmd + + def test_no_posix_or_jq_constructs(self): + cmd = db_mod._build_windows_auth_command(WS, None) + # None of the POSIX-only constructs that cmd.exe chokes on. + assert "jq" not in cmd + assert "env -u" not in cmd + assert "[ -n" not in cmd + def test_embeds_profile_when_provided(self): - cmd = build_auth_shell_command(WS, profile="stablebox") - assert "--profile stablebox" in cmd - # We do not strip DATABRICKS_CONFIG_PROFILE when we are explicit about - # which profile to use — the --profile flag wins. - assert "env -u DATABRICKS_CONFIG_PROFILE" not in cmd + cmd = db_mod._build_windows_auth_command(WS, "stablebox") + assert '--profile "stablebox"' in cmd - def test_quotes_profile_shell_metacharacters(self): - cmd = build_auth_shell_command(WS, profile="weird name; rm -rf /") - # shlex.quote should wrap the value so the rest of the command cannot - # be interpreted as a shell injection. - assert "rm -rf /" in cmd - assert "'weird name; rm -rf /'" in cmd + def test_quotes_values_for_cmd_exe(self): + # Values are double-quoted so cmd.exe treats spaces as part of the arg. + cmd = db_mod._build_windows_auth_command(WS, "my profile") + assert '--profile "my profile"' in cmd + + def test_prints_token_via_get_databricks_token(self, monkeypatch, capsys): + monkeypatch.setattr( + db_mod, "get_databricks_token", lambda ws, profile, force_refresh: "tok-123" + ) + db_mod.print_databricks_token(WS, "stablebox") + assert capsys.readouterr().out.strip() == "tok-123" class TestFormatSubprocessResult: diff --git a/tests/test_state.py b/tests/test_state.py index 6593a65..f2366fc 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -173,7 +173,12 @@ def test_populates_agent_state_when_workspace_present(self): assert result["agents"]["claude"]["model"] == "claude-opus" assert result["agents"]["claude"]["base_url"] == FAKE_URLS["claude"] - assert result["agents"]["claude"]["auth_command"].startswith("if [ -n") + # auth_command is platform-specific (POSIX sh vs the Windows ucode helper); + # assert it matches the builder's output for the current OS rather than a + # POSIX-only prefix. + from ucode.databricks import build_auth_shell_command + + assert result["agents"]["claude"]["auth_command"] == build_auth_shell_command(FAKE_WS) assert result["agents"]["codex"]["model"] == "gpt-5" assert result["agents"]["codex"]["base_url"] == FAKE_URLS["codex"] assert ( From 48b27c2cb6852cc160b78861dd65ce226226d762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yakup=20=C3=96zt=C3=BCrk?= Date: Mon, 22 Jun 2026 15:22:28 +0200 Subject: [PATCH 3/4] fix(windows): run npm-installed CLIs via `cmd /c` npm install/outdated and the claude/codex/gemini MCP registrations invoked the bare CLI name through subprocess, which fails on Windows with WinError 2 because the executables are .cmd shims and CreateProcess doesn't apply PATHEXT. Add ucode/proc.py with cli_command()/npm_command(), which resolve the shim and wrap it in `cmd /c` on Windows (POSIX unchanged), and route npm and MCP calls through it. Add tests. --- src/ucode/agent_updates.py | 8 ++-- src/ucode/agents/__init__.py | 14 +++++-- src/ucode/mcp.py | 73 +++++++++++++++++++++++------------- src/ucode/proc.py | 28 ++++++++++++++ tests/test_agent_updates.py | 8 ++-- tests/test_agents_init.py | 15 +++++--- tests/test_mcp.py | 34 +++++++++++++++++ tests/test_proc.py | 60 +++++++++++++++++++++++++++++ 8 files changed, 196 insertions(+), 44 deletions(-) create mode 100644 src/ucode/proc.py create mode 100644 tests/test_proc.py diff --git a/src/ucode/agent_updates.py b/src/ucode/agent_updates.py index f315583..45a27e7 100644 --- a/src/ucode/agent_updates.py +++ b/src/ucode/agent_updates.py @@ -3,16 +3,18 @@ from __future__ import annotations import json -import shutil import subprocess +from ucode.proc import npm_command + def available_npm_package_update(package: str) -> tuple[str, str] | None: - if not shutil.which("npm"): + npm = npm_command("outdated", "-g", "--json", package) + if npm is None: return None try: result = subprocess.run( - ["npm", "outdated", "-g", "--json", package], + npm, capture_output=True, text=True, timeout=10, diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 4770971..8090197 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -20,6 +20,7 @@ from ucode.databricks import ( install_databricks_cli, ) +from ucode.proc import npm_command from ucode.state import load_state, save_state from ucode.telemetry import agent_version from ucode.ui import ( @@ -75,13 +76,14 @@ def _update_installed_tool_binary(tool: str) -> bool: binary = spec["binary"] package = spec["package"] - if not shutil.which("npm"): + npm = npm_command("install", "-g", package) + if npm is None: print_warning(f"`npm` is not available to update {spec['display']}; continuing.") return False print_note(f"Updating {spec['display']}...") try: - subprocess.run(["npm", "install", "-g", package], check=True, timeout=300) + subprocess.run(npm, check=True, timeout=300) except (subprocess.CalledProcessError, subprocess.TimeoutExpired): print_warning(f"Could not update {spec['display']}; continuing.") return False @@ -135,7 +137,8 @@ def install_tool_binary(tool: str, *, strict: bool = True, update_existing: bool raise RuntimeError(version_error) return True - if not shutil.which("npm"): + npm = npm_command("install", "-g", package) + if npm is None: message = f"`{binary}` is not installed and npm is not available to install it." if strict: raise RuntimeError(message) @@ -145,7 +148,7 @@ def install_tool_binary(tool: str, *, strict: bool = True, update_existing: bool print_section("Bootstrap") print_warning(f"`{binary}` was not found. Installing {spec['display']}...") try: - subprocess.run(["npm", "install", "-g", package], check=True, timeout=300) + subprocess.run(npm, check=True, timeout=300) except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: message = f"Failed to install {spec['display']} automatically." if strict: @@ -415,6 +418,9 @@ def validate_all_tools(state: dict) -> None: # Rollback settings.json for Pi if tool == "pi": restore_file(PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH, managed) + # Rollback the opt-in default ~/.claude/settings.json for Claude. + if tool == "claude": + claude.revert_default_settings(state) available_tools.remove(tool) state["available_tools"] = available_tools save_state(state) diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index ba94521..3cc4924 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -32,6 +32,7 @@ list_genie_spaces, workspace_hostname, ) +from ucode.proc import cli_command from ucode.state import load_full_state, load_state, save_state from ucode.ui import ( print_note, @@ -96,9 +97,12 @@ def build_mcp_http_entry(url: str) -> dict: def add_claude_mcp_server(name: str, entry: dict, scope: str = MCP_USER_SCOPE) -> None: + cmd = cli_command("claude", "mcp", "add-json", name, json.dumps(entry), "-s", scope) + if cmd is None: + raise RuntimeError("`claude` CLI is not available to add MCP server.") try: subprocess.run( - ["claude", "mcp", "add-json", name, json.dumps(entry), "-s", scope], + cmd, check=True, capture_output=True, text=True, @@ -119,9 +123,12 @@ def _is_missing_mcp_server_output(output: str) -> bool: def remove_claude_mcp_server(name: str, scope: str) -> bool: + cmd = cli_command("claude", "mcp", "remove", name, "-s", scope) + if cmd is None: + return False try: subprocess.run( - ["claude", "mcp", "remove", name, "-s", scope], + cmd, check=True, capture_output=True, text=True, @@ -136,18 +143,21 @@ def remove_claude_mcp_server(name: str, scope: str) -> bool: def add_codex_mcp_server(name: str, url: str) -> None: + cmd = cli_command( + "codex", + "mcp", + "add", + name, + "--url", + url, + "--bearer-token-env-var", + MCP_AUTH_TOKEN_ENV_VAR, + ) + if cmd is None: + raise RuntimeError("`codex` CLI is not available to add MCP server.") try: subprocess.run( - [ - "codex", - "mcp", - "add", - name, - "--url", - url, - "--bearer-token-env-var", - MCP_AUTH_TOKEN_ENV_VAR, - ], + cmd, check=True, capture_output=True, text=True, @@ -158,9 +168,12 @@ def add_codex_mcp_server(name: str, url: str) -> None: def remove_codex_mcp_server(name: str) -> bool: + cmd = cli_command("codex", "mcp", "remove", name) + if cmd is None: + return False try: result = subprocess.run( - ["codex", "mcp", "remove", name], + cmd, check=False, capture_output=True, text=True, @@ -178,21 +191,24 @@ def remove_codex_mcp_server(name: str) -> bool: def add_gemini_mcp_server(name: str, url: str) -> None: + cmd = cli_command( + "gemini", + "mcp", + "add", + name, + url, + "--type", + "http", + "--scope", + MCP_USER_SCOPE, + "--header", + f"Authorization: Bearer ${{{MCP_AUTH_TOKEN_ENV_VAR}}}", + ) + if cmd is None: + raise RuntimeError("`gemini` CLI is not available to add MCP server.") try: subprocess.run( - [ - "gemini", - "mcp", - "add", - name, - url, - "--type", - "http", - "--scope", - MCP_USER_SCOPE, - "--header", - f"Authorization: Bearer ${{{MCP_AUTH_TOKEN_ENV_VAR}}}", - ], + cmd, check=True, capture_output=True, text=True, @@ -203,9 +219,12 @@ def add_gemini_mcp_server(name: str, url: str) -> None: def remove_gemini_mcp_server(name: str) -> bool: + cmd = cli_command("gemini", "mcp", "remove", name, "--scope", MCP_USER_SCOPE) + if cmd is None: + return False try: result = subprocess.run( - ["gemini", "mcp", "remove", name, "--scope", MCP_USER_SCOPE], + cmd, check=False, capture_output=True, text=True, diff --git a/src/ucode/proc.py b/src/ucode/proc.py new file mode 100644 index 0000000..57259c8 --- /dev/null +++ b/src/ucode/proc.py @@ -0,0 +1,28 @@ +"""Subprocess helpers for running npm-installed CLIs across platforms.""" + +from __future__ import annotations + +import os +import shutil + + +def cli_command(name: str, *args: str) -> list[str] | None: + """Resolve a `` `` invocation for a CLI on PATH, or None when + it isn't installed. + + On Windows, npm installs its CLIs (npm, claude, codex, gemini, ...) as + ``.cmd`` wrappers. A bare ``"name"`` passed to subprocess fails with WinError + 2 because CreateProcess doesn't apply PATHEXT, and a ``.cmd`` can't be + executed directly either — so the resolved wrapper is run through ``cmd /c``. + On POSIX (and for a real executable) the resolved path is used unchanged.""" + exe = shutil.which(name) + if not exe: + return None + if os.name == "nt" and exe.lower().endswith((".cmd", ".bat")): + return ["cmd", "/c", exe, *args] + return [exe, *args] + + +def npm_command(*args: str) -> list[str] | None: + """Convenience wrapper for ``cli_command("npm", ...)``.""" + return cli_command("npm", *args) diff --git a/tests/test_agent_updates.py b/tests/test_agent_updates.py index 3f88300..35d26d8 100644 --- a/tests/test_agent_updates.py +++ b/tests/test_agent_updates.py @@ -8,13 +8,13 @@ def test_returns_none_when_npm_missing(monkeypatch): - monkeypatch.setattr("ucode.agent_updates.shutil.which", lambda _: None) + monkeypatch.setattr("ucode.agent_updates.npm_command", lambda *args: None) assert available_npm_package_update("opencode-ai") is None def test_returns_none_when_package_is_current(monkeypatch): - monkeypatch.setattr("ucode.agent_updates.shutil.which", lambda _: "/usr/bin/npm") + monkeypatch.setattr("ucode.agent_updates.npm_command", lambda *args: ["npm", *args]) monkeypatch.setattr( "ucode.agent_updates.subprocess.run", lambda *args, **kwargs: subprocess.CompletedProcess(args[0], 0, stdout="{}", stderr=""), @@ -24,7 +24,7 @@ def test_returns_none_when_package_is_current(monkeypatch): def test_returns_current_and_latest_when_outdated(monkeypatch): - monkeypatch.setattr("ucode.agent_updates.shutil.which", lambda _: "/usr/bin/npm") + monkeypatch.setattr("ucode.agent_updates.npm_command", lambda *args: ["npm", *args]) def fake_run(*args, **kwargs): return subprocess.CompletedProcess( @@ -40,7 +40,7 @@ def fake_run(*args, **kwargs): def test_returns_none_for_malformed_output(monkeypatch): - monkeypatch.setattr("ucode.agent_updates.shutil.which", lambda _: "/usr/bin/npm") + monkeypatch.setattr("ucode.agent_updates.npm_command", lambda *args: ["npm", *args]) monkeypatch.setattr( "ucode.agent_updates.subprocess.run", lambda *args, **kwargs: subprocess.CompletedProcess( diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index 15d1e36..b6f9651 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -180,21 +180,24 @@ def test_raises_when_no_models_available(self): class TestInstallToolBinary: + @pytest.fixture(autouse=True) + def _stub_npm_command(self, monkeypatch): + """Resolve npm to the bare ``["npm", *args]`` form so install argv + assertions don't depend on the test host's PATH. The npm-missing test + overrides this with None.""" + monkeypatch.setattr("ucode.agents.npm_command", lambda *args: ["npm", *args]) + def test_non_strict_returns_false_when_npm_missing(self, monkeypatch): monkeypatch.setattr("ucode.agents.shutil.which", lambda _: None) + monkeypatch.setattr("ucode.agents.npm_command", lambda *args: None) assert install_tool_binary("opencode", strict=False) is False def test_non_strict_returns_false_when_install_fails(self, monkeypatch): - def fake_which(binary: str) -> str | None: - if binary == "npm": - return "/usr/bin/npm" - return None - def fake_run(*args, **kwargs): raise subprocess.CalledProcessError(1, args[0]) - monkeypatch.setattr("ucode.agents.shutil.which", fake_which) + monkeypatch.setattr("ucode.agents.shutil.which", lambda binary: None) monkeypatch.setattr("ucode.agents.subprocess.run", fake_run) assert install_tool_binary("opencode", strict=False) is False diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 2c15495..44f77a6 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -6,6 +6,8 @@ import subprocess from unittest.mock import MagicMock +import pytest + from ucode import mcp WS = "https://example.databricks.com" @@ -13,6 +15,14 @@ ALL_MCP_CLIENTS = ["claude", "codex", "gemini", "opencode", "copilot"] +@pytest.fixture(autouse=True) +def _stub_cli_command(monkeypatch): + """Resolve CLI argv to the bare ``[name, *args]`` form so argv assertions + don't depend on the test host's PATH (real `cmd /c` wrapping is covered in + test_proc.py). Tests that need the not-installed branch override this.""" + monkeypatch.setattr(mcp, "cli_command", lambda name, *args: [name, *args]) + + class TestBuildMcpHttpEntry: def test_uses_http_url(self): entry = mcp.build_mcp_http_entry(f"{WS}/api/2.0/mcp/external/github") @@ -48,6 +58,30 @@ def fake_run(args, **kwargs): assert "env" not in calls[0]["kwargs"] +class TestMcpCliMissing: + """When the target CLI isn't on PATH, cli_command returns None: add raises, + remove is a no-op returning False.""" + + def test_add_claude_raises_when_cli_missing(self, monkeypatch): + monkeypatch.setattr(mcp, "cli_command", lambda name, *args: None) + entry = mcp.build_mcp_http_entry(f"{WS}/api/2.0/mcp/external/github") + with pytest.raises(RuntimeError, match="claude"): + mcp.add_claude_mcp_server("github", entry) + + def test_remove_claude_false_when_cli_missing(self, monkeypatch): + monkeypatch.setattr(mcp, "cli_command", lambda name, *args: None) + assert mcp.remove_claude_mcp_server("github", "user") is False + + def test_add_codex_raises_when_cli_missing(self, monkeypatch): + monkeypatch.setattr(mcp, "cli_command", lambda name, *args: None) + with pytest.raises(RuntimeError, match="codex"): + mcp.add_codex_mcp_server("github", f"{WS}/api/2.0/mcp/external/github") + + def test_remove_gemini_false_when_cli_missing(self, monkeypatch): + monkeypatch.setattr(mcp, "cli_command", lambda name, *args: None) + assert mcp.remove_gemini_mcp_server("github") is False + + class TestAddCodexMcpServer: def test_adds_http_server_with_bearer_token_env(self, monkeypatch): calls: list[dict] = [] diff --git a/tests/test_proc.py b/tests/test_proc.py new file mode 100644 index 0000000..d991232 --- /dev/null +++ b/tests/test_proc.py @@ -0,0 +1,60 @@ +"""Tests for proc.py — cross-platform npm/CLI command resolution.""" + +from __future__ import annotations + +import os + +import ucode.proc as proc +from ucode.proc import cli_command, npm_command + + +class TestCliCommand: + def test_returns_none_when_not_on_path(self, monkeypatch): + monkeypatch.setattr(proc.shutil, "which", lambda name: None) + assert cli_command("claude", "mcp", "list") is None + + def test_posix_uses_resolved_path(self, monkeypatch): + monkeypatch.setattr(os, "name", "posix") + monkeypatch.setattr(proc.shutil, "which", lambda name: "/usr/local/bin/claude") + assert cli_command("claude", "mcp", "list") == [ + "/usr/local/bin/claude", + "mcp", + "list", + ] + + def test_windows_wraps_cmd_wrapper(self, monkeypatch): + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr(proc.shutil, "which", lambda name: r"C:\npm\claude.cmd") + assert cli_command("claude", "mcp", "list") == [ + "cmd", + "/c", + r"C:\npm\claude.cmd", + "mcp", + "list", + ] + + def test_windows_wraps_bat_wrapper(self, monkeypatch): + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr(proc.shutil, "which", lambda name: r"C:\npm\npm.bat") + assert cli_command("npm", "install")[:3] == ["cmd", "/c", r"C:\npm\npm.bat"] + + def test_windows_real_exe_unwrapped(self, monkeypatch): + monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr(proc.shutil, "which", lambda name: r"C:\tools\claude.exe") + assert cli_command("claude") == [r"C:\tools\claude.exe"] + + +class TestNpmCommand: + def test_delegates_to_cli_command(self, monkeypatch): + monkeypatch.setattr(os, "name", "posix") + monkeypatch.setattr(proc.shutil, "which", lambda name: f"/usr/bin/{name}") + assert npm_command("install", "-g", "pkg") == [ + "/usr/bin/npm", + "install", + "-g", + "pkg", + ] + + def test_none_when_npm_missing(self, monkeypatch): + monkeypatch.setattr(proc.shutil, "which", lambda name: None) + assert npm_command("install") is None From 18a51a9388f3131bb73631e8da55899e7d74271b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yakup=20=C3=96zt=C3=BCrk?= Date: Mon, 22 Jun 2026 15:23:38 +0200 Subject: [PATCH 4/4] feat(claude): opt-in mirror of config into ~/.claude/settings.json `ucode claude` injects ucode-settings.json via --settings, but the Claude desktop app and the IDE/VS Code extension read ~/.claude/settings.json and so never pick up the Databricks gateway auth. Add `ucode configure claude --write-default-settings` (off by default) to also merge the overlay into settings.json, backing up any existing file. Wire the restore into `ucode revert` and every validation-failure rollback path; use --no-write-default-settings to turn it back off and restore the original. Document the trade-off in the README and add tests. --- README.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 77f9b33..d42d556 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,32 @@ Discovered external MCP connections are listed directly. MCP auth uses a Databri | `ucode configure --dry-run` | Preview config files without writing them | | `ucode configure --agents claude,codex` | Configure specific agents without the interactive picker | | `ucode configure --workspaces https://first.databricks.com,https://second.databricks.com` | Configure workspaces without the interactive picker | +| `ucode configure claude --write-default-settings` | Also write `~/.claude/settings.json` so the Claude desktop app and IDE/VS Code extension use the Databricks gateway (see below) | + +### Using Claude outside `ucode claude` (desktop app / IDE extension) + +By default `ucode` keeps its Claude configuration self-contained in +`~/.claude/ucode-settings.json` and injects it via `--settings` when you run +`ucode claude`. The Claude desktop app and the IDE / VS Code extension ignore +that flag — they read `~/.claude/settings.json` — so they do **not** pick up the +Databricks gateway auth on their own. + +If you want those launch paths to use the gateway too, opt in: + +```bash +ucode configure claude --write-default-settings +``` + +This merges ucode's `apiKeyHelper` and `ANTHROPIC_*` overlay into +`~/.claude/settings.json` (backing up any existing file first). It is off by +default because it affects **all** Claude usage on your machine, including plain +`claude`. To turn it back off and restore the original file: + +```bash +ucode configure claude --no-write-default-settings +``` + +`ucode revert` also restores it. ## Managed Local Files @@ -98,7 +124,8 @@ Discovered external MCP connections are listed directly. MCP auth uses a Databri | File | Tool | |------|------| | `~/.codex/config.toml` | Codex | -| `~/.claude/settings.json` | Claude Code | +| `~/.claude/ucode-settings.json` | Claude Code (always) | +| `~/.claude/settings.json` | Claude Code (only with `configure claude --write-default-settings`) | | `~/.gemini/.env` | Gemini CLI | | `~/.config/opencode/opencode.json` | OpenCode | | `~/.copilot/.env` | GitHub Copilot CLI |