|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import copy |
| 6 | +import json |
5 | 7 | import os |
6 | 8 | import re |
7 | 9 | import shutil |
@@ -495,12 +497,130 @@ def default_model(state: dict) -> str | None: |
495 | 497 | return claude_models.get("opus") or claude_models.get("sonnet") or claude_models.get("haiku") |
496 | 498 |
|
497 | 499 |
|
| 500 | +def _extract_caller_settings(tool_args: list[str]) -> tuple[list[str], list[str]]: |
| 501 | + """Split caller-supplied ``--settings`` values out of *tool_args*. |
| 502 | +
|
| 503 | + Returns ``(values, remaining_args)``, handling both ``--settings <value>`` |
| 504 | + and ``--settings=<value>`` spellings. Each value is either a JSON string or |
| 505 | + a path to a settings file — Claude Code accepts either. |
| 506 | + """ |
| 507 | + values: list[str] = [] |
| 508 | + remaining: list[str] = [] |
| 509 | + i = 0 |
| 510 | + while i < len(tool_args): |
| 511 | + arg = tool_args[i] |
| 512 | + if arg == "--settings" and i + 1 < len(tool_args): |
| 513 | + values.append(tool_args[i + 1]) |
| 514 | + i += 2 |
| 515 | + continue |
| 516 | + if arg.startswith("--settings="): |
| 517 | + values.append(arg[len("--settings=") :]) |
| 518 | + i += 1 |
| 519 | + continue |
| 520 | + remaining.append(arg) |
| 521 | + i += 1 |
| 522 | + return values, remaining |
| 523 | + |
| 524 | + |
| 525 | +def _load_caller_settings(value: str) -> dict | None: |
| 526 | + """Resolve a ``--settings`` value (inline JSON or file path) to a dict. |
| 527 | +
|
| 528 | + Returns ``None`` when it is neither parseable JSON nor an existing file, so |
| 529 | + the caller can pass such a value through untouched instead of dropping it. |
| 530 | + """ |
| 531 | + text = value.strip() |
| 532 | + if text.startswith("{"): |
| 533 | + try: |
| 534 | + parsed = json.loads(text) |
| 535 | + except json.JSONDecodeError: |
| 536 | + return None |
| 537 | + return parsed if isinstance(parsed, dict) else None |
| 538 | + path = Path(text) |
| 539 | + if path.exists(): |
| 540 | + return read_json_safe(path) |
| 541 | + return None |
| 542 | + |
| 543 | + |
| 544 | +def _union_claude_hooks(base: dict, overlay: dict) -> dict: |
| 545 | + """Union two Claude Code ``hooks`` maps. |
| 546 | +
|
| 547 | + ``hooks`` is ``{event: [entry, ...]}``. Per event we concatenate the entry |
| 548 | + lists so hooks from BOTH settings sources fire, rather than one replacing |
| 549 | + the other (which is what a plain deep-merge does to lists). This is what |
| 550 | + lets ucode's tracing Stop hook and a caller's own hooks coexist. |
| 551 | + """ |
| 552 | + result: dict = {} |
| 553 | + for event in [*base, *(e for e in overlay if e not in base)]: |
| 554 | + entries: list = [] |
| 555 | + for src in (base, overlay): |
| 556 | + val = src.get(event) |
| 557 | + if isinstance(val, list): |
| 558 | + entries.extend(val) |
| 559 | + result[event] = entries |
| 560 | + return result |
| 561 | + |
| 562 | + |
| 563 | +def _merge_claude_settings(base: dict, overlay: dict) -> dict: |
| 564 | + """Deep-merge *overlay* onto *base* (overlay wins on conflicting leaves), |
| 565 | + but UNION the ``hooks`` so neither side's hooks are dropped. Inputs are not |
| 566 | + mutated. |
| 567 | + """ |
| 568 | + merged = deep_merge_dict(copy.deepcopy(base), overlay) |
| 569 | + base_hooks = base.get("hooks") |
| 570 | + overlay_hooks = overlay.get("hooks") |
| 571 | + if isinstance(base_hooks, dict) or isinstance(overlay_hooks, dict): |
| 572 | + merged["hooks"] = _union_claude_hooks( |
| 573 | + base_hooks if isinstance(base_hooks, dict) else {}, |
| 574 | + overlay_hooks if isinstance(overlay_hooks, dict) else {}, |
| 575 | + ) |
| 576 | + return merged |
| 577 | + |
| 578 | + |
| 579 | +def _build_claude_argv(binary: str, tool_args: list[str]) -> list[str]: |
| 580 | + """Build the ``claude`` argv, composing any caller ``--settings`` with |
| 581 | + ucode's managed settings. |
| 582 | +
|
| 583 | + ucode needs its own settings (gateway ``apiKeyHelper`` + env) to reach |
| 584 | + Claude, and normally passes ``--settings <ucode-file>``. But Claude Code |
| 585 | + honors only ONE ``--settings`` flag, so a caller that ALSO passes |
| 586 | + ``--settings`` (e.g. an integration injecting hooks) would have exactly one |
| 587 | + of the two silently dropped. To let ucode compose with any prior command, |
| 588 | + we merge a caller-supplied ``--settings`` with ucode's — ucode's gateway |
| 589 | + keys win, hooks from both are unioned — and hand Claude a single merged |
| 590 | + ``--settings`` (inline JSON). The merge is per-launch and is never written |
| 591 | + back to the shared ucode settings file, so concurrent launches cannot |
| 592 | + accumulate one another's hooks. |
| 593 | + """ |
| 594 | + caller_values, remaining = _extract_caller_settings(tool_args) |
| 595 | + if not caller_values: |
| 596 | + # No caller --settings: hand Claude ucode's settings file directly (the |
| 597 | + # common path; behavior unchanged). |
| 598 | + return [binary, "--settings", str(CLAUDE_SETTINGS_PATH), *tool_args] |
| 599 | + caller_settings: dict = {} |
| 600 | + unparsed: list[str] = [] |
| 601 | + for value in caller_values: |
| 602 | + parsed = _load_caller_settings(value) |
| 603 | + if parsed is None: |
| 604 | + unparsed.append(value) |
| 605 | + continue |
| 606 | + caller_settings = _merge_claude_settings(caller_settings, parsed) |
| 607 | + # ucode wins over the caller for conflicting keys (protects gateway auth); |
| 608 | + # hooks from both sides survive. |
| 609 | + merged = _merge_claude_settings(caller_settings, read_json_safe(CLAUDE_SETTINGS_PATH)) |
| 610 | + argv = [binary, "--settings", json.dumps(merged, separators=(",", ":")), *remaining] |
| 611 | + # Anything we could not parse (not JSON, not an existing file) is passed |
| 612 | + # through untouched rather than silently dropped. |
| 613 | + for value in unparsed: |
| 614 | + argv.extend(["--settings", value]) |
| 615 | + return argv |
| 616 | + |
| 617 | + |
498 | 618 | def launch(state: dict, tool_args: list[str]) -> None: |
499 | 619 | binary = SPEC["binary"] |
500 | 620 | workspace = state.get("workspace") |
501 | 621 | if workspace: |
502 | 622 | os.environ["OAUTH_TOKEN"] = get_databricks_token(workspace, state.get("profile")) |
503 | | - exec_or_spawn([binary, "--settings", str(CLAUDE_SETTINGS_PATH), *tool_args]) |
| 623 | + exec_or_spawn(_build_claude_argv(binary, tool_args)) |
504 | 624 |
|
505 | 625 |
|
506 | 626 | def validate_cmd(binary: str) -> list[str]: |
|
0 commit comments