Skip to content

Commit e42de49

Browse files
authored
claude: merge a caller's --settings instead of clobbering ucode's (#189)
* claude: merge a caller's --settings instead of clobbering ucode's `ucode claude` prepends its own `--settings <ucode-file>` to every launch. Claude Code honors only ONE --settings flag, so a caller that also passes --settings (e.g. an integration injecting hooks) has exactly one of the two silently dropped — either ucode's gateway config or the caller's hooks. This broke omnigent's managed Claude sessions: its transcript/permission hooks were dropped and the web UI rendered empty. Compose instead of clobber: when a caller supplies --settings, deep-merge it with ucode's (ucode's gateway keys win; hooks from both are unioned) and hand Claude a single merged --settings (inline JSON). The merge is per-launch and is never written back to the shared ucode settings file, so concurrent launches cannot accumulate one another's hooks. No caller --settings → unchanged (pass ucode's settings file directly). Co-authored-by: Isaac * claude: fail loudly on an unresolvable --settings value Per review on #189: a caller --settings value that ucode could not parse was silently swallowed (returned None) and then passed through as a second --settings flag. Claude Code honors only one --settings, so that collision silently dropped either the caller's settings or ucode's gateway config (breaking auth). Raise an actionable RuntimeError instead — malformed inline JSON, a malformed/unreadable settings file, a nonexistent file path, or non-object JSON — surfaced by the CLI as a clean error. Removes the pass-through branch, which never helped (the colliding flag always loses). Co-authored-by: Isaac
1 parent 144b2ac commit e42de49

2 files changed

Lines changed: 235 additions & 1 deletion

File tree

src/ucode/agents/claude.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import copy
6+
import json
57
import os
68
import re
79
import shutil
@@ -503,12 +505,141 @@ def default_model(state: dict) -> str | None:
503505
return claude_models.get("opus") or claude_models.get("sonnet") or claude_models.get("haiku")
504506

505507

508+
def _extract_caller_settings(tool_args: list[str]) -> tuple[list[str], list[str]]:
509+
"""Split caller-supplied ``--settings`` values out of *tool_args*.
510+
511+
Returns ``(values, remaining_args)``, handling both ``--settings <value>``
512+
and ``--settings=<value>`` spellings. Each value is either a JSON string or
513+
a path to a settings file — Claude Code accepts either.
514+
"""
515+
values: list[str] = []
516+
remaining: list[str] = []
517+
i = 0
518+
while i < len(tool_args):
519+
arg = tool_args[i]
520+
if arg == "--settings" and i + 1 < len(tool_args):
521+
values.append(tool_args[i + 1])
522+
i += 2
523+
continue
524+
if arg.startswith("--settings="):
525+
values.append(arg[len("--settings=") :])
526+
i += 1
527+
continue
528+
remaining.append(arg)
529+
i += 1
530+
return values, remaining
531+
532+
533+
def _load_caller_settings(value: str) -> dict:
534+
"""Resolve a ``--settings`` value (inline JSON or file path) to a dict.
535+
536+
Claude Code accepts either inline JSON or a path to a JSON file. Raises
537+
``RuntimeError`` (surfaced by the CLI as an actionable error) when the value
538+
is neither, rather than silently dropping it: a dropped value would also be
539+
passed through as a second ``--settings`` flag, and Claude Code honors only
540+
one — so either the caller's settings or ucode's gateway config would be
541+
silently ignored. Failing loudly lets the caller fix their input.
542+
"""
543+
text = value.strip()
544+
if text.startswith("{"):
545+
source, malformed = text, "value is not valid JSON"
546+
else:
547+
path = Path(text)
548+
if not path.exists():
549+
raise RuntimeError(
550+
f"--settings file not found: {value!r}. "
551+
"Pass inline JSON or a path to an existing JSON file."
552+
)
553+
try:
554+
source = path.read_text(encoding="utf-8")
555+
except OSError as exc:
556+
raise RuntimeError(f"--settings file could not be read: {value!r} ({exc}).") from exc
557+
malformed = "file is not valid JSON"
558+
try:
559+
parsed = json.loads(source)
560+
except json.JSONDecodeError as exc:
561+
raise RuntimeError(
562+
f"--settings {malformed} ({exc}): {value!r}. Pass inline JSON or a path to a JSON file."
563+
) from exc
564+
if not isinstance(parsed, dict):
565+
raise RuntimeError(
566+
f"--settings must be a JSON object, got {type(parsed).__name__}: {value!r}."
567+
)
568+
return parsed
569+
570+
571+
def _union_claude_hooks(base: dict, overlay: dict) -> dict:
572+
"""Union two Claude Code ``hooks`` maps.
573+
574+
``hooks`` is ``{event: [entry, ...]}``. Per event we concatenate the entry
575+
lists so hooks from BOTH settings sources fire, rather than one replacing
576+
the other (which is what a plain deep-merge does to lists). This is what
577+
lets ucode's tracing Stop hook and a caller's own hooks coexist.
578+
"""
579+
result: dict = {}
580+
for event in [*base, *(e for e in overlay if e not in base)]:
581+
entries: list = []
582+
for src in (base, overlay):
583+
val = src.get(event)
584+
if isinstance(val, list):
585+
entries.extend(val)
586+
result[event] = entries
587+
return result
588+
589+
590+
def _merge_claude_settings(base: dict, overlay: dict) -> dict:
591+
"""Deep-merge *overlay* onto *base* (overlay wins on conflicting leaves),
592+
but UNION the ``hooks`` so neither side's hooks are dropped. Inputs are not
593+
mutated.
594+
"""
595+
merged = deep_merge_dict(copy.deepcopy(base), overlay)
596+
base_hooks = base.get("hooks")
597+
overlay_hooks = overlay.get("hooks")
598+
if isinstance(base_hooks, dict) or isinstance(overlay_hooks, dict):
599+
merged["hooks"] = _union_claude_hooks(
600+
base_hooks if isinstance(base_hooks, dict) else {},
601+
overlay_hooks if isinstance(overlay_hooks, dict) else {},
602+
)
603+
return merged
604+
605+
606+
def _build_claude_argv(binary: str, tool_args: list[str]) -> list[str]:
607+
"""Build the ``claude`` argv, composing any caller ``--settings`` with
608+
ucode's managed settings.
609+
610+
ucode needs its own settings (gateway ``apiKeyHelper`` + env) to reach
611+
Claude, and normally passes ``--settings <ucode-file>``. But Claude Code
612+
honors only ONE ``--settings`` flag, so a caller that ALSO passes
613+
``--settings`` (e.g. an integration injecting hooks) would have exactly one
614+
of the two silently dropped. To let ucode compose with any prior command,
615+
we merge a caller-supplied ``--settings`` with ucode's — ucode's gateway
616+
keys win, hooks from both are unioned — and hand Claude a single merged
617+
``--settings`` (inline JSON). The merge is per-launch and is never written
618+
back to the shared ucode settings file, so concurrent launches cannot
619+
accumulate one another's hooks. A caller ``--settings`` value ucode cannot
620+
resolve raises (see :func:`_load_caller_settings`) rather than being passed
621+
through as a second, colliding flag.
622+
"""
623+
caller_values, remaining = _extract_caller_settings(tool_args)
624+
if not caller_values:
625+
# No caller --settings: hand Claude ucode's settings file directly (the
626+
# common path; behavior unchanged).
627+
return [binary, "--settings", str(CLAUDE_SETTINGS_PATH), *tool_args]
628+
caller_settings: dict = {}
629+
for value in caller_values:
630+
caller_settings = _merge_claude_settings(caller_settings, _load_caller_settings(value))
631+
# ucode wins over the caller for conflicting keys (protects gateway auth);
632+
# hooks from both sides survive.
633+
merged = _merge_claude_settings(caller_settings, read_json_safe(CLAUDE_SETTINGS_PATH))
634+
return [binary, "--settings", json.dumps(merged, separators=(",", ":")), *remaining]
635+
636+
506637
def launch(state: dict, tool_args: list[str]) -> None:
507638
binary = SPEC["binary"]
508639
workspace = state.get("workspace")
509640
if workspace:
510641
os.environ["OAUTH_TOKEN"] = get_databricks_token(workspace, state.get("profile"))
511-
exec_or_spawn([binary, "--settings", str(CLAUDE_SETTINGS_PATH), *tool_args])
642+
exec_or_spawn(_build_claude_argv(binary, tool_args))
512643

513644

514645
def validate_cmd(binary: str) -> list[str]:

tests/test_agent_claude.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
from __future__ import annotations
44

5+
import json
56
import os
67

8+
import pytest
9+
710
from ucode.agents import claude
811

912
WS = "https://example.databricks.com"
@@ -525,3 +528,103 @@ def test_prunes_stale_name_companion_keys_from_older_ucode(self, monkeypatch):
525528
assert "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME" not in env
526529
assert "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME" not in env
527530
assert "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME" not in env
531+
532+
533+
class TestBuildClaudeArgv:
534+
def test_no_caller_settings_uses_ucode_file(self, monkeypatch):
535+
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
536+
argv = claude._build_claude_argv("claude", ["-p", "hi"])
537+
assert argv == ["claude", "--settings", str(claude.CLAUDE_SETTINGS_PATH), "-p", "hi"]
538+
539+
def test_inline_caller_settings_merged_into_single_flag(self, monkeypatch):
540+
ucode_settings = {
541+
"apiKeyHelper": "ucode-helper",
542+
"env": {"ANTHROPIC_BASE_URL": "https://gw"},
543+
"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "ucode-stop"}]}]},
544+
}
545+
monkeypatch.setattr(claude, "read_json_safe", lambda p: ucode_settings)
546+
caller = json.dumps(
547+
{
548+
"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "caller-stop"}]}]},
549+
"statusLine": {"type": "command", "command": "sl"},
550+
}
551+
)
552+
argv = claude._build_claude_argv("claude", ["--settings", caller, "-p", "hi"])
553+
# Exactly one --settings reaches Claude, and the caller's raw flag is gone.
554+
assert argv.count("--settings") == 1
555+
assert argv[:2] == ["claude", "--settings"]
556+
assert argv[3:] == ["-p", "hi"]
557+
merged = json.loads(argv[2])
558+
# ucode's gateway config survives.
559+
assert merged["apiKeyHelper"] == "ucode-helper"
560+
assert merged["env"]["ANTHROPIC_BASE_URL"] == "https://gw"
561+
# The caller's own (non-hook) settings pass through.
562+
assert merged["statusLine"] == {"type": "command", "command": "sl"}
563+
# Hooks from BOTH sides fire (unioned, not clobbered).
564+
stop_cmds = [h["command"] for e in merged["hooks"]["Stop"] for h in e["hooks"]]
565+
assert "ucode-stop" in stop_cmds
566+
assert "caller-stop" in stop_cmds
567+
568+
def test_equals_form_is_handled(self, monkeypatch):
569+
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
570+
caller = json.dumps({"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "c"}]}]}})
571+
argv = claude._build_claude_argv("claude", [f"--settings={caller}"])
572+
assert argv.count("--settings") == 1
573+
merged = json.loads(argv[2])
574+
assert merged["apiKeyHelper"] == "u"
575+
assert merged["hooks"]["Stop"][0]["hooks"][0]["command"] == "c"
576+
577+
def test_ucode_wins_on_conflicting_env(self, monkeypatch):
578+
monkeypatch.setattr(
579+
claude, "read_json_safe", lambda p: {"env": {"ANTHROPIC_BASE_URL": "https://ucode"}}
580+
)
581+
caller = json.dumps({"env": {"ANTHROPIC_BASE_URL": "https://caller", "FOO": "bar"}})
582+
argv = claude._build_claude_argv("claude", ["--settings", caller])
583+
merged = json.loads(argv[2])
584+
assert merged["env"]["ANTHROPIC_BASE_URL"] == "https://ucode" # ucode wins
585+
assert merged["env"]["FOO"] == "bar" # caller's non-conflicting key kept
586+
587+
def test_file_path_caller_settings(self, tmp_path, monkeypatch):
588+
caller_file = tmp_path / "caller.json"
589+
caller_file.write_text(
590+
json.dumps(
591+
{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "cs"}]}]}}
592+
)
593+
)
594+
# The caller file is read directly; read_json_safe is only used for
595+
# ucode's own settings file.
596+
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
597+
argv = claude._build_claude_argv("claude", ["--settings", str(caller_file)])
598+
assert argv.count("--settings") == 1
599+
merged = json.loads(argv[2])
600+
assert merged["apiKeyHelper"] == "u"
601+
assert merged["hooks"]["SessionStart"][0]["hooks"][0]["command"] == "cs"
602+
603+
def test_malformed_inline_json_raises(self, monkeypatch):
604+
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
605+
# Clearly-intended-as-JSON but broken: fail loudly rather than pass it
606+
# through as a second, colliding --settings flag.
607+
with pytest.raises(RuntimeError, match="not valid JSON"):
608+
claude._build_claude_argv("claude", ["--settings", '{"hooks": '])
609+
610+
def test_nonexistent_file_raises(self, monkeypatch):
611+
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
612+
with pytest.raises(RuntimeError, match="file not found"):
613+
claude._build_claude_argv("claude", ["--settings", "/no/such/settings.json"])
614+
615+
def test_non_object_file_json_raises(self, tmp_path, monkeypatch):
616+
# A --settings file whose JSON is not an object (e.g. an array) can't be
617+
# merged; fail loudly. (An inline value only enters the JSON branch when
618+
# it starts with "{", so the non-object case is reachable via a file.)
619+
bad_file = tmp_path / "bad.json"
620+
bad_file.write_text("[1, 2, 3]")
621+
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
622+
with pytest.raises(RuntimeError, match="must be a JSON object"):
623+
claude._build_claude_argv("claude", ["--settings", str(bad_file)])
624+
625+
def test_malformed_file_json_raises(self, tmp_path, monkeypatch):
626+
bad_file = tmp_path / "bad.json"
627+
bad_file.write_text('{"hooks": ')
628+
monkeypatch.setattr(claude, "read_json_safe", lambda p: {"apiKeyHelper": "u"})
629+
with pytest.raises(RuntimeError, match="not valid JSON"):
630+
claude._build_claude_argv("claude", ["--settings", str(bad_file)])

0 commit comments

Comments
 (0)