Coverage for src/lilbee/cli/launchers/claude.py: 100%
62 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Claude Code launcher: Anthropic env wiring, MCP config, skill, exec."""
3from __future__ import annotations
5import json
6import os
7import shutil
8from pathlib import Path
10import typer
12from lilbee.app.agent_configs.claude import claude_http_config
13from lilbee.cli.launchers import config_file
14from lilbee.cli.launchers.launcher import LILBEE_TOKEN_ENV_VAR, run_launcher
15from lilbee.cli.launchers.server import LOOPBACK, client_chat_ctx
16from lilbee.cli.launchers.setup_gate import confirm_first_run_setup
17from lilbee.cli.launchers.skill_install import install_bundled_skill
18from lilbee.core.config import cfg
20_CLAUDE_INSTALL_HINT = (
21 "claude binary not found on PATH. Install Claude Code from https://claude.com/claude-code."
22)
23_SETUP_MARKER_NAME = "claude-setup.json"
24_MCP_CONFIG_NAME = "claude-mcp.json"
25# The written MCP config references the token by env var (Claude Code expands
26# ${...} at load), so the file never holds the literal.
27_TOKEN_REF = "${" + LILBEE_TOKEN_ENV_VAR + "}"
30def _mcp_config_path() -> Path:
31 """Launcher-generated MCP config, kept inside lilbee's own data dir.
33 Passed via ``--mcp-config`` instead of merging into ``~/.claude.json`` so a
34 launch never rewrites Claude Code's own settings.
35 """
36 return cfg.data_dir / "launchers" / _MCP_CONFIG_NAME
39def _claude_skill_dest() -> Path:
40 return Path.home() / ".claude" / "skills" / "lilbee-mcp"
43def _find_claude_binary() -> str | None:
44 """The claude binary on PATH, or its two conventional install locations."""
45 found = shutil.which("claude")
46 if found:
47 return found
48 for candidate in (
49 Path.home() / ".claude" / "local" / "claude",
50 Path.home() / ".local" / "bin" / "claude",
51 ):
52 if candidate.is_file() and os.access(candidate, os.X_OK):
53 return str(candidate)
54 return None
57def _print_setup_plan() -> None:
58 """Tell the user exactly which files the first-run setup writes."""
59 typer.secho("First-time Claude Code setup will write:", fg=typer.colors.CYAN)
60 typer.echo(f" - lilbee MCP config -> {_mcp_config_path()}")
61 typer.echo(f" - lilbee MCP skill -> {_claude_skill_dest()}")
62 typer.echo(
63 "Claude Code's own settings are not touched; the MCP config is passed "
64 "per launch via --mcp-config. The token is referenced by env, never "
65 "written as a literal. To undo, remove the two paths above."
66 )
69class ClaudeLauncher:
70 """``Launcher`` implementation for Claude Code (https://claude.com/claude-code)."""
72 name = "claude"
73 install_hint = _CLAUDE_INSTALL_HINT
75 def __init__(self, *, assume_yes: bool = False, include_mcp: bool = True) -> None:
76 self._assume_yes = assume_yes
77 self._include_mcp = include_mcp
79 def find_binary(self) -> str | None:
80 return _find_claude_binary()
82 def prepare(
83 self, *, token: str, port: int, model_refs: list[str]
84 ) -> tuple[list[str], dict[str, str]]:
85 from lilbee.catalog import agent_model_id
87 base_url = f"http://{LOOPBACK}:{port}"
88 model_id = agent_model_id(str(cfg.chat_model))
89 env = {
90 **os.environ,
91 LILBEE_TOKEN_ENV_VAR: token,
92 "ANTHROPIC_BASE_URL": base_url,
93 # Claude Code sends the auth token as a bearer Authorization
94 # header, which is exactly what lilbee's /v1 auth validates.
95 "ANTHROPIC_AUTH_TOKEN": token,
96 # Cleared so a real Anthropic key in the shell can't ride along to
97 # the local server (or shadow the auth token).
98 "ANTHROPIC_API_KEY": "",
99 # Every tier Claude Code reaches for resolves to the one model
100 # lilbee serves, subagents included.
101 "ANTHROPIC_DEFAULT_OPUS_MODEL": model_id,
102 "ANTHROPIC_DEFAULT_SONNET_MODEL": model_id,
103 "ANTHROPIC_DEFAULT_HAIKU_MODEL": model_id,
104 "CLAUDE_CODE_SUBAGENT_MODEL": model_id,
105 "CLAUDE_CODE_ATTRIBUTION_HEADER": "0",
106 "DISABLE_ERROR_REPORTING": "1",
107 }
108 ctx = client_chat_ctx(port)
109 if ctx is not None:
110 # Compact before the local window overflows; Claude Code's default
111 # assumes a frontier-sized context.
112 env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = str(ctx)
114 # Project-scoped settings only: the user's global plugins and skills
115 # add tens of thousands of prompt tokens (a measured ~50K baseline
116 # with a populated ~/.claude), which no local model's window absorbs.
117 # Project-level CLAUDE.md and .claude/ still load.
118 extra_args = ["--model", model_id, "--setting-sources", "project,local"]
119 if self._include_mcp:
120 if not confirm_first_run_setup(
121 marker_name=_SETUP_MARKER_NAME,
122 client_name="Claude Code",
123 print_plan=_print_setup_plan,
124 assume_yes=self._assume_yes,
125 ):
126 raise typer.Exit(0)
127 block = claude_http_config(base_url=base_url, api_key=_TOKEN_REF)
128 config_file.atomic_write_text(_mcp_config_path(), json.dumps(block, indent=2))
129 # --strict-mcp-config keeps the session to lilbee's one MCP server.
130 # Without it, every server in the user's own Claude Code config
131 # loads too, and their tool schemas alone can overflow a local
132 # model's context before the first turn.
133 extra_args.extend(["--mcp-config", str(_mcp_config_path()), "--strict-mcp-config"])
134 install_bundled_skill(_claude_skill_dest())
135 return (extra_args, env)
138def claude_cmd(
139 yes: bool = typer.Option(
140 False,
141 "--no-prompt",
142 "--yes",
143 "-y",
144 help="Proceed with first-run setup without the interactive prompt (for scripts/CI).",
145 ),
146 mcp: bool | None = typer.Option(
147 None,
148 "--mcp/--no-mcp",
149 help="Inject lilbee's MCP search tool into Claude Code. Defaults to the "
150 "agent_mcp_enabled config; --mcp/--no-mcp overrides it for this launch.",
151 ),
152) -> None:
153 """Launch Claude Code with lilbee as its model backend."""
154 include_mcp = cfg.agent_mcp_enabled if mcp is None else mcp
155 run_launcher(ClaudeLauncher(assume_yes=yes, include_mcp=include_mcp))