Coverage for src/lilbee/cli/app.py: 100%
91 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"""App creation, console, and global callback."""
3import logging
4import os
5import sys
6from pathlib import Path
7from typing import Any
9import typer
10from rich.console import Console
12from lilbee.app.services import install_engine_lifecycle_hooks
13from lilbee.app.version import get_version
14from lilbee.cli.helpers import json_output as json_out
15from lilbee.core.config import cfg, config_load_error
16from lilbee.core.settings import overlay_persisted_settings
18app = typer.Typer(help="lilbee: Local RAG knowledge base", invoke_without_command=True)
19console = Console()
21data_dir_option = typer.Option(
22 None,
23 "--data-dir",
24 "-d",
25 help="Override data directory (default: platform-specific, see 'lilbee status')",
26)
28model_option = typer.Option(
29 None,
30 "--model",
31 "-m",
32 help="Override chat model (default: $LILBEE_CHAT_MODEL or the configured chat model)",
33)
35json_option = typer.Option(
36 False,
37 "--json",
38 "-j",
39 help="Emit structured JSON output (for agent/script consumption).",
40)
42global_option = typer.Option(
43 False,
44 "--global",
45 "-g",
46 help="Use the global database, ignoring any local .lilbee/ directory.",
47)
49_log_level_option = typer.Option(
50 None,
51 "--log-level",
52 help="Set log level (DEBUG, INFO, WARNING, ERROR). Overrides LILBEE_LOG_LEVEL.",
53)
55temperature_option = typer.Option(None, "--temperature", "-t", help="Sampling temperature.")
56top_p_option = typer.Option(None, "--top-p", help="Top-p (nucleus) sampling threshold.")
57top_k_sampling_option = typer.Option(None, "--top-k-sampling", help="Top-k sampling count.")
58repeat_penalty_option = typer.Option(None, "--repeat-penalty", help="Repeat penalty factor.")
59num_ctx_option = typer.Option(None, "--num-ctx", help="Context window size (tokens).")
60seed_option = typer.Option(None, "--seed", help="Random seed for reproducibility.")
63def _apply_data_root(root: Path) -> None:
64 """Point cfg paths at *root*, export ``LILBEE_DATA``, overlay config.toml.
66 Exporting the env var keeps spawn-context worker subprocesses on the
67 same data root after their fresh ``import lilbee``. The root is
68 canonicalized so a symlinked or relative ``--data-dir`` keys the same lock
69 as another process on the same directory.
70 """
71 from lilbee.core.system import canonical_data_root
73 root = canonical_data_root(root)
74 cfg.data_root = root
75 cfg.documents_dir = root / "documents"
76 cfg.data_dir = root / "data"
77 cfg.lancedb_dir = root / "data" / "lancedb"
78 os.environ["LILBEE_DATA"] = str(root)
79 overlay_persisted_settings(root)
82def _resolve_data_root(data_dir: Path | None, use_global: bool) -> None:
83 """Resolve the data-root precedence: --data-dir | --global | LILBEE_DATA | default."""
84 if use_global:
85 from lilbee.core.system import default_data_dir
87 _apply_data_root(default_data_dir())
88 return
89 if data_dir is not None:
90 _apply_data_root(data_dir)
91 return
92 data_env = os.environ.get("LILBEE_DATA", "")
93 if data_env:
94 _apply_data_root(Path(data_env))
97class _OverrideState:
98 """Which one-off CLI overrides were given this invocation, wherever the
99 flag sat (typer binds --model both before and after the subcommand)."""
101 def __init__(self) -> None:
102 self.chat_model = False
105_override_state = _OverrideState()
108def chat_model_overridden() -> bool:
109 """Whether --model was passed anywhere on this invocation's command line."""
110 return _override_state.chat_model
113def apply_overrides(
114 data_dir: Path | None = None,
115 model: str | None = None,
116 use_global: bool = False,
117 temperature: float | None = None,
118 top_p: float | None = None,
119 top_k_sampling: int | None = None,
120 repeat_penalty: float | None = None,
121 num_ctx: int | None = None,
122 seed: int | None = None,
123) -> None:
124 """Apply CLI overrides to config before any work begins.
125 Precedence (highest first):
126 --data-dir / LILBEE_DATA > .lilbee/ (local walk-up) > global platform default
127 """
128 if data_dir is not None and use_global:
129 raise typer.BadParameter("Cannot use --global with --data-dir")
131 _resolve_data_root(data_dir, use_global)
133 if model is not None:
134 _override_state.chat_model = True
135 overrides: dict[str, Any] = {
136 "chat_model": model,
137 "temperature": temperature,
138 "top_p": top_p,
139 "top_k_sampling": top_k_sampling,
140 "repeat_penalty": repeat_penalty,
141 "num_ctx": num_ctx,
142 "seed": seed,
143 }
144 for attr, value in overrides.items():
145 if value is not None:
146 setattr(cfg, attr, value)
149@app.callback()
150def _default(
151 ctx: typer.Context,
152 data_dir: Path | None = data_dir_option,
153 model: str | None = model_option,
154 json_output: bool = json_option,
155 use_global: bool = global_option,
156 log_level: str | None = _log_level_option,
157 show_version: bool = typer.Option(
158 False,
159 "--version",
160 "-V",
161 help="Show version and exit.",
162 is_eager=True,
163 ),
164) -> None:
165 """Start interactive chat when no command is given."""
166 _override_state.chat_model = False
167 if show_version:
168 typer.echo(f"lilbee {get_version()}")
169 raise SystemExit(0)
171 if config_load_error is not None and not json_output:
172 # Print to stderr so JSON-mode output stays parseable.
173 sys.stderr.write(
174 "Warning: persisted config has values this version doesn't accept; "
175 "running with defaults until you fix it.\n"
176 f" Detail: {config_load_error}\n"
177 )
179 env_level = os.environ.get("LILBEE_LOG_LEVEL", "")
180 level_str = (log_level or env_level or "WARNING").upper()
181 from lilbee.cli.log_routing import set_explicit_verbosity
183 set_explicit_verbosity(bool(log_level or env_level))
184 _log_levels = {
185 "DEBUG": logging.DEBUG,
186 "INFO": logging.INFO,
187 "WARNING": logging.WARNING,
188 "ERROR": logging.ERROR,
189 }
190 level = _log_levels.get(level_str, logging.WARNING)
191 logging.basicConfig(
192 level=level, format="%(levelname)s %(name)s: %(message)s", stream=sys.stderr
193 )
194 # basicConfig is a no-op when handlers already exist, so always set level explicitly
195 logging.getLogger().setLevel(level)
197 # Swallow lancedb's shutdown-time thread noise: opt-in side effect, not
198 # imposed on library consumers of lilbee.
199 from lilbee.data.store import install_lancedb_thread_error_suppressor
201 install_lancedb_thread_error_suppressor()
203 # A terminal close or `kill` otherwise leaves the engine fleet running and its
204 # VRAM pinned, because the default disposition skips the atexit teardown.
205 install_engine_lifecycle_hooks()
207 cfg.json_mode = json_output
208 # Typer binds options placed before the subcommand name to this callback;
209 # apply them here for every invocation. Subcommands re-call apply_overrides
210 # with their own (post-subcommand) flags, and re-applying None is a no-op,
211 # so ``--data-dir`` / ``--model`` / ``--global`` work in either position.
212 apply_overrides(data_dir=data_dir, model=model, use_global=use_global)
213 # Backend-level logging toggles are applied lazily by SdkLLMProvider
214 # on first use, so nothing else is needed here.
215 if ctx.invoked_subcommand is None:
216 if cfg.json_mode:
217 json_out({"error": "Interactive chat requires a terminal, not --json"})
218 raise SystemExit(1)
219 if not sys.stdin.isatty() or not sys.stdout.isatty():
220 typer.echo("Error: Interactive chat requires a terminal.", err=True)
221 raise SystemExit(1)
222 from lilbee.cli.tui import run_tui
224 run_tui()