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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ ucode configure skills --location main.default,ml.prod --mcp
same.
- **Download mode** (with `--location`, no `--mcp`) writes each skill flat as `<leaf>/SKILL.md`
(plus its bundled files) into both `.claude/skills/` and `.agents/skills/`. `--path` (an existing
absolute directory) is optional; when omitted, skills are written under your home directory. Any
absolute directory) is optional; when omitted, skills are written to Claude Code's config
directory (`~/.claude` by default, or `CLAUDE_CONFIG_DIR` when set) and `~/.agents/skills`. Any
pre-existing skill dir prompts before it's overwritten. It then registers a schema-less skills
MCP connection, leaving any prior `--mcp` scope untouched. `--skill <name>[,<name>…]` narrows the
download to the named skills (by leaf name) from the schema instead of all of them; requested
Expand Down Expand Up @@ -260,7 +261,7 @@ pick the new config up on their next ucode run.
| File | Tool |
|------|------|
| `~/.codex/config.toml` | Codex |
| `~/.claude/settings.json` | Claude Code |
| `~/.claude/settings.json` | Claude Code (set `CLAUDE_CONFIG_DIR` to use a different directory) |
| `~/.gemini/.env` | Gemini CLI |
| `~/.config/opencode/opencode.json` | OpenCode |
| `~/.copilot/.env` | GitHub Copilot CLI |
Expand Down
14 changes: 11 additions & 3 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Claude Code agent: writes ~/.claude/settings.json env block."""
"""Claude Code agent: writes the settings.json env block into Claude Code's config
directory (``~/.claude`` by default, or ``$CLAUDE_CONFIG_DIR`` when set)."""

from __future__ import annotations

Expand Down Expand Up @@ -40,7 +41,14 @@
from ucode.tracing import tracing_env
from ucode.ui import print_err, print_note, print_success, print_warning

CLAUDE_CONFIG_DIR = Path.home() / ".claude"

def claude_config_dir() -> Path:
"""Claude Code's user config directory: ``$CLAUDE_CONFIG_DIR`` if set, else ``~/.claude``."""
override = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
return Path(override).expanduser() if override else Path.home() / ".claude"


CLAUDE_CONFIG_DIR = claude_config_dir()
CLAUDE_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "ucode-settings.json"
CLAUDE_BACKUP_PATH = APP_DIR / "claude-ucode-settings.backup.json"

