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
70 changes: 50 additions & 20 deletions claude-code/gateway/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,21 @@ 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"
return Path(value).expanduser().resolve()


def remove_hooks_unbound_script(config_dir: Path = None) -> None:
"""Remove <config_dir>/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()
Expand All @@ -294,12 +306,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 <config_dir>/anthropic_key.sh that echoes UNBOUND_API_KEY and
update <config_dir>/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"

Expand All @@ -326,8 +338,10 @@ def setup_claude_key_helper() -> bool:
if "hooks" in settings:
del settings["hooks"]

# 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
Expand Down Expand Up @@ -428,12 +442,13 @@ 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:
Expand All @@ -451,8 +466,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)
Expand All @@ -468,19 +484,29 @@ def clear_setup() -> bool:
print(f"Failed to clear {label}")
any_failed = True

_r = _clear_path(Path.home() / ".claude" / "anthropic_key.sh", "Claude anthropic_key.sh")
_r = _clear_path(config_dir / "anthropic_key.sh", "Claude anthropic_key.sh")
if _r == "cleared":
any_cleared = True
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():
if _clear_path(default_dir / "anthropic_key.sh", "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:
Expand Down Expand Up @@ -593,12 +619,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"
Expand Down Expand Up @@ -671,16 +698,19 @@ 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)

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).")
Expand All @@ -702,7 +732,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:
Expand Down Expand Up @@ -742,14 +772,14 @@ 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()

write_unbound_config(api_key, urls={"base_url": args.backend_url, "gateway_url": args.gateway_url, "frontend_url": normalize_url(args.domain) if args.domain else None})

# 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")

Expand Down
105 changes: 105 additions & 0 deletions claude-code/gateway/test_setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import importlib.util
import json
import os
import tempfile
import unittest
from unittest import mock
from pathlib import Path

_SPEC = importlib.util.spec_from_file_location(
"gateway_setup", os.path.join(os.path.dirname(__file__), "setup.py")
)
gw = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(gw)


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(d).resolve())

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(d).resolve())

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("/opt/cc").resolve())

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 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("echo x")
(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_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()
Loading