diff --git a/claude-code/gateway/setup.py b/claude-code/gateway/setup.py index 87e0840..de08ac9 100644 --- a/claude-code/gateway/setup.py +++ b/claude-code/gateway/setup.py @@ -270,15 +270,16 @@ def _command_targets_hook(command: str, target: Path) -> bool: return os.path.normcase(os.path.normpath(tokens[0])) == normalized_target -def _is_unbound_hook_command(command) -> bool: +def _is_unbound_hook_command(command, config_dir: Path = None) -> bool: """Whether a settings.json hook entry runs the Unbound hook. The hooks installer writes the interpreter, quoting and separators of the platform it ran on, so the command is tokenised and the path compared rather than matched as a substring.""" + config_dir = config_dir or (Path.home() / ".claude") return isinstance(command, str) and _command_targets_hook( - command, Path.home() / ".claude" / "hooks" / "unbound.py") + command, config_dir / "hooks" / "unbound.py") -def _strip_unbound_hooks(settings: dict) -> None: +def _strip_unbound_hooks(settings: dict, config_dir: Path = None) -> None: """Drop the Unbound entries from settings["hooks"], leaving every other hook in place and removing only the groups and the block our entries emptied.""" hooks = settings.get("hooks") @@ -297,7 +298,7 @@ def _strip_unbound_hooks(settings: dict) -> None: kept_groups.append(group) continue kept = [e for e in entries - if not (isinstance(e, dict) and _is_unbound_hook_command(e.get("command")))] + if not (isinstance(e, dict) and _is_unbound_hook_command(e.get("command"), config_dir))] if not kept: continue group["hooks"] = kept @@ -310,20 +311,27 @@ def _strip_unbound_hooks(settings: dict) -> None: del settings["hooks"] -def _is_unbound_key_helper_setting(value) -> bool: - """Whether settings.json's apiKeyHelper is the one this setup writes. The expanded - form counts too: the setup writes the ~ form, but a device may already carry the - expanded one.""" +def _is_unbound_key_helper_setting(value, config_dir: Path = None) -> bool: + """Whether settings.json's apiKeyHelper is the one this setup writes. The setup + writes the portable ~ form for the default dir and an absolute path for a relocated + one, and a device may already carry the expanded default; all three count.""" + config_dir = config_dir or (Path.home() / ".claude") if not isinstance(value, str): return False + default_helper = Path.home() / ".claude" / "anthropic_key.sh" + active_helper = config_dir / "anthropic_key.sh" candidate = value.strip() - if candidate not in (UNBOUND_KEY_HELPER_SETTING, - str(Path.home() / ".claude" / "anthropic_key.sh")): + # Judge the file the setting actually names, not the active dir's — a portable + # ~ form left over from a default install points at ~/.claude either way. + if candidate in (UNBOUND_KEY_HELPER_SETTING, str(default_helper)): + target = default_helper + elif candidate == str(active_helper): + target = active_helper + else: return False # The path is a name anyone could choose, so the script there decides. Nothing there # means our own removal already ran; a dangling helper is broken either way. - path = Path.home() / ".claude" / "anthropic_key.sh" - return not path.exists() or _is_unbound_key_helper_file(path) + return not target.exists() or _is_unbound_key_helper_file(target) def _is_unbound_key_helper_file(path: Path) -> bool: @@ -448,9 +456,22 @@ def write_unbound_config(api_key: str, urls: dict = None) -> bool: return False -def remove_hooks_unbound_script() -> None: - """Remove ~/.claude/hooks/unbound.py if present (leftover from hooks setup).""" - script_path = Path.home() / ".claude" / "hooks" / "unbound.py" +def _resolve_claude_config_dir(config_dir_arg: Optional[str] = None) -> Path: + """Resolve Claude Code's config dir: $CLAUDE_CONFIG_DIR (env wins), else the + --config-dir arg the CLI forwards, else the default ~/.claude.""" + value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None + if not value and config_dir_arg: + value = config_dir_arg.strip() or None + if not value: + return Path.home() / ".claude" + # Verbatim, as Claude Code reads it: a literal "~" must not be expanded. + return Path(os.path.abspath(value)) + + +def remove_hooks_unbound_script(config_dir: Path = None) -> None: + """Remove /hooks/unbound.py if present (leftover from hooks setup).""" + config_dir = config_dir or (Path.home() / ".claude") + script_path = config_dir / "hooks" / "unbound.py" if script_path.exists(): try: script_path.unlink() @@ -459,12 +480,12 @@ def remove_hooks_unbound_script() -> None: debug_print(f"Failed to remove {script_path}: {e}") -def setup_claude_key_helper() -> bool: +def setup_claude_key_helper(config_dir: Path = None) -> bool: """ - Create ~/.claude/anthropic_key.sh that echoes UNBOUND_API_KEY and - update ~/.claude/settings.json with apiKeyHelper pointing to that script. + Create /anthropic_key.sh that echoes UNBOUND_API_KEY and + update /settings.json with apiKeyHelper pointing to that script. """ - claude_dir = Path.home() / ".claude" + claude_dir = config_dir or (Path.home() / ".claude") settings_path = claude_dir / "settings.json" key_helper_path = claude_dir / "anthropic_key.sh" @@ -490,10 +511,12 @@ def setup_claude_key_helper() -> bool: # Our hook and the gateway cannot both drive Claude Code, so ours goes before # apiKeyHelper is added. Only ours: a hook the user installed is not ours to drop. - _strip_unbound_hooks(settings) + _strip_unbound_hooks(settings, claude_dir) - # Update apiKeyHelper - settings["apiKeyHelper"] = "~/.claude/anthropic_key.sh" + if claude_dir.resolve() == (Path.home() / ".claude").resolve(): + settings["apiKeyHelper"] = "~/.claude/anthropic_key.sh" + else: + settings["apiKeyHelper"] = str(key_helper_path) settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") return True @@ -594,18 +617,19 @@ def _clear_path(path: Path, label: str) -> str: return "failed" -def remove_api_key_helper_setting() -> str: +def remove_api_key_helper_setting(config_dir: Path = None) -> str: """Remove apiKeyHelper from settings.json. Returns "cleared", "not_found", or "failed". """ - settings_path = Path.home() / ".claude" / "settings.json" + config_dir = config_dir or (Path.home() / ".claude") + settings_path = config_dir / "settings.json" if not settings_path.exists(): return "not_found" try: with open(settings_path, "r", encoding="utf-8") as f: settings = json.load(f) - if not _is_unbound_key_helper_setting(settings.get("apiKeyHelper")): + if not _is_unbound_key_helper_setting(settings.get("apiKeyHelper"), config_dir): return "not_found" del settings["apiKeyHelper"] with open(settings_path, "w", encoding="utf-8") as f: @@ -617,8 +641,9 @@ def remove_api_key_helper_setting() -> str: return "failed" -def clear_setup() -> bool: +def clear_setup(config_dir: Path = None) -> bool: """Undo all changes made by the setup script.""" + config_dir = config_dir or (Path.home() / ".claude") print("=" * 60) print("Claude Code - Clearing Setup") print("=" * 60) @@ -636,7 +661,7 @@ def clear_setup() -> bool: print(f"Failed to clear {label}") any_failed = True - key_helper = Path.home() / ".claude" / "anthropic_key.sh" + key_helper = config_dir / "anthropic_key.sh" _r = (_clear_path(key_helper, "Claude anthropic_key.sh") if _is_unbound_key_helper_file(key_helper) else "not_found") if _r == "cleared": @@ -644,13 +669,27 @@ def clear_setup() -> bool: elif _r == "failed": any_failed = True - settings_status = remove_api_key_helper_setting() + settings_status = remove_api_key_helper_setting(config_dir) if settings_status == "cleared": any_cleared = True elif settings_status == "failed": print("Failed to clear apiKeyHelper in settings.json") any_failed = True + # When the config dir was relocated, also strip enforcement left behind in the + # default ~/.claude so clearing leaves nothing that fires if Claude later runs + # without CLAUDE_CONFIG_DIR set. + default_dir = Path.home() / ".claude" + if config_dir.resolve() != default_dir.resolve(): + # Same ownership check the primary path uses: a helper of this name that we + # did not write belongs to whoever did, and clearing must not delete it. + legacy_helper = default_dir / "anthropic_key.sh" + if (_is_unbound_key_helper_file(legacy_helper) + and _clear_path(legacy_helper, "Claude anthropic_key.sh (~/.claude)") == "cleared"): + any_cleared = True + if remove_api_key_helper_setting(default_dir) == "cleared": + any_cleared = True + if any_cleared: print("Cleared") elif not any_failed: @@ -763,12 +802,13 @@ def get_device_identifier() -> Optional[str]: return None -def detect_install_state() -> str: +def detect_install_state(config_dir: Path = None) -> str: """User-level install state (informational): 'persisted' if this tool's Unbound setup already exists on this device, else 'fresh'. User-level setups are never tamper-eligible, so 'tampered' is never reported.""" + config_dir = config_dir or (Path.home() / ".claude") try: - return "persisted" if (Path.home() / ".claude" / "anthropic_key.sh").exists() else "fresh" + return "persisted" if (config_dir / "anthropic_key.sh").exists() else "fresh" except Exception as e: debug_print(f"detect_install_state failed: {e}") return "fresh" @@ -841,16 +881,34 @@ def main(): parser.add_argument("--clear", action="store_true", help="Undo all changes made by the setup script") parser.add_argument("--debug", action="store_true", help="Show detailed debug information") parser.add_argument("--api-key", dest="api_key", help="API key (skip browser auth)") + parser.add_argument("--config-dir", dest="config_dir", help="Claude Code config dir (defaults to $CLAUDE_CONFIG_DIR or ~/.claude)") args, _ = parser.parse_known_args() args.gateway_url = normalize_url(args.gateway_url) args.backend_url = normalize_url(args.backend_url) + config_dir = _resolve_claude_config_dir(args.config_dir) + # Claude Code resolves CLAUDE_CONFIG_DIR against the current directory when it + # is set but empty, instead of falling back to ~/.claude, so installing to the + # default would be invisible to it. + _raw_cfg = os.environ.get("CLAUDE_CONFIG_DIR") + if _raw_cfg is not None and not _raw_cfg.strip(): + print("\n\u26a0\ufe0f CLAUDE_CONFIG_DIR is set but empty. Claude Code reads that as a path " + "relative to the current directory, not as ~/.claude, so it will not load this " + "install. Unset the variable, or point it at a real directory.") + # Claude Code picks its config dir from the environment alone. An install aimed + # somewhere else by --config-dir is one it will never read, so say so rather than + # reporting success over a key helper that cannot be found. + if args.config_dir and not (_raw_cfg or "").strip(): + print(f"\n\u26a0\ufe0f --config-dir set to {config_dir} but CLAUDE_CONFIG_DIR is not set in the " + "environment. Claude Code reads only the environment variable, so it will not load " + "this install. Export CLAUDE_CONFIG_DIR to the same path.") + if args.debug: DEBUG = True debug_print("Debug mode enabled") if args.clear: - return clear_setup() + return clear_setup(config_dir) if check_enterprise_hooks_conflict(): print("\n❌ Skipped — Claude Code is managed by your organization (MDM).") @@ -876,7 +934,7 @@ def main(): pass # Remove leftover hooks setup artifacts - remove_hooks_unbound_script() + remove_hooks_unbound_script(config_dir) api_key = args.api_key if not api_key: @@ -928,12 +986,12 @@ def main(): return False debug_print("ANTHROPIC_BASE_URL set successfully") - _install_state = detect_install_state() + _install_state = detect_install_state(config_dir) _device_id = get_device_identifier() # Configure Claude Code helper files debug_print("Setting up Claude key helper...") - if not setup_claude_key_helper(): + if not setup_claude_key_helper(config_dir): return False debug_print("Claude key helper configured") diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index 97e4d76..80bc748 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -62,6 +62,25 @@ def normalize_url(domain: str) -> str: return url.rstrip('/') +def _resolve_claude_config_dir(argv) -> Path: + value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None + if not value: + for i, arg in enumerate(argv): + # Both spellings: --config-dir VALUE and --config-dir=VALUE. Missing + # the second would silently fall back to ~/.claude, which is the very + # wrong-directory install this is meant to prevent. + if arg.startswith("--config-dir="): + value = arg.split("=", 1)[1].strip() or None + break + if arg == "--config-dir" and i + 1 < len(argv) and not argv[i + 1].startswith("--"): + value = argv[i + 1].strip() or None + break + if not value: + return Path.home() / ".claude" + # Verbatim, as Claude Code reads it: a literal "~" must not be expanded. + return Path(os.path.abspath(value)) + + def get_shell_rc_file() -> Path: system = platform.system().lower() shell = os.environ.get("SHELL", "").lower() @@ -195,18 +214,28 @@ def _export_value(line: str, prefix: str) -> str: return line.strip()[len(prefix):].strip().strip('"').strip("'") -def _is_unbound_key_helper_setting(value) -> bool: - """Whether settings.json's apiKeyHelper is the one the gateway setup writes.""" +def _is_unbound_key_helper_setting(value, config_dir: Path = None) -> bool: + """Whether settings.json's apiKeyHelper is the one the gateway setup writes. It + writes the portable ~ form for the default dir and an absolute path for a relocated + one, so both count — otherwise the helper survives here and drives Claude alongside + the hooks we are about to install.""" + config_dir = config_dir or (Path.home() / ".claude") if not isinstance(value, str): return False + default_helper = Path.home() / ".claude" / "anthropic_key.sh" + active_helper = config_dir / "anthropic_key.sh" candidate = value.strip() - if candidate not in (UNBOUND_KEY_HELPER_SETTING, - str(Path.home() / ".claude" / "anthropic_key.sh")): + # Judge the file the setting actually names, not the active dir's — a portable + # ~ form left over from a default install points at ~/.claude either way. + if candidate in (UNBOUND_KEY_HELPER_SETTING, str(default_helper)): + target = default_helper + elif candidate == str(active_helper): + target = active_helper + else: return False # The path is a name anyone could choose, so the script there decides. Nothing there # means our own removal already ran; a dangling helper is broken either way. - path = Path.home() / ".claude" / "anthropic_key.sh" - return not path.exists() or _is_unbound_key_helper_file(path) + return not target.exists() or _is_unbound_key_helper_file(target) def _is_unbound_key_helper_file(path: Path) -> bool: @@ -399,11 +428,12 @@ def write_unbound_config(api_key: str, urls: dict = None) -> bool: return False -def remove_gateway_artifacts() -> None: - """Remove ~/.claude/anthropic_key.sh if present (leftover from gateway setup). +def remove_gateway_artifacts(config_dir: Path = None) -> None: + """Remove /anthropic_key.sh if present (leftover from gateway setup). Only the script our gateway wrote -- somebody else's helper of the same name is theirs to keep.""" - key_helper_path = Path.home() / ".claude" / "anthropic_key.sh" + config_dir = config_dir or (Path.home() / ".claude") + key_helper_path = config_dir / "anthropic_key.sh" if key_helper_path.exists() and _is_unbound_key_helper_file(key_helper_path): try: key_helper_path.unlink() @@ -443,8 +473,9 @@ def rewrite_gateway_url_in_file(path: Path, gateway_url: str) -> None: debug_print(f"Could not rewrite gateway URL in {path}: {e}") -def setup_hooks(gateway_url: str = DEFAULT_GATEWAY_URL): - hooks_dir = Path.home() / ".claude" / "hooks" +def setup_hooks(gateway_url: str = DEFAULT_GATEWAY_URL, config_dir: Path = None): + config_dir = config_dir or (Path.home() / ".claude") + hooks_dir = config_dir / "hooks" script_path = hooks_dir / "unbound.py" # print("\n📥 Downloading unbound.py script...") @@ -491,9 +522,10 @@ def _command_targets_hook(command: str, target: Path) -> bool: return os.path.normcase(os.path.normpath(tokens[0])) == normalized_target -def configure_claude_settings() -> bool: - settings_path = Path.home() / ".claude" / "settings.json" - +def configure_claude_settings(config_dir: Path = None) -> bool: + config_dir = config_dir or (Path.home() / ".claude") + settings_path = config_dir / "settings.json" + try: if settings_path.exists(): with open(settings_path, 'r', encoding='utf-8') as f: @@ -504,10 +536,10 @@ def configure_claude_settings() -> bool: # Our hook and the gateway's key helper cannot both drive Claude Code, so ours # goes before the hooks are added. Only ours: an org's own helper stays. - if _is_unbound_key_helper_setting(settings.get("apiKeyHelper")): + if _is_unbound_key_helper_setting(settings.get("apiKeyHelper"), config_dir): del settings["apiKeyHelper"] - script_path = Path.home() / ".claude" / "hooks" / "unbound.py" + script_path = config_dir / "hooks" / "unbound.py" # On Windows, invoke via the launcher and quote the path (handles spaces # like C:\Users\Jane Doe\ or C:\Program Files\). Use `py -3` if present, @@ -639,13 +671,14 @@ def _hook(entry: dict) -> dict: return False -def remove_hooks_from_settings() -> str: +def remove_hooks_from_settings(config_dir: Path = None) -> str: """Remove the unbound hooks from settings.json. Returns "cleared", "not_found", or "failed". """ - settings_path = Path.home() / ".claude" / "settings.json" - script_path = Path.home() / ".claude" / "hooks" / "unbound.py" + config_dir = config_dir or (Path.home() / ".claude") + settings_path = config_dir / "settings.json" + script_path = config_dir / "hooks" / "unbound.py" if not settings_path.exists(): return "not_found" @@ -708,8 +741,9 @@ def _clear_path(path: Path, label: str) -> str: return "failed" -def clear_setup() -> bool: +def clear_setup(config_dir: Path = None) -> bool: """Undo all changes made by the setup script.""" + config_dir = config_dir or (Path.home() / ".claude") print("=" * 60) print("Claude Code Hooks - Clearing Setup") print("=" * 60) @@ -724,15 +758,15 @@ def clear_setup() -> bool: print("Failed to clear API_KEY") any_failed = True - _r = _clear_path(Path.home() / ".claude" / "hooks" / "unbound.py", "Claude unbound.py hook") + _r = _clear_path(config_dir / "hooks" / "unbound.py", "Claude unbound.py hook") if _r == "cleared": any_cleared = True elif _r == "failed": any_failed = True for extra in ( - Path.home() / ".claude" / "hooks" / "unbound-setup.py", - Path.home() / ".claude" / "hooks" / ".last_updated", + config_dir / "hooks" / "unbound-setup.py", + config_dir / "hooks" / ".last_updated", ): _r = _clear_path(extra, str(extra)) if _r == "cleared": @@ -740,13 +774,23 @@ def clear_setup() -> bool: elif _r == "failed": any_failed = True - settings_status = remove_hooks_from_settings() + settings_status = remove_hooks_from_settings(config_dir) if settings_status == "cleared": any_cleared = True elif settings_status == "failed": print("Failed to clear Unbound hooks in settings.json") any_failed = True + # When the config dir was relocated, also strip enforcement left behind in the + # default ~/.claude so clearing leaves nothing that fires if Claude later runs + # without CLAUDE_CONFIG_DIR set. + default_dir = Path.home() / ".claude" + if config_dir.resolve() != default_dir.resolve(): + if _clear_path(default_dir / "hooks" / "unbound.py", "Claude unbound.py hook (~/.claude)") == "cleared": + any_cleared = True + if remove_hooks_from_settings(default_dir) == "cleared": + any_cleared = True + if any_cleared: print("Cleared") elif not any_failed: @@ -858,12 +902,13 @@ def get_device_identifier() -> Optional[str]: return None -def detect_install_state() -> str: +def detect_install_state(config_dir: Path = None) -> str: """User-level install state (informational): 'persisted' if this tool's Unbound setup already exists on this device, else 'fresh'. User-level setups are never tamper-eligible, so 'tampered' is never reported.""" + config_dir = config_dir or (Path.home() / ".claude") try: - return "persisted" if (Path.home() / ".claude" / "hooks" / "unbound.py").exists() else "fresh" + return "persisted" if (config_dir / "hooks" / "unbound.py").exists() else "fresh" except Exception as e: debug_print(f"detect_install_state failed: {e}") return "fresh" @@ -1061,16 +1106,16 @@ def _backfill_upload_chunk(api_key: str, backend_url: str, sessions: List[Dict]) return True -def _backfill_state_path(home: Path) -> Path: - return home / '.claude' / 'hooks' / BACKFILL_STATE_FILE +def _backfill_state_path(config_dir: Path) -> Path: + return config_dir / 'hooks' / BACKFILL_STATE_FILE -def _backfill_read_cutoff(home: Path) -> float: +def _backfill_read_cutoff(config_dir: Path) -> float: """mtime cutoff for transcript selection: the last successful backfill when cached (so cron reruns only seed sessions touched since), else 30 days ago.""" default_cutoff = time.time() - (BACKFILL_MAX_AGE_DAYS * 86400) try: - last = float(_backfill_state_path(home).read_text().strip()) + last = float(_backfill_state_path(config_dir).read_text().strip()) except (OSError, ValueError): return default_cutoff # Ignore corrupt or future timestamps (clock skew). @@ -1079,11 +1124,11 @@ def _backfill_read_cutoff(home: Path) -> float: return last -def _backfill_write_cutoff(home: Path, ts: float) -> None: +def _backfill_write_cutoff(config_dir: Path, ts: float) -> None: # Write via temp + atomic replace so an overlapping cron run never reads a # half-written timestamp. try: - path = _backfill_state_path(home) + path = _backfill_state_path(config_dir) path.parent.mkdir(parents=True, exist_ok=True) tmp = path.parent / f'{path.name}.{os.getpid()}.tmp' tmp.write_text(str(ts)) @@ -1203,17 +1248,18 @@ def _backfill_slice_session(session: Dict, max_chunk_bytes: int): start_idx = last_fit_end -def run_backfill(api_key: str, backend_url: str) -> None: - """Walk ~/.claude/projects and seed historical sessions. Never raises.""" +def run_backfill(api_key: str, backend_url: str, config_dir: Path = None) -> None: + """Walk config_dir/projects and seed historical sessions. Never raises.""" if os.environ.get('UNBOUND_BACKFILL_DISABLED') == '1': debug_print("UNBOUND_BACKFILL_DISABLED=1 — skipping backfill") return try: - home = Path.home() + if config_dir is None: + config_dir = Path.home() / '.claude' started_at = time.time() - cutoff_mtime = _backfill_read_cutoff(home) - projects_root = home / '.claude' / 'projects' + cutoff_mtime = _backfill_read_cutoff(config_dir) + projects_root = config_dir / 'projects' sessions: List[Dict] = [] capped = False if projects_root.exists(): @@ -1228,7 +1274,7 @@ def run_backfill(api_key: str, backend_url: str) -> None: if session: sessions.append(session) if not sessions: - _backfill_write_cutoff(home, started_at) + _backfill_write_cutoff(config_dir, started_at) print("[backfill] No past sessions found.") return @@ -1275,7 +1321,7 @@ def _flush(): print(f"[backfill] Done — queued {sessions_sent} past sessions ({failed} chunks failed).") else: if not capped: - _backfill_write_cutoff(home, started_at) + _backfill_write_cutoff(config_dir, started_at) print(f"[backfill] Done — queued {sessions_sent} past sessions for processing.") except Exception as e: print(f"[backfill] Skipped due to error: {e}", file=sys.stderr) @@ -1293,8 +1339,26 @@ def main(): DEBUG = True debug_print("Debug mode enabled") + config_dir = _resolve_claude_config_dir(sys.argv) + # Claude Code resolves CLAUDE_CONFIG_DIR against the current directory when it + # is set but empty, instead of falling back to ~/.claude. Installing to the + # default would then be invisible to it, so refuse to do that quietly. + _raw_cfg = os.environ.get("CLAUDE_CONFIG_DIR") + if _raw_cfg is not None and not _raw_cfg.strip(): + print("\n\u26a0\ufe0f CLAUDE_CONFIG_DIR is set but empty. Claude Code reads that as a path " + "relative to the current directory, not as ~/.claude, so it will not load hooks " + "installed here. Unset the variable, or point it at a real directory.") + # Claude Code picks its config dir from the environment alone. An install + # aimed somewhere else by --config-dir is one it will never read, so say so + # rather than reporting success over a set of hooks that cannot fire. + _arg_dir_given = any(a == "--config-dir" or a.startswith("--config-dir=") for a in sys.argv) + if _arg_dir_given and not (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip(): + print(f"\n⚠️ --config-dir set to {config_dir} but CLAUDE_CONFIG_DIR is not set in the " + "environment. Claude Code reads only the environment variable, so it will not load " + "these hooks. Export CLAUDE_CONFIG_DIR to the same path.") + if clear_mode: - return clear_setup() + return clear_setup(config_dir) if check_enterprise_hooks_conflict(): print("\n❌ Skipped — Claude Code is managed by your organization (MDM).") @@ -1370,7 +1434,7 @@ def main(): remove_env_var(var_name, only_if) except Exception: pass - remove_gateway_artifacts() + remove_gateway_artifacts(config_dir) debug_print("Setting UNBOUND_CLAUDE_API_KEY environment variable...") success, message = set_env_var("UNBOUND_CLAUDE_API_KEY", api_key) @@ -1379,19 +1443,19 @@ def main(): return False debug_print("UNBOUND_CLAUDE_API_KEY set successfully") - _install_state = detect_install_state() + _install_state = detect_install_state(config_dir) _device_id = get_device_identifier() write_unbound_config(api_key, urls={"base_url": backend_url, "gateway_url": gateway_url, "frontend_url": normalize_url(domain) if domain else None}) debug_print("Setting up hooks...") - if not setup_hooks(gateway_url=gateway_url): + if not setup_hooks(gateway_url=gateway_url, config_dir=config_dir): print("❌ Failed to setup hooks") return False debug_print("Hooks downloaded successfully") debug_print("Configuring Claude settings...") - if not configure_claude_settings(): + if not configure_claude_settings(config_dir=config_dir): print("❌ Failed to configure Claude settings") return False debug_print("Claude settings configured successfully") @@ -1403,7 +1467,7 @@ def main(): notify_setup_complete(api_key, "claude-code", backend_url=backend_url, install_state=_install_state, serial_number=_device_id) if backfill_mode: - run_backfill(api_key, backend_url) + run_backfill(api_key, backend_url, config_dir) rc_path = get_shell_rc_file() if rc_path is not None: diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 901c684..e5be530 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -20,9 +20,14 @@ UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" ).rstrip("/") -AUDIT_LOG = Path.home() / ".claude" / "hooks" / "agent-audit.log" -ERROR_LOG = Path.home() / ".claude" / "hooks" / "error.log" -LAST_REPORT_FILE = Path.home() / ".claude" / "hooks" / ".last_error_report" +# Claude Code reads CLAUDE_CONFIG_DIR verbatim and resolves it against the cwd, +# without expanding a leading "~". Expanding it here would put the hook's files +# under $HOME while Claude read a literal "~" dir. Blank is treated as unset. +_env_config_dir = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() +_CONFIG_DIR = Path(os.path.abspath(_env_config_dir)) if _env_config_dir else Path.home() / ".claude" +AUDIT_LOG = _CONFIG_DIR / "hooks" / "agent-audit.log" +ERROR_LOG = _CONFIG_DIR / "hooks" / "error.log" +LAST_REPORT_FILE = _CONFIG_DIR / "hooks" / ".last_error_report" ALLOWED_NON_MCP_HOOK_NAMES = ['Bash', 'Read', 'Write', 'Edit'] # MCP tools (mcp__*) are always checked separately NATIVE_FILE_TOOLS = {'Read', 'Write', 'Edit'} MCP_TOOL_PREFIX = 'mcp__' @@ -34,7 +39,7 @@ SKILL_SEARCH_DIRS = (('.claude', 'skills'),) UNBOUND_SKILL_PREFIX = 'unbound-' -CLAUDE_SKILLS_ROOT = Path(os.environ.get('CLAUDE_CONFIG_DIR') or (Path.home() / '.claude')) / 'skills' +CLAUDE_SKILLS_ROOT = _CONFIG_DIR / 'skills' UNBOUND_SKILL_MARKER = '.unbound-managed' INJECTION_TURN_GUARD_DIR = Path.home() / '.unbound' / 'injection-turn' SKILLS_SYNC_LOCK_PATH = Path.home() / '.unbound' / 'skills-sync.lock' @@ -44,20 +49,22 @@ # unbound- is the skill name, so the slug carries the spec's name rules. _SLUG_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$') + # CoWork built-in tools that are exposed under mcp__ COWORK_BUILTIN_MCP_SERVERS = frozenset({ 'workspace', 'cowork', 'cowork-onboarding', 'visualize', 'scheduled-tasks', 'plugins', 'mcp-registry', 'session_info', 'skills', }) -# CLAUDE_CONFIG_DIR relocates .claude.json entirely; read the file Claude uses. -CLAUDE_MCP_CONFIG_PATH = ( - Path(os.environ['CLAUDE_CONFIG_DIR']) / '.claude.json' - if os.environ.get('CLAUDE_CONFIG_DIR') - else Path.home() / '.claude.json' -) -CLAUDE_PLUGIN_CACHE_DIR = Path.home() / ".claude" / "plugins" / "cache" -POLICY_CACHE_FILE = Path.home() / ".claude" / "hooks" / ".policy_cache.json" +# Claude keeps .claude.json beside the config dir when relocated, and directly +# in the home dir otherwise — it is not nested under the default ~/.claude. +CLAUDE_MCP_CONFIG_PATH = (_CONFIG_DIR / ".claude.json") if _env_config_dir else (Path.home() / ".claude.json") +# Claude Code lets CLAUDE_CODE_PLUGIN_CACHE_DIR override the plugin root outright, +# and falls back to /plugins otherwise. +_env_plugin_dir = (os.environ.get("CLAUDE_CODE_PLUGIN_CACHE_DIR") or "").strip() +_PLUGIN_ROOT = Path(os.path.abspath(_env_plugin_dir)) if _env_plugin_dir else _CONFIG_DIR / "plugins" +CLAUDE_PLUGIN_CACHE_DIR = _PLUGIN_ROOT / "cache" +POLICY_CACHE_FILE = _CONFIG_DIR / "hooks" / ".policy_cache.json" CACHE_TTL_SECONDS = 300 # Repo-scope gate. Straying outside the allowed org is blocked on the first # write, and the gate keeps no state on disk at all. @@ -86,7 +93,7 @@ SELF_UPDATE_INTERVAL_SECONDS = 2 * 3600 SELF_UPDATE_LOCK_TTL_SECONDS = 30 SELF_UPDATE_CURL_TIMEOUT = 10 -SELF_SCRIPT_PATH = Path.home() / ".claude" / "hooks" / "unbound.py" +SELF_SCRIPT_PATH = _CONFIG_DIR / "hooks" / "unbound.py" SELF_UPDATE_STATE_PATH = SELF_SCRIPT_PATH.parent / ".self_update_check" SELF_UPDATE_LOCK_PATH = SELF_SCRIPT_PATH.parent / ".self_update.lock" @@ -109,7 +116,7 @@ MCP_DIAG_COOLDOWN_SECONDS = 6 * 3600 MCP_DIAG_VERSION = "v3" MCP_DIAG_MAX_REPORT_CHARS = 200 * 1024 # stay well under the gateway's 256KB cap -_DIAG_CLAUDE_DIR = Path(os.environ.get('CLAUDE_CONFIG_DIR') or (Path.home() / '.claude')) +_DIAG_CLAUDE_DIR = _CONFIG_DIR _cached_api_key = None _reporting_error = False @@ -317,7 +324,7 @@ def append_to_audit_log(event_data: Dict): pass -_APPROVAL_MARKER_FILE = Path.home() / ".claude" / "hooks" / ".approval_pending" +_APPROVAL_MARKER_FILE = _CONFIG_DIR / "hooks" / ".approval_pending" def _is_approval_retry(command: str) -> bool: @@ -3483,25 +3490,28 @@ def _resolve_skill_path(skill: Optional[str], cwd: Optional[str]) -> Optional[st # A prefixed skill never falls back to the bare name — "slack:standup" # and a personal "standup" are different skills. nested = segments - roots = [] - if cwd: - roots = _trusted_ancestors(Path(cwd)) - roots.append(Path.home()) - - for root in roots: - for skill_dir in SKILL_SEARCH_DIRS: - base = root.joinpath(*nested, *skill_dir) - candidate = base / name / 'SKILL.md' - if candidate.is_file(): - return str(candidate) - # Bundled skills sit one level deeper (skills//). - # Several bundles sharing a name is ambiguous, so resolve - # nothing rather than attach the wrong path to a join key. - matches = sorted(base.glob('*/%s/SKILL.md' % name)) - if len(matches) > 1: - return None - if matches: - return str(matches[0]) + roots = _trusted_ancestors(Path(cwd)) if cwd else [] + + bases = [root.joinpath(*nested, *skill_dir) + for root in roots for skill_dir in SKILL_SEARCH_DIRS] + # User-level skills live in /skills — where the sync writes them + # and where Claude Code reads them. It stands in for ~/.claude/skills rather + # than following it: after a move the sync stops refreshing the old copy, so + # searching there first would pin resolution to whatever it last held. + bases.append(CLAUDE_SKILLS_ROOT.joinpath(*nested)) + + for base in bases: + candidate = base / name / 'SKILL.md' + if candidate.is_file(): + return str(candidate) + # Bundled skills sit one level deeper (skills//). + # Several bundles sharing a name is ambiguous, so resolve + # nothing rather than attach the wrong path to a join key. + matches = sorted(base.glob('*/%s/SKILL.md' % name)) + if len(matches) > 1: + return None + if matches: + return str(matches[0]) return None except Exception: return None diff --git a/tests/claude_code/gateway/test_setup.py b/tests/claude_code/gateway/test_setup.py new file mode 100644 index 0000000..edb4693 --- /dev/null +++ b/tests/claude_code/gateway/test_setup.py @@ -0,0 +1,153 @@ +import json +import os +import tempfile +import unittest +from unittest import mock +from pathlib import Path + +from tests.conftest import tool_module + +gw = tool_module("claude-code/gateway", "setup") + + +class TestResolveClaudeConfigDir(unittest.TestCase): + + def test_env_wins(self): + with tempfile.TemporaryDirectory() as d: + with mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": d}): + self.assertEqual(gw._resolve_claude_config_dir(None), Path(os.path.abspath(d))) + + def test_env_takes_precedence_over_arg(self): + with tempfile.TemporaryDirectory() as d: + with mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": d}): + self.assertEqual(gw._resolve_claude_config_dir("/other/dir"), Path(os.path.abspath(d))) + + def test_arg_used_when_env_absent(self): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("CLAUDE_CONFIG_DIR", None) + self.assertEqual(gw._resolve_claude_config_dir("/opt/cc"), Path(os.path.abspath("/opt/cc"))) + + def test_default_fallback(self): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("CLAUDE_CONFIG_DIR", None) + self.assertEqual(gw._resolve_claude_config_dir(None), Path.home() / ".claude") + + def test_blank_env_falls_back(self): + with mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": " "}): + self.assertEqual(gw._resolve_claude_config_dir(None), Path.home() / ".claude") + + +class TestKeyHelperUnderConfigDir(unittest.TestCase): + def test_custom_dir_writes_there_with_absolute_helper(self): + with tempfile.TemporaryDirectory() as d: + cc = Path(d) / "cc" + gw.setup_claude_key_helper(cc) + self.assertTrue((cc / "anthropic_key.sh").exists()) + settings = json.loads((cc / "settings.json").read_text()) + self.assertEqual(settings["apiKeyHelper"], str(cc / "anthropic_key.sh")) + + def test_default_dir_keeps_portable_helper(self): + with tempfile.TemporaryDirectory() as home: + with mock.patch.object(gw.Path, "home", staticmethod(lambda: Path(home))): + default_dir = Path(home) / ".claude" + gw.setup_claude_key_helper(default_dir) + settings = json.loads((default_dir / "settings.json").read_text()) + self.assertEqual(settings["apiKeyHelper"], "~/.claude/anthropic_key.sh") + + def test_detect_install_state_honors_config_dir(self): + with tempfile.TemporaryDirectory() as d: + cc = Path(d) / "cc" + self.assertEqual(gw.detect_install_state(cc), "fresh") + gw.setup_claude_key_helper(cc) + self.assertEqual(gw.detect_install_state(cc), "persisted") + + def test_apikeyhelper_portable_when_dir_equals_default_via_realpath(self): + # Passing the default dir (even pre-resolution) must still yield the + # portable ~/.claude form, not an absolute realpath. + with tempfile.TemporaryDirectory() as home: + with mock.patch.object(gw.Path, "home", staticmethod(lambda: Path(home))): + gw.setup_claude_key_helper(Path(home) / ".claude") + settings = json.loads((Path(home) / ".claude" / "settings.json").read_text()) + self.assertEqual(settings["apiKeyHelper"], "~/.claude/anthropic_key.sh") + + +class TestRelocatedClearRemovesTheSetting(unittest.TestCase): + def test_clear_removes_apikeyhelper_written_for_a_custom_dir(self): + """A relocated install writes apiKeyHelper as an absolute path. If clear does + not recognise that form it leaves Claude pointing at a deleted script, and + every API call then fails.""" + with tempfile.TemporaryDirectory() as home: + home = Path(home) + with mock.patch.object(gw.Path, "home", staticmethod(lambda: home)): + cc = home / "cc" + gw.setup_claude_key_helper(cc) + settings = json.loads((cc / "settings.json").read_text()) + self.assertEqual(settings["apiKeyHelper"], str(cc / "anthropic_key.sh")) + gw.clear_setup(cc) + after = json.loads((cc / "settings.json").read_text()) + self.assertNotIn("apiKeyHelper", after, "no dangling pointer left behind") + self.assertFalse((cc / "anthropic_key.sh").exists()) + + def test_strip_hooks_finds_hooks_under_a_custom_dir(self): + """Switching from hooks mode to gateway mode must drop the hook entries, or + both enforcement paths fire at once.""" + with tempfile.TemporaryDirectory() as home: + home = Path(home) + with mock.patch.object(gw.Path, "home", staticmethod(lambda: home)): + cc = home / "cc" + hook = cc / "hooks" / "unbound.py" + hook.parent.mkdir(parents=True) + hook.write_text("# unbound") + (cc / "settings.json").write_text(json.dumps( + {"hooks": {"PreToolUse": [{"hooks": [{"command": str(hook)}]}]}})) + gw.setup_claude_key_helper(cc) + after = json.loads((cc / "settings.json").read_text()) + self.assertNotIn("PreToolUse", after.get("hooks", {})) + + +class TestClearSweepsLegacyDir(unittest.TestCase): + def test_clear_relocated_also_clears_default_claude(self): + with tempfile.TemporaryDirectory() as home: + home = Path(home) + with mock.patch.object(gw.Path, "home", staticmethod(lambda: home)): + legacy = home / ".claude" + legacy.mkdir(parents=True) + (legacy / "anthropic_key.sh").write_text(gw.UNBOUND_KEY_HELPER_BODY) + (legacy / "settings.json").write_text(json.dumps({"apiKeyHelper": "~/.claude/anthropic_key.sh"})) + cc = home / "cc" + gw.setup_claude_key_helper(cc) + gw.clear_setup(cc) + # active dir cleared + self.assertFalse((cc / "anthropic_key.sh").exists()) + # legacy ~/.claude swept too + self.assertFalse((legacy / "anthropic_key.sh").exists()) + self.assertNotIn("apiKeyHelper", json.loads((legacy / "settings.json").read_text())) + + def test_clear_relocated_leaves_a_foreign_legacy_helper_alone(self): + """A helper of the same name that we did not write is not ours to delete, + and the legacy sweep must honour that as the primary path does.""" + with tempfile.TemporaryDirectory() as home: + home = Path(home) + with mock.patch.object(gw.Path, "home", staticmethod(lambda: home)): + legacy = home / ".claude" + legacy.mkdir(parents=True) + foreign = legacy / "anthropic_key.sh" + foreign.write_text("echo $MY_COMPANY_KEY") + cc = home / "cc" + gw.setup_claude_key_helper(cc) + gw.clear_setup(cc) + self.assertFalse((cc / "anthropic_key.sh").exists(), "our own install is cleared") + self.assertTrue(foreign.exists(), "someone else's helper survives the sweep") + self.assertEqual(foreign.read_text(), "echo $MY_COMPANY_KEY") + + def test_clear_default_dir_does_not_double_sweep(self): + with tempfile.TemporaryDirectory() as home: + home = Path(home) + with mock.patch.object(gw.Path, "home", staticmethod(lambda: home)): + gw.setup_claude_key_helper(home / ".claude") + gw.clear_setup(home / ".claude") + self.assertFalse((home / ".claude" / "anthropic_key.sh").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/claude_code/hooks/test_config_dir.py b/tests/claude_code/hooks/test_config_dir.py new file mode 100644 index 0000000..05d2e19 --- /dev/null +++ b/tests/claude_code/hooks/test_config_dir.py @@ -0,0 +1,198 @@ +"""Runtime path resolution in unbound.py, checked against what Claude Code does. + +Every constant here is a path Claude Code also computes. If the two disagree the +hook reads or writes somewhere Claude never looks, which fails silently — so +these assertions mirror the resolver in the shipped Claude Code bundle. +""" + +import importlib.util +import itertools +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from tests.conftest import REPO, tool_module + +_UNBOUND = REPO / "claude-code" / "hooks" / "unbound.py" +_seq = itertools.count() +hooks_setup = tool_module("claude-code/hooks", "setup") + + +def _abs(p): + """The absolute form of a test path. Windows prepends the current drive to a + root-relative path, which is what the code under test stores, so expectations + have to go through the same call to compare on either platform.""" + return Path(os.path.abspath(p)) + + +def _reload(**env): + """Import unbound afresh with `env` applied. Every path resolves at import, so + each case needs its own module rather than a reload of the shared one.""" + base = {k: v for k, v in os.environ.items() + if k not in ('CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_PLUGIN_CACHE_DIR')} + base.update({k: v for k, v in env.items() if v is not None}) + # expanduser reads USERPROFILE on Windows and HOME on POSIX; set both so a + # fake home takes effect either way. + if 'HOME' in base: + base.setdefault('USERPROFILE', base['HOME']) + base['USERPROFILE'] = base['HOME'] + with patch.dict(os.environ, base, clear=True): + name = "unbound_cfgdir_%d" % next(_seq) + spec = importlib.util.spec_from_file_location(name, _UNBOUND) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + try: + spec.loader.exec_module(module) + finally: + sys.modules.pop(name, None) + return module + + +class TestConfigDirResolution(unittest.TestCase): + def test_default_when_env_unset(self): + m = _reload(HOME='/home/jane') + self.assertEqual(m._CONFIG_DIR, Path('/home/jane/.claude')) + + def test_relocated_when_env_set(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') + self.assertEqual(m._CONFIG_DIR, _abs('/opt/cc')) + + def test_leading_tilde_is_not_expanded(self): + # Claude Code reads the value verbatim and makes a literal "~" directory. + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='~/cc') + self.assertNotEqual(m._CONFIG_DIR, Path('/home/jane/cc')) + self.assertEqual(m._CONFIG_DIR, Path(os.path.abspath('~/cc'))) + + def test_blank_env_falls_back_to_home(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR=' ') + self.assertEqual(m._CONFIG_DIR, Path('/home/jane/.claude')) + + def test_empty_env_also_falls_back_to_home(self): + # Claude Code would use the cwd here; we deliberately do not follow it + # there, and the installer warns instead. See the note in setup.py main(). + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='') + self.assertEqual(m._CONFIG_DIR, Path('/home/jane/.claude')) + + def test_enforcement_paths_follow_the_relocated_dir(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') + self.assertEqual(m.AUDIT_LOG, _abs('/opt/cc/hooks/agent-audit.log')) + self.assertEqual(m.POLICY_CACHE_FILE, _abs('/opt/cc/hooks/.policy_cache.json')) + self.assertEqual(m.SELF_SCRIPT_PATH, _abs('/opt/cc/hooks/unbound.py')) + self.assertEqual(m.CLAUDE_SKILLS_ROOT, _abs('/opt/cc/skills')) + + +class TestClaudeJsonLocation(unittest.TestCase): + """Claude resolves it as (CLAUDE_CONFIG_DIR or homedir) + '/.claude.json' — + beside the config dir when relocated, never nested under ~/.claude.""" + + def tearDown(self): + _reload() + + def test_sits_in_home_not_under_dot_claude_by_default(self): + m = _reload(HOME='/home/jane') + self.assertEqual(m.CLAUDE_MCP_CONFIG_PATH, Path('/home/jane/.claude.json')) + + def test_moves_into_the_relocated_dir(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') + self.assertEqual(m.CLAUDE_MCP_CONFIG_PATH, _abs('/opt/cc/.claude.json')) + + +class TestPluginCacheDir(unittest.TestCase): + def tearDown(self): + _reload() + + def test_defaults_under_the_config_dir(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') + self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, _abs('/opt/cc/plugins/cache')) + + def test_env_override_wins_over_the_config_dir(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc', + CLAUDE_CODE_PLUGIN_CACHE_DIR='/var/pcache') + self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, _abs('/var/pcache/cache')) + + def test_blank_override_is_ignored(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc', + CLAUDE_CODE_PLUGIN_CACHE_DIR=' ') + self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, _abs('/opt/cc/plugins/cache')) + + + + +class TestGatewayHelperRemovedUnderCustomDir(unittest.TestCase): + """Installing hooks must drop the gateway's apiKeyHelper, including the absolute + form it writes for a relocated dir — otherwise both drive Claude at once.""" + + def test_absolute_helper_setting_is_recognised(self): + with tempfile.TemporaryDirectory() as home: + cc = Path(home) / "cc" + cc.mkdir(parents=True) + (cc / "anthropic_key.sh").write_text(hooks_setup.UNBOUND_KEY_HELPER_BODY) + self.assertTrue(hooks_setup._is_unbound_key_helper_setting( + str(cc / "anthropic_key.sh"), cc)) + + def test_portable_form_is_judged_against_the_default_dir(self): + # A ~ form left over from a default install names ~/.claude, so the script + # there decides — not whatever sits in the relocated dir. + with tempfile.TemporaryDirectory() as home: + with patch.object(hooks_setup.Path, "home", staticmethod(lambda: Path(home))): + default = Path(home) / ".claude" + default.mkdir(parents=True) + (default / "anthropic_key.sh").write_text("echo $SOMEONE_ELSES_KEY") + cc = Path(home) / "cc" + cc.mkdir(parents=True) + (cc / "anthropic_key.sh").write_text(hooks_setup.UNBOUND_KEY_HELPER_BODY) + self.assertFalse( + hooks_setup._is_unbound_key_helper_setting( + hooks_setup.UNBOUND_KEY_HELPER_SETTING, cc), + "the relocated dir's helper must not vouch for the default one") + + def test_a_foreign_helper_setting_is_not_ours(self): + with tempfile.TemporaryDirectory() as home: + cc = Path(home) / "cc" + cc.mkdir(parents=True) + self.assertFalse(hooks_setup._is_unbound_key_helper_setting( + str(cc / "somebody_else.sh"), cc)) + + +class TestSkillResolutionFollowsConfigDir(unittest.TestCase): + """The sync writes managed skills to /skills, so resolution has to + look there — otherwise every Unbound skill silently fails to resolve on a + relocated install.""" + + def test_user_skill_resolves_under_a_relocated_dir(self): + with tempfile.TemporaryDirectory() as home: + cc = Path(home) / "cc" + skill = cc / "skills" / "unbound-review" / "SKILL.md" + skill.parent.mkdir(parents=True) + skill.write_text("# review") + m = _reload(HOME=home, CLAUDE_CONFIG_DIR=str(cc)) + self.assertEqual(m._resolve_skill_path("unbound-review", None), str(skill)) + + def test_a_stale_copy_in_the_old_home_dir_does_not_win(self): + """After a move the sync stops refreshing ~/.claude/skills, so anything left + there is frozen at whatever it last held. Resolution must not prefer it.""" + with tempfile.TemporaryDirectory() as home: + stale = Path(home) / ".claude" / "skills" / "unbound-review" / "SKILL.md" + stale.parent.mkdir(parents=True) + stale.write_text("# stale") + cc = Path(home) / "cc" + fresh = cc / "skills" / "unbound-review" / "SKILL.md" + fresh.parent.mkdir(parents=True) + fresh.write_text("# fresh") + m = _reload(HOME=home, CLAUDE_CONFIG_DIR=str(cc)) + self.assertEqual(m._resolve_skill_path("unbound-review", None), str(fresh)) + + def test_default_dir_resolution_is_unchanged(self): + with tempfile.TemporaryDirectory() as home: + skill = Path(home) / ".claude" / "skills" / "unbound-review" / "SKILL.md" + skill.parent.mkdir(parents=True) + skill.write_text("# review") + m = _reload(HOME=home) + self.assertEqual(m._resolve_skill_path("unbound-review", None), str(skill)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/claude_code/hooks/test_setup.py b/tests/claude_code/hooks/test_setup.py index 9ffead0..be52b3f 100644 --- a/tests/claude_code/hooks/test_setup.py +++ b/tests/claude_code/hooks/test_setup.py @@ -542,3 +542,104 @@ def test_only_the_mdm_trees_carry_the_binary_branch(self): if __name__ == "__main__": unittest.main() + + +class TestResolveClaudeConfigDir(unittest.TestCase): + """CLAUDE_CONFIG_DIR env > --config-dir arg > ~/.claude.""" + + def test_env_beats_arg_and_home(self): + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): + result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) + self.assertEqual(result, Path(os.path.abspath("/env/cc"))) + + def test_arg_used_when_no_env(self): + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) + self.assertEqual(result, Path(os.path.abspath("/arg/cc"))) + + def test_env_used_when_no_arg(self): + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path(os.path.abspath("/env/cc"))) + + def test_home_default_when_arg_and_env_absent(self): + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path.home() / ".claude") + + def test_relative_value_is_absolutized(self): + result = setup._resolve_claude_config_dir(["x", "--config-dir", "rel/cc"]) + self.assertEqual(result, Path(os.path.abspath("rel/cc"))) + + def test_leading_tilde_stays_literal(self): + # Claude Code creates a literal "~" directory rather than expanding it, + # so expanding here would install where Claude never looks. + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "~/cc"}): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path(os.path.abspath("~/cc"))) + self.assertNotEqual(result, Path.home() / "cc") + + def test_blank_env_falls_back_to_home(self): + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": " "}): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path.home() / ".claude") + + def test_equals_form_of_config_dir_arg(self): + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x", "--config-dir=/arg/cc"]) + self.assertEqual(result, Path(os.path.abspath("/arg/cc"))) + + def test_arg_without_env_still_resolves_to_the_arg(self): + # The install honours --config-dir, but Claude Code keys off the env var + # alone, so main() warns that these hooks will not be read. + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) + self.assertEqual(result, Path(os.path.abspath("/arg/cc"))) + + + +class TestInstallUnderResolvedDir(unittest.TestCase): + """Hooks + settings + baked command must land under the resolved config dir.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.home = Path(self.tmp) / "home" + self.home.mkdir(parents=True) + self.config_dir = Path(self.tmp) / "custom-cc" + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_settings_and_hook_command_under_config_dir(self): + with patch.object(Path, "home", staticmethod(lambda: self.home)), \ + patch.object(setup, "download_file", lambda url, dest: dest.parent.mkdir(parents=True, exist_ok=True) or dest.write_text("# hook") or True): + self.assertTrue(setup.setup_hooks(config_dir=self.config_dir)) + self.assertTrue(setup.configure_claude_settings(config_dir=self.config_dir)) + + hook_path = self.config_dir / "hooks" / "unbound.py" + settings_path = self.config_dir / "settings.json" + self.assertTrue(hook_path.exists()) + self.assertTrue(settings_path.exists()) + settings = json.loads(settings_path.read_text()) + cmd = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + self.assertEqual(cmd, str(hook_path)) + self.assertNotIn(str(self.home / ".claude"), cmd) + + def test_backward_compat_no_env_uses_home_claude(self): + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True), \ + patch.object(Path, "home", staticmethod(lambda: self.home)), \ + patch.object(setup, "download_file", lambda url, dest: dest.parent.mkdir(parents=True, exist_ok=True) or dest.write_text("# hook") or True): + config_dir = setup._resolve_claude_config_dir(["x"]) + self.assertTrue(setup.setup_hooks(config_dir=config_dir)) + self.assertTrue(setup.configure_claude_settings(config_dir=config_dir)) + + hook_path = self.home / ".claude" / "hooks" / "unbound.py" + self.assertTrue(hook_path.exists()) + settings = json.loads((self.home / ".claude" / "settings.json").read_text()) + cmd = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + self.assertEqual(cmd, str(hook_path))