Expand Down Expand Up @@ -255,7 +263,7 @@ def render_overlay(
"""Return (overlay, managed_key_paths) for Claude settings.json.

NOTE: MCP servers are NOT written here. Claude Code reads `mcpServers`
from `~/.claude.json`, not `~/.claude/settings.json` — registration goes
from `.claude.json`, not `settings.json` — registration goes
through `claude mcp add-json` (see `_register_web_search_mcp`).

When `provider` is set (a `<catalog>.<schema>.<name>` Model Provider
Expand Down
6 changes: 3 additions & 3 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1486,7 +1486,7 @@ def _download_managed_skills(managed: dict, state: dict) -> None:

Registering the skills MCP connection (see :func:`_apply_managed_skills`) exposes the skill
*tools* over the gateway, but the agent's ``/skills`` picker reads skill bundles from
``~/.claude/skills`` / ``~/.agents/skills`` on disk. Without this download those directories stay
``<claude config dir>/skills`` / ``~/.agents/skills`` on disk. Without this download those stay
empty, so a workspace-published skill never shows up in ``/skills``. Skills already on disk are
left untouched, so a steady-state launch only lists each schema and writes nothing. Best-effort:
a failure here never blocks the launch.
Expand Down Expand Up @@ -2534,7 +2534,7 @@ def configure_skills(
str | None,
typer.Option(
"--path",
help="(download) Existing absolute dir to download into; defaults to your home dir.",
help="(download) Existing absolute dir to download into; defaults to the user scope.",
),
] = None,
skill: Annotated[
Expand All @@ -2554,7 +2554,7 @@ def configure_skills(

When ``--location`` is provided: with ``--mcp``, sets the connection's scope to
exactly the listed schemas (no download); otherwise, downloads every skill in
each schema to disk (under ``--path``, or your home dir when omitted) and
each schema to disk (under ``--path``, or the user-scope skill dirs when omitted) and
registers the MCP connection with utility tools only. ``--skill`` narrows a
download to a named subset of a single schema's skills (requires exactly one
``--location``).
Expand Down
6 changes: 3 additions & 3 deletions src/ucode/managed_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
this module resolves them *per key* at config-write time: whatever the manifest specifies wins, and
anything it leaves unset falls back to
the developer's ucode state. The resolved view is what gets rendered into the agent config files
(e.g. ``~/.claude/ucode-settings.json``), so managed settings take precedence for every ``ucode``
command without either file being rewritten.
(e.g. ``~/.claude/ucode-settings.json``, under ``$CLAUDE_CONFIG_DIR`` when that is set), so managed
settings take precedence for every ``ucode`` command without either file being rewritten.

Only settings the developer set *through* ucode participate in the fallback. Settings they wrote by
hand outside ucode (``~/.claude/settings.json``, etc.) are not read here — Claude Code merges
hand outside ucode (Claude Code's own ``settings.json``, etc.) are not read here — Claude Code merges
those scopes itself at launch, underneath the file ucode passes via ``--settings``.

Everything here is pure: no I/O, no mutation of the inputs. Fetching and persisting the manifest,
Expand Down
2 changes: 1 addition & 1 deletion src/ucode/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -1449,7 +1449,7 @@ def apply_mcp_server_changes(
# rewrites a config file, so a large diff means hundreds of operations; we
# run them concurrently ACROSS clients but SERIALLY within a client, since
# every operation for one client mutates that client's single shared config
# (`claude mcp add-json` edits ~/.claude.json, etc.) and concurrent
# (`claude mcp add-json` edits Claude Code's .claude.json, etc.) and concurrent
# read-modify-writes would clobber each other.
work: dict[str, list[Callable[[], object]]] = {client: [] for client in clients}
changed = False
Expand Down
24 changes: 14 additions & 10 deletions src/ucode/skills_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path
from urllib.parse import urlencode

from ucode.agents.claude import claude_config_dir
from ucode.databricks import (
_http_get_bytes,
_http_get_json,
Expand All @@ -25,7 +26,9 @@
)

# `.claude/skills` (Claude) + `.agents/skills` (the alias other agents read).
# Project scope only — user scope resolves the Claude root via `claude_config_dir()`.
SKILL_BASE_DIR_NAMES = (".claude/skills", ".agents/skills")
AGENTS_SKILL_DIR_NAME = ".agents/skills"

SKILL_FILES_API_PREFIX = "Skills"

Expand Down Expand Up @@ -197,19 +200,20 @@ def fetch_skill_bundle(


def skill_dir_roots(project_dir: str | None) -> list[Path]:
"""The ``.claude/skills`` and ``.agents/skills`` roots to download into.
"""The Claude and ``.agents/skills`` roots to download into.

``project_dir`` must be an existing absolute directory when given; when
omitted, roots default to the user's home directory (user scope).
``project_dir`` must be an existing absolute directory when given; both roots
are then relative to it. When omitted (user scope), the Claude root is
``<claude config dir>/skills`` — honoring ``$CLAUDE_CONFIG_DIR`` so skills land
where the launched ``claude`` reads them — and the alias root stays under home.
"""
if project_dir is None:
base = Path.home()
else:
base = Path(project_dir)
if not base.is_absolute():
raise ValueError(f"--path must be an absolute path, got `{project_dir}`.")
if not base.is_dir():
raise ValueError(f"--path directory does not exist: `{project_dir}`.")
return [claude_config_dir() / "skills", Path.home() / AGENTS_SKILL_DIR_NAME]
base = Path(project_dir)
if not base.is_absolute():
raise ValueError(f"--path must be an absolute path, got `{project_dir}`.")
if not base.is_dir():
raise ValueError(f"--path directory does not exist: `{project_dir}`.")
return [base / name for name in SKILL_BASE_DIR_NAMES]


Expand Down
3 changes: 2 additions & 1 deletion src/ucode/smart_routing/claude_hooks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Claude Code hook configuration for smart subagent routing.

Written into ``~/.claude/ucode-settings.json`` under Claude Code's hook events.
Written into ``ucode-settings.json`` in Claude Code's config directory (``~/.claude``
by default, or ``$CLAUDE_CONFIG_DIR`` when set) under Claude Code's hook events.
The ``PreToolUse`` hook matches the subagent-spawn tool (``Agent``, formerly
``Task``) and rewrites its ``model`` input to the router's pick; ``SessionStart``
and ``SubagentStart`` drive the canary/audit trail. Mirrors ``codex_hooks`` but
Expand Down
25 changes: 23 additions & 2 deletions tests/test_agent_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ def test_display(self):
assert claude.SPEC["display"] == "Claude Code"


