diff --git a/README.md b/README.md index d0a457c7..acfc62f3 100644 --- a/README.md +++ b/README.md @@ -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 `/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 [,…]` narrows the download to the named skills (by leaf name) from the schema instead of all of them; requested @@ -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 | diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index e818cdf9..6629008c 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -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 @@ -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" @@ -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 `..` Model Provider diff --git a/src/ucode/cli.py b/src/ucode/cli.py index c2e38f4c..5d0acc78 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -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 + ``/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. @@ -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[ @@ -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``). diff --git a/src/ucode/managed_resolve.py b/src/ucode/managed_resolve.py index 811b9f1f..c0ff91be 100644 --- a/src/ucode/managed_resolve.py +++ b/src/ucode/managed_resolve.py @@ -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, diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index a3db7906..798b471f 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -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 diff --git a/src/ucode/skills_download.py b/src/ucode/skills_download.py index 2168b522..ba7398dc 100644 --- a/src/ucode/skills_download.py +++ b/src/ucode/skills_download.py @@ -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, @@ -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" @@ -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 + ``/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] diff --git a/src/ucode/smart_routing/claude_hooks.py b/src/ucode/smart_routing/claude_hooks.py index 773b7707..9c2ddce3 100644 --- a/src/ucode/smart_routing/claude_hooks.py +++ b/src/ucode/smart_routing/claude_hooks.py @@ -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 diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index b45b6303..bb3ee417 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -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 @@ -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 @@ -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) diff --git a/tests/test_skills_download.py b/tests/test_skills_download.py index c7927019..078558e4 100644 --- a/tests/test_skills_download.py +++ b/tests/test_skills_download.py @@ -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")