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

1"""Claude Code launcher: Anthropic env wiring, MCP config, skill, exec.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import os 

7import shutil 

8from pathlib import Path 

9 

10import typer 

11 

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 

19 

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 + "}" 

28 

29 

30def _mcp_config_path() -> Path: 

31 """Launcher-generated MCP config, kept inside lilbee's own data dir. 

32 

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 

37 

38 

39def _claude_skill_dest() -> Path: 

40 return Path.home() / ".claude" / "skills" / "lilbee-mcp" 

41 

42 

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 

55 

56 

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 ) 

67 

68 

69class ClaudeLauncher: 

70 """``Launcher`` implementation for Claude Code (https://claude.com/claude-code).""" 

71 

72 name = "claude" 

73 install_hint = _CLAUDE_INSTALL_HINT 

74 

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 

78 

79 def find_binary(self) -> str | None: 

80 return _find_claude_binary() 

81 

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 

86 

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) 

113 

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) 

136 

137 

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