class TestClaudeConfigDir:
def test_defaults_to_home_dot_claude(self, tmp_path, monkeypatch):
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
assert claude.claude_config_dir() == tmp_path / ".claude"

def test_env_var_overrides_default(self, tmp_path, monkeypatch):
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "work"))
assert claude.claude_config_dir() == tmp_path / "work"

def test_env_var_expands_tilde(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("CLAUDE_CONFIG_DIR", "~/claude-work")
assert claude.claude_config_dir() == tmp_path / "claude-work"

def test_blank_env_var_falls_back_to_default(self, tmp_path, monkeypatch):
monkeypatch.setenv("CLAUDE_CONFIG_DIR", " ")
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
assert claude.claude_config_dir() == tmp_path / ".claude"


class TestRenderOverlay:
def test_does_not_set_anthropic_model_env(self):
# We deliberately don't pin ANTHROPIC_MODEL: when set, Claude Code's
Expand Down Expand Up @@ -319,7 +340,7 @@ def test_headers_newline_delimited(self, monkeypatch):

class TestRenderOverlayWebSearchDisable:
def test_settings_overlay_never_includes_mcp_servers(self):
# MCP servers belong in ~/.claude.json, not settings.json.
# MCP servers belong in Claude Code's .claude.json, not settings.json.
overlay, _ = claude.render_overlay(WS, "s4", disable_web_search=True)
assert "mcpServers" not in overlay

Expand Down Expand Up @@ -754,7 +775,7 @@ def test_non_relayed_does_not_set_setting_sources(self, monkeypatch):
assert "--setting-sources" not in argv

def test_relayed_excludes_user_scope_via_setting_sources(self, monkeypatch):
# Relayed must drop the user scope so a stale ~/.claude/settings.json
# Relayed must drop the user scope so a stale user-scope settings.json
# apiKeyHelper can't merge through and shadow the subscription OAuth.
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"env": {}})
argv = claude._build_claude_argv("claude", ["-p", "hi"], relayed=True)
Expand Down
12 changes: 12 additions & 0 deletions tests/test_skills_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,22 @@ def test_roots_under_project_dir(self, tmp_path):
assert roots == [tmp_path / ".claude/skills", tmp_path / ".agents/skills"]

def test_defaults_to_home_when_omitted(self, tmp_path, monkeypatch):
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
monkeypatch.setattr(sd.Path, "home", classmethod(lambda cls: tmp_path))
roots = skill_dir_roots(None)
assert roots == [tmp_path / ".claude/skills", tmp_path / ".agents/skills"]

def test_user_scope_claude_root_follows_claude_config_dir(self, tmp_path, monkeypatch):
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "claude-work"))
monkeypatch.setattr(sd.Path, "home", classmethod(lambda cls: tmp_path))
roots = skill_dir_roots(None)
assert roots == [tmp_path / "claude-work/skills", tmp_path / ".agents/skills"]

def test_project_scope_ignores_claude_config_dir(self, tmp_path, monkeypatch):
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "claude-work"))
roots = skill_dir_roots(str(tmp_path))
assert roots == [tmp_path / ".claude/skills", tmp_path / ".agents/skills"]

def test_relative_path_rejected(self):
with pytest.raises(ValueError, match="absolute"):
skill_dir_roots("relative/dir")
Expand Down