Coverage for src / lilbee / cli / app.py: 100%
77 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +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.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
17app = typer.Typer(help="lilbee: Local RAG knowledge base", invoke_without_command=True)
18console = Console()
20data_dir_option = typer.Option(
21 None,
22 "--data-dir",
23 "-d",
24 help="Override data directory (default: platform-specific, see 'lilbee status')",
25)
27model_option = typer.Option(
28 None,
29 "--model",
30 "-m",
31 help="Override chat model (default: $LILBEE_CHAT_MODEL or the featured Qwen3 entry)",
32)
34json_option = typer.Option(
35 False,
36 "--json",
37 "-j",
38 help="Emit structured JSON output (for agent/script consumption).",
39)
41global_option = typer.Option(
42 False,
43 "--global",
44 "-g",
45 help="Use the global database, ignoring any local .lilbee/ directory.",
46)
48_log_level_option = typer.Option(
49 None,
50 "--log-level",
51 help="Set log level (DEBUG, INFO, WARNING, ERROR). Overrides LILBEE_LOG_LEVEL.",
52)
54temperature_option = typer.Option(None, "--temperature", "-t", help="Sampling temperature.")
55top_p_option = typer.Option(None, "--top-p", help="Top-p (nucleus) sampling threshold.")
56top_k_sampling_option = typer.Option(None, "--top-k-sampling", help="Top-k sampling count.")
57repeat_penalty_option = typer.Option(None, "--repeat-penalty", help="Repeat penalty factor.")
58num_ctx_option = typer.Option(None, "--num-ctx", help="Context window size (tokens).")
59seed_option = typer.Option(None, "--seed", help="Random seed for reproducibility.")
62def _apply_data_root(root: Path) -> None:
63 """Point cfg paths at *root*, export ``LILBEE_DATA``, overlay config.toml.
65 Exporting the env var keeps spawn-context worker subprocesses on the
66 same data root after their fresh ``import lilbee``.
67 """
68 cfg.data_root = root
69 cfg.documents_dir = root / "documents"
70 cfg.data_dir = root / "data"
71 cfg.lancedb_dir = root / "data" / "lancedb"
72 os.environ["LILBEE_DATA"] = str(root)
73 overlay_persisted_settings(root)
76def _resolve_data_root(data_dir: Path | None, use_global: bool) -> None:
77 """Resolve the data-root precedence: --data-dir | --global | LILBEE_DATA | default."""
78 if use_global:
79 from lilbee.core.system import default_data_dir
81 _apply_data_root(default_data_dir())
82 return
83 if data_dir is not None:
84 _apply_data_root(data_dir)
85 return
86 data_env = os.environ.get("LILBEE_DATA", "")
87 if data_env:
88 _apply_data_root(Path(data_env))
91def apply_overrides(
92 data_dir: Path | None = None,
93 model: str | None = None,
94 use_global: bool = False,
95 temperature: float | None = None,
96 top_p: float | None = None,
97 top_k_sampling: int | None = None,
98 repeat_penalty: float | None = None,
99 num_ctx: int | None = None,
100 seed: int | None = None,
101) -> None:
102 """Apply CLI overrides to config before any work begins.
103 Precedence (highest first):
104 --data-dir / LILBEE_DATA > .lilbee/ (local walk-up) > global platform default
105 """
106 if data_dir is not None and use_global:
107 raise typer.BadParameter("Cannot use --global with --data-dir")
109 _resolve_data_root(data_dir, use_global)
111 overrides: dict[str, Any] = {
112 "chat_model": model,
113 "temperature": temperature,
114 "top_p": top_p,
115 "top_k_sampling": top_k_sampling,
116 "repeat_penalty": repeat_penalty,
117 "num_ctx": num_ctx,
118 "seed": seed,
119 }
120 for attr, value in overrides.items():
121 if value is not None:
122 setattr(cfg, attr, value)
125@app.callback()
126def _default(
127 ctx: typer.Context,
128 data_dir: Path | None = data_dir_option,
129 model: str | None = model_option,
130 json_output: bool = json_option,
131 use_global: bool = global_option,
132 log_level: str | None = _log_level_option,
133 show_version: bool = typer.Option(
134 False,
135 "--version",
136 "-V",
137 help="Show version and exit.",
138 is_eager=True,
139 ),
140) -> None:
141 """Start interactive chat when no command is given."""
142 if show_version:
143 typer.echo(f"lilbee {get_version()}")
144 raise SystemExit(0)
146 if config_load_error is not None and not json_output:
147 # Print to stderr so JSON-mode output stays parseable.
148 sys.stderr.write(
149 "Warning: persisted config has values this version doesn't accept; "
150 "running with defaults until you fix it.\n"
151 f" Detail: {config_load_error}\n"
152 )
154 level_str = os.environ.get("LILBEE_LOG_LEVEL", "WARNING").upper()
155 if log_level is not None:
156 level_str = log_level.upper()
157 _log_levels = {
158 "DEBUG": logging.DEBUG,
159 "INFO": logging.INFO,
160 "WARNING": logging.WARNING,
161 "ERROR": logging.ERROR,
162 }
163 level = _log_levels.get(level_str, logging.WARNING)
164 logging.basicConfig(
165 level=level, format="%(levelname)s %(name)s: %(message)s", stream=sys.stderr
166 )
167 # basicConfig is a no-op when handlers already exist, so always set level explicitly
168 logging.getLogger().setLevel(level)
170 # Swallow lancedb's shutdown-time thread noise: opt-in side effect, not
171 # imposed on library consumers of lilbee.
172 from lilbee.data.store import install_lancedb_thread_error_suppressor
174 install_lancedb_thread_error_suppressor()
176 cfg.json_mode = json_output
177 # Typer binds options placed before the subcommand name to this callback;
178 # apply them here for every invocation. Subcommands re-call apply_overrides
179 # with their own (post-subcommand) flags, and re-applying None is a no-op,
180 # so ``--data-dir`` / ``--model`` / ``--global`` work in either position.
181 apply_overrides(data_dir=data_dir, model=model, use_global=use_global)
182 # Backend-level logging toggles are applied lazily by SdkLLMProvider
183 # on first use, so nothing else is needed here.
184 if ctx.invoked_subcommand is None:
185 if cfg.json_mode:
186 json_out({"error": "Interactive chat requires a terminal, not --json"})
187 raise SystemExit(1)
188 if not sys.stdin.isatty() or not sys.stdout.isatty():
189 typer.echo("Error: Interactive chat requires a terminal.", err=True)
190 raise SystemExit(1)
191 from lilbee.cli.tui import run_tui
193 run_tui()