Coverage for src/lilbee/cli/app.py: 100%
134 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"""App creation, console, and global callback."""
3import logging
4import os
5import sys
6from pathlib import Path
7from typing import Any
9import typer
10from pydantic import ValidationError
11from typer._click import Context
12from typer.core import TyperCommand
14from lilbee.app.services import install_engine_lifecycle_hooks, report_library_upgrade
15from lilbee.app.version import get_version
16from lilbee.cli.helpers import json_output as json_out
17from lilbee.cli.upgrade_line import UpgradeLine
18from lilbee.core.config import RefusedVariableError, cfg, refuse_environment
19from lilbee.core.config.model import export_download_environment
20from lilbee.core.settings import overlay_persisted_settings
21from lilbee.runtime.console import PlainConsole
22from lilbee.runtime.onefile_cache import cleanup_stale_onefile_caches
24app = typer.Typer(help="lilbee: Local RAG knowledge base", invoke_without_command=True)
25console = PlainConsole()
27_MODEL_FLAG = "--model"
28_TEMPERATURE_FLAG = "--temperature"
29_TOP_P_FLAG = "--top-p"
30_TOP_K_SAMPLING_FLAG = "--top-k-sampling"
31_REPEAT_PENALTY_FLAG = "--repeat-penalty"
32_NUM_CTX_FLAG = "--num-ctx"
33_SEED_FLAG = "--seed"
35data_dir_option = typer.Option(
36 None,
37 "--data-dir",
38 "-d",
39 help="Override data directory (default: platform-specific, see 'lilbee status')",
40)
42model_option = typer.Option(
43 None,
44 _MODEL_FLAG,
45 "-m",
46 help="Override chat model (default: $LILBEE_CHAT_MODEL or the configured chat model)",
47)
49json_option = typer.Option(
50 False,
51 "--json",
52 "-j",
53 help="Emit structured JSON output (for agent/script consumption).",
54)
56global_option = typer.Option(
57 False,
58 "--global",
59 "-g",
60 help="Use the global database, ignoring any local .lilbee/ directory.",
61)
63_log_level_option = typer.Option(
64 None,
65 "--log-level",
66 help="Set log level (DEBUG, INFO, WARNING, ERROR). Overrides LILBEE_LOG_LEVEL.",
67)
69temperature_option = typer.Option(None, _TEMPERATURE_FLAG, "-t", help="Sampling temperature.")
70top_p_option = typer.Option(None, _TOP_P_FLAG, help="Top-p (nucleus) sampling threshold.")
71top_k_sampling_option = typer.Option(None, _TOP_K_SAMPLING_FLAG, help="Top-k sampling count.")
72repeat_penalty_option = typer.Option(None, _REPEAT_PENALTY_FLAG, help="Repeat penalty factor.")
73num_ctx_option = typer.Option(None, _NUM_CTX_FLAG, help="Context window size (tokens).")
74seed_option = typer.Option(None, _SEED_FLAG, help="Random seed for reproducibility.")
77def _apply_data_root(root: Path) -> None:
78 """Point cfg paths at *root*, export ``LILBEE_DATA``, overlay config.toml.
80 Exporting the env var keeps spawn-context worker subprocesses on the
81 same data root after their fresh ``import lilbee``. The root is
82 canonicalized so a symlinked or relative ``--data-dir`` keys the same lock
83 as another process on the same directory.
84 """
85 from lilbee.core.system import canonical_data_root
87 root = canonical_data_root(root)
88 cfg.data_root = root
89 # An explicit LILBEE_DOCUMENTS_DIR wins over the root-derived default,
90 # matching the env precedence a bare ``import lilbee`` applies.
91 documents_env = os.environ.get("LILBEE_DOCUMENTS_DIR", "")
92 cfg.documents_dir = Path(documents_env) if documents_env.strip() else root / "documents"
93 cfg.data_dir = root / "data"
94 cfg.lancedb_dir = root / "data" / "lancedb"
95 os.environ["LILBEE_DATA"] = str(root)
96 overlay_persisted_settings(root)
97 export_download_environment()
100def _resolve_data_root(data_dir: Path | None, use_global: bool) -> None:
101 """Resolve the data-root precedence: --data-dir | --global | LILBEE_DATA | default."""
102 if use_global:
103 from lilbee.core.system import default_data_dir
105 _apply_data_root(default_data_dir())
106 return
107 if data_dir is not None:
108 from lilbee.core.system import LOCAL_ROOT_DIRNAME
110 # A project folder holding .lilbee/ names that knowledge base, as it does from the cwd.
111 local = data_dir / LOCAL_ROOT_DIRNAME
112 _apply_data_root(local if local.is_dir() else data_dir)
113 return
114 data_env = os.environ.get("LILBEE_DATA", "")
115 if data_env:
116 _apply_data_root(Path(data_env))
119class _OverrideState:
120 """The one-off CLI overrides given this invocation, wherever the flag sat
121 (typer binds --model both before and after the subcommand)."""
123 def __init__(self) -> None:
124 self.values: dict[str, Any] = {}
127_override_state = _OverrideState()
130def clear_overrides() -> None:
131 """Forget the one-off CLI overrides; each invocation starts with none."""
132 _override_state.values.clear()
135def chat_model_overridden() -> bool:
136 """Whether --model was passed anywhere on this invocation's command line."""
137 return "chat_model" in _override_state.values
140def apply_overrides(
141 data_dir: Path | None = None,
142 model: str | None = None,
143 use_global: bool = False,
144 temperature: float | None = None,
145 top_p: float | None = None,
146 top_k_sampling: int | None = None,
147 repeat_penalty: float | None = None,
148 num_ctx: int | None = None,
149 seed: int | None = None,
150) -> None:
151 """Apply CLI overrides to config before any work begins.
152 Precedence (highest first):
153 --data-dir / LILBEE_DATA > .lilbee/ (local walk-up) > global platform default
154 """
155 if data_dir is not None and use_global:
156 raise typer.BadParameter("Cannot use --global with --data-dir")
158 _resolve_data_root(data_dir, use_global)
160 # attr -> (flag, value)
161 overrides: dict[str, tuple[str, Any]] = {
162 "chat_model": (_MODEL_FLAG, model),
163 "temperature": (_TEMPERATURE_FLAG, temperature),
164 "top_p": (_TOP_P_FLAG, top_p),
165 "top_k_sampling": (_TOP_K_SAMPLING_FLAG, top_k_sampling),
166 "repeat_penalty": (_REPEAT_PENALTY_FLAG, repeat_penalty),
167 "num_ctx": (_NUM_CTX_FLAG, num_ctx),
168 "seed": (_SEED_FLAG, seed),
169 }
170 given = {attr: value for attr, (_, value) in overrides.items() if value is not None}
171 for attr, value in given.items():
172 try:
173 setattr(cfg, attr, value)
174 except ValidationError as exc:
175 flag = overrides[attr][0]
176 raise typer.BadParameter(f"{flag}: {exc.errors()[0]['msg']}") from None
177 _override_state.values.update(given)
178 # Re-applied every call: a later call's data-root overlay resets the fields.
179 for attr, value in _override_state.values.items():
180 setattr(cfg, attr, value)
183def _refuse_environment(json_output: bool) -> None:
184 """Exit with one error line when the environment sets a variable lilbee does not run with."""
185 try:
186 refuse_environment()
187 except RefusedVariableError as exc:
188 if json_output:
189 json_out({"error": str(exc)})
190 else:
191 typer.echo(f"Error: {exc}", err=True)
192 raise SystemExit(1) from None
195class RefusingCommand(TyperCommand):
196 """A command that refuses the environment when it runs, after its --help is parsed."""
198 def invoke(self, ctx: Context) -> Any:
199 _refuse_environment(cfg.json_mode)
200 return super().invoke(ctx)
203def refuse_environment_in(typer_app: typer.Typer) -> None:
204 """Make every command registered on *typer_app*, nested groups included, a refusing one."""
205 for command in typer_app.registered_commands:
206 command.cls = RefusingCommand
207 for group in typer_app.registered_groups:
208 if group.typer_instance is not None:
209 refuse_environment_in(group.typer_instance)
212@app.callback()
213def _default(
214 ctx: typer.Context,
215 data_dir: Path | None = data_dir_option,
216 model: str | None = model_option,
217 json_output: bool = json_option,
218 use_global: bool = global_option,
219 log_level: str | None = _log_level_option,
220 show_version: bool = typer.Option(
221 False,
222 "--version",
223 "-V",
224 help="Show version and exit.",
225 is_eager=True,
226 ),
227) -> None:
228 """Start interactive chat when no command is given."""
229 clear_overrides()
230 if show_version:
231 typer.echo(f"lilbee {get_version()}")
232 raise SystemExit(0)
234 env_level = os.environ.get("LILBEE_LOG_LEVEL", "")
235 level_str = (log_level or env_level or "WARNING").upper()
236 from lilbee.cli.log_routing import set_explicit_verbosity
238 set_explicit_verbosity(bool(log_level or env_level))
239 _log_levels = {
240 "DEBUG": logging.DEBUG,
241 "INFO": logging.INFO,
242 "WARNING": logging.WARNING,
243 "ERROR": logging.ERROR,
244 }
245 level = _log_levels.get(level_str, logging.WARNING)
246 logging.basicConfig(
247 level=level, format="%(levelname)s %(name)s: %(message)s", stream=sys.stderr
248 )
249 # basicConfig is a no-op when handlers already exist, so always set level explicitly
250 logging.getLogger().setLevel(level)
252 cleanup_stale_onefile_caches()
254 # Swallow lancedb's shutdown-time thread noise: opt-in side effect, not
255 # imposed on library consumers of lilbee.
256 from lilbee.data.store import install_lancedb_thread_error_suppressor
258 install_lancedb_thread_error_suppressor()
260 # A terminal close or `kill` otherwise leaves the engine fleet running and its
261 # VRAM pinned, because the default disposition skips the atexit teardown.
262 install_engine_lifecycle_hooks()
263 # On stderr, so stdout stays the command's output.
264 report_library_upgrade(UpgradeLine(PlainConsole(stderr=True)))
266 cfg.json_mode = json_output
267 # Typer binds options placed before the subcommand name to this callback;
268 # apply them here for every invocation. Subcommands re-call apply_overrides
269 # with their own (post-subcommand) flags, and re-applying None is a no-op,
270 # so ``--data-dir`` / ``--model`` / ``--global`` work in either position.
271 apply_overrides(data_dir=data_dir, model=model, use_global=use_global)
272 # Backend-level logging toggles are applied lazily by SdkLLMProvider
273 # on first use, so nothing else is needed here.
274 if ctx.invoked_subcommand is None:
275 _refuse_environment(json_output)
276 if cfg.json_mode:
277 json_out({"error": "Interactive chat requires a terminal, not --json"})
278 raise SystemExit(1)
279 if not sys.stdin.isatty() or not sys.stdout.isatty():
280 typer.echo("Error: Interactive chat requires a terminal.", err=True)
281 raise SystemExit(1)
282 from lilbee.cli.tui import run_tui
284 run_tui()