Coverage for src/lilbee/cli/app.py: 100%
94 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""App creation, console, and global callback."""
3import logging
4import os
5import sys
6from pathlib import Path
7from typing import Any
9import typer
11from lilbee.app.services import install_engine_lifecycle_hooks
12from lilbee.app.version import get_version
13from lilbee.cli.helpers import json_output as json_out
14from lilbee.core.config import cfg, config_load_error
15from lilbee.core.settings import overlay_persisted_settings
16from lilbee.runtime.console import PlainConsole
17from lilbee.runtime.onefile_cache import cleanup_stale_onefile_caches
19app = typer.Typer(help="lilbee: Local RAG knowledge base", invoke_without_command=True)
20console = PlainConsole()
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()