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
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down
8 changes: 5 additions & 3 deletions src/ucode/agent_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 10 additions & 4 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
70 changes: 67 additions & 3 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
backup_existing_file,
deep_merge_dict,
read_json_safe,
restore_file,
write_json_file,
)
from ucode.databricks import (
Expand All @@ -31,6 +32,19 @@
CLAUDE_CONFIG_DIR = Path.home() / ".claude"
CLAUDE_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "ucode-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"
# 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",
Expand All @@ -41,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"])

Expand Down Expand Up @@ -251,6 +281,13 @@ def write_tool_config(state: dict, model: str) -> dict:
_remove_tracing_stop_hook(merged)
write_json_file(CLAUDE_SETTINGS_PATH, merged)

# 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"))

Expand All @@ -259,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
Expand Down Expand Up @@ -424,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",
Expand Down
83 changes: 83 additions & 0 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -432,13 +435,19 @@ 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")
print_kv("Workspace", state.get("workspace") or "none")
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",
Expand All @@ -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")
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down
Loading