Coverage for src/lilbee/cli/commands/agent_config.py: 100%
63 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""`lilbee agent-config <client>`, print a paste-ready config block."""
3from __future__ import annotations
5import json
6from pathlib import Path
8import typer
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, with_configured_chat
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
29agent_config_app = typer.Typer(help="Print a paste-ready config block for an AI client.")
31_SERVE_HINT = "Start `lilbee serve --port 8080` first, then re-run this command."
33_STDIO_HINT = (
34 "To have the client start lilbee itself instead of using the running "
35 "server, register this block instead:"
36)
39def _apply_overrides(*, data_dir: Path | None, use_global: bool) -> None:
40 """Apply CLI overrides for a command that only writes config, with the fleet left cold.
42 The command lists installed models and reads the served window from the
43 running server. It runs no inference, so the services it builds must not
44 start the engine warm-up.
45 """
46 apply_overrides(data_dir=data_dir, use_global=use_global)
47 cfg.worker_pool_eager_start = False
50def _session_or_exit() -> tuple[str, int]:
51 """The running server's ``(token, port)``; exits non-zero when none is up."""
52 session = running_server_session()
53 if session is None:
54 typer.secho(_SERVE_HINT, err=True, fg=typer.colors.RED)
55 raise typer.Exit(1)
56 return session
59def _served_chat_models() -> list[str]:
60 """Chat refs to advertise: the installed ones plus the configured model the server serves."""
61 return with_configured_chat(installed_chat_model_refs(), cfg.chat_model)
64def _warn_on_small_agent_window(chat_ctx: int | None) -> None:
65 """Say when the served window cannot hold an agent's first turn, with the remedy.
67 ``lilbee launch`` sizes the window itself; this paste path runs against a
68 server the user started, whose window is fixed at boot, so the printed
69 config would otherwise carry a window the first message overflows. An
70 unknown window (no chat engine yet) stays silent.
71 """
72 if chat_ctx is None or chat_ctx >= AGENT_CHAT_CTX_FLOOR:
73 return
74 typer.secho(
75 f"Warning: the server serves a {chat_ctx:,}-token context window, but an "
76 f"agent's first turn (system prompt plus tool schemas) needs about "
77 f"{AGENT_CHAT_CTX_FLOOR:,}, so the first message can overflow. To raise it: "
78 f"stop the server, run 'lilbee engine stop', set chat_n_ctx_target to "
79 f"{AGENT_CHAT_CTX_FLOOR} (or start with "
80 f"LILBEE_CHAT_N_CTX_TARGET={AGENT_CHAT_CTX_FLOOR}), then run 'lilbee serve' "
81 "again. If the window stays small, the model's trained context or device "
82 "memory is the limit; use a longer-context model or a smaller quantization.",
83 err=True,
84 fg=typer.colors.YELLOW,
85 )
88def _build(client: AgentClient) -> AgentConfigDocument:
89 """Build *client*'s document from the running server's port and token."""
90 token, port = _session_or_exit()
91 serves_models = client_serves_models(client)
92 chat_ctx = client_chat_ctx(port) if serves_models else None
93 _warn_on_small_agent_window(chat_ctx)
94 return build_agent_config(
95 client,
96 base_url=f"http://{LOOPBACK}:{port}",
97 api_key=token,
98 model_refs=_served_chat_models() if serves_models else None,
99 # Match the launchers: pin the served model as default and pass the
100 # context window, so the pasted config opens on a lilbee model and trims
101 # history to the right limit.
102 default_ref=str(cfg.chat_model) if serves_models else None,
103 chat_ctx=chat_ctx,
104 )
107def _emit(document: AgentConfigDocument) -> None:
108 """Print the block on stdout, with any client-specific note on stderr."""
109 if document.format is ConfigFormat.YAML:
110 # Use typer.echo (no Rich word-wrap) so YAML stays parseable when
111 # piped to a file or wrapped in narrow test terminals.
112 typer.echo(document.content, nl=False)
113 else:
114 typer.echo(json.dumps(document.config, indent=2))
115 if document.stdio_config is not None:
116 typer.secho(_STDIO_HINT, err=True, fg=typer.colors.YELLOW)
117 typer.echo(json.dumps(document.stdio_config, indent=2), err=True)
118 if document.client is AgentClient.HERMES:
119 # Parity with `lilbee launch hermes`: the MCP block only works once
120 # hermes has its `mcp` extra. The paste path can't install it, so
121 # surface the same hint (to stderr, keeping the YAML pipe-clean).
122 typer.secho(MCP_EXTRA_HINT, err=True, fg=typer.colors.YELLOW)
125@agent_config_app.command("claude")
126def _claude_cmd(
127 data_dir: Path | None = data_dir_option,
128 use_global: bool = global_option,
129) -> None:
130 """Print a Claude Code mcpServers block registering lilbee's MCP tools."""
131 _apply_overrides(data_dir=data_dir, use_global=use_global)
132 _emit(_build(AgentClient.CLAUDE))
135@agent_config_app.command("opencode")
136def _opencode_cmd(
137 data_dir: Path | None = data_dir_option,
138 use_global: bool = global_option,
139) -> None:
140 """Print an opencode.json block (OpenAI-compatible provider + MCP server)."""
141 _apply_overrides(data_dir=data_dir, use_global=use_global)
142 _emit(_build(AgentClient.OPENCODE))
145@agent_config_app.command("hermes")
146def _hermes_cmd(
147 data_dir: Path | None = data_dir_option,
148 use_global: bool = global_option,
149) -> None:
150 """Print a hermes config.yaml block (OpenAI-compatible provider + MCP server)."""
151 _apply_overrides(data_dir=data_dir, use_global=use_global)
152 _emit(_build(AgentClient.HERMES))
155@agent_config_app.command("litellm")
156def _litellm_cmd(
157 data_dir: Path | None = data_dir_option,
158 use_global: bool = global_option,
159) -> None:
160 """Print a LiteLLM `config.yaml` snippet routing model names to lilbee."""
161 _apply_overrides(data_dir=data_dir, use_global=use_global)
162 token, port = _session_or_exit()
163 snippet = litellm_config(
164 base_url=f"http://{LOOPBACK}:{port}",
165 api_key=token,
166 model_refs=_served_chat_models(),
167 )
168 typer.echo(snippet, nl=False)