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