Coverage for src/lilbee/cli/commands/agent_config.py: 100%

61 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""`lilbee agent-config <client>`, print a paste-ready config block.""" 

2 

3from __future__ import annotations 

4 

5import json 

6from pathlib import Path 

7 

8import typer 

9 

10from lilbee.app.agent_configs.document import ( 

11 AgentClient, 

12 AgentConfigDocument, 

13 ConfigFormat, 

14 build_agent_config, 

15 client_serves_models, 

16) 

17from lilbee.app.agent_configs.litellm import litellm_config 

18from lilbee.app.agent_configs.window import AGENT_CHAT_CTX_FLOOR 

19from lilbee.app.models import installed_chat_model_refs 

20from lilbee.cli.app import apply_overrides, data_dir_option, global_option 

21from lilbee.cli.launchers.hermes_mcp import MCP_EXTRA_HINT 

22from lilbee.cli.launchers.server import ( 

23 LOOPBACK, 

24 client_chat_ctx, 

25 running_server_session, 

26) 

27from lilbee.core.config import cfg 

28from lilbee.providers.model_ref import with_configured_remote_chat 

29 

30agent_config_app = typer.Typer(help="Print a paste-ready config block for an AI client.") 

31 

32_SERVE_HINT = "Start `lilbee serve --port 8080` first, then re-run this command." 

33 

34_STDIO_HINT = ( 

35 "To have the client start lilbee itself instead of using the running " 

36 "server, register this block instead:" 

37) 

38 

39 

40def _session_or_exit() -> tuple[str, int]: 

41 """The running server's ``(token, port)``; exits non-zero when none is up.""" 

42 session = running_server_session() 

43 if session is None: 

44 typer.secho(_SERVE_HINT, err=True, fg=typer.colors.RED) 

45 raise typer.Exit(1) 

46 return session 

47 

48 

49def _served_chat_models() -> list[str]: 

50 """Chat refs to advertise: the installed ones plus a remote-configured model.""" 

51 return with_configured_remote_chat(installed_chat_model_refs(), cfg.chat_model) 

52 

53 

54def _warn_on_small_agent_window(chat_ctx: int | None) -> None: 

55 """Say when the served window cannot hold an agent's first turn, with the remedy. 

56 

57 ``lilbee launch`` sizes the window itself; this paste path runs against a 

58 server the user started, whose window is fixed at boot, so the printed 

59 config would otherwise carry a window the first message overflows. An 

60 unknown window (no chat engine yet) stays silent. 

61 """ 

62 if chat_ctx is None or chat_ctx >= AGENT_CHAT_CTX_FLOOR: 

63 return 

64 typer.secho( 

65 f"Warning: the server serves a {chat_ctx:,}-token context window, but an " 

66 f"agent's first turn (system prompt plus tool schemas) needs about " 

67 f"{AGENT_CHAT_CTX_FLOOR:,}, so the first message can overflow. To raise it: " 

68 f"stop the server, run 'lilbee engine stop', set chat_n_ctx_target to " 

69 f"{AGENT_CHAT_CTX_FLOOR} (or start with " 

70 f"LILBEE_CHAT_N_CTX_TARGET={AGENT_CHAT_CTX_FLOOR}), then run 'lilbee serve' " 

71 "again. If the window stays small, the model's trained context or device " 

72 "memory is the limit; use a longer-context model or a smaller quantization.", 

73 err=True, 

74 fg=typer.colors.YELLOW, 

75 ) 

76 

77 

78def _build(client: AgentClient) -> AgentConfigDocument: 

79 """Build *client*'s document from the running server's port and token.""" 

80 token, port = _session_or_exit() 

81 serves_models = client_serves_models(client) 

82 chat_ctx = client_chat_ctx(port) if serves_models else None 

83 _warn_on_small_agent_window(chat_ctx) 

84 return build_agent_config( 

85 client, 

86 base_url=f"http://{LOOPBACK}:{port}", 

87 api_key=token, 

88 model_refs=_served_chat_models() if serves_models else None, 

89 # Match the launchers: pin the served model as default and pass the 

90 # context window, so the pasted config opens on a lilbee model and trims 

91 # history to the right limit. 

92 default_ref=str(cfg.chat_model) if serves_models else None, 

93 chat_ctx=chat_ctx, 

94 ) 

95 

96 

97def _emit(document: AgentConfigDocument) -> None: 

98 """Print the block on stdout, with any client-specific note on stderr.""" 

99 if document.format is ConfigFormat.YAML: 

100 # Use typer.echo (no Rich word-wrap) so YAML stays parseable when 

101 # piped to a file or wrapped in narrow test terminals. 

102 typer.echo(document.content, nl=False) 

103 else: 

104 typer.echo(json.dumps(document.config, indent=2)) 

105 if document.stdio_config is not None: 

106 typer.secho(_STDIO_HINT, err=True, fg=typer.colors.YELLOW) 

107 typer.echo(json.dumps(document.stdio_config, indent=2), err=True) 

108 if document.client is AgentClient.HERMES: 

109 # Parity with `lilbee launch hermes`: the MCP block only works once 

110 # hermes has its `mcp` extra. The paste path can't install it, so 

111 # surface the same hint (to stderr, keeping the YAML pipe-clean). 

112 typer.secho(MCP_EXTRA_HINT, err=True, fg=typer.colors.YELLOW) 

113 

114 

115@agent_config_app.command("claude") 

116def _claude_cmd( 

117 data_dir: Path | None = data_dir_option, 

118 use_global: bool = global_option, 

119) -> None: 

120 """Print a Claude Code mcpServers block registering lilbee's MCP tools.""" 

121 apply_overrides(data_dir=data_dir, use_global=use_global) 

122 _emit(_build(AgentClient.CLAUDE)) 

123 

124 

125@agent_config_app.command("opencode") 

126def _opencode_cmd( 

127 data_dir: Path | None = data_dir_option, 

128 use_global: bool = global_option, 

129) -> None: 

130 """Print an opencode.json block (OpenAI-compatible provider + MCP server).""" 

131 apply_overrides(data_dir=data_dir, use_global=use_global) 

132 _emit(_build(AgentClient.OPENCODE)) 

133 

134 

135@agent_config_app.command("hermes") 

136def _hermes_cmd( 

137 data_dir: Path | None = data_dir_option, 

138 use_global: bool = global_option, 

139) -> None: 

140 """Print a hermes config.yaml block (OpenAI-compatible provider + MCP server).""" 

141 apply_overrides(data_dir=data_dir, use_global=use_global) 

142 _emit(_build(AgentClient.HERMES)) 

143 

144 

145@agent_config_app.command("litellm") 

146def _litellm_cmd( 

147 data_dir: Path | None = data_dir_option, 

148 use_global: bool = global_option, 

149) -> None: 

150 """Print a LiteLLM `config.yaml` snippet routing model names to lilbee.""" 

151 apply_overrides(data_dir=data_dir, use_global=use_global) 

152 token, port = _session_or_exit() 

153 snippet = litellm_config( 

154 base_url=f"http://{LOOPBACK}:{port}", 

155 api_key=token, 

156 model_refs=_served_chat_models(), 

157 ) 

158 typer.echo(snippet, nl=False)