Coverage for src / lilbee / cli / app.py: 100%
99 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
1"""App creation, console, and global callback."""
3import logging
4import os
5import sys
6from pathlib import Path
8import typer
9from rich.console import Console
11from lilbee import settings as _settings_module
12from lilbee.cli.helpers import get_version
13from lilbee.cli.helpers import json_output as json_out
14from lilbee.config import cfg, config_load_error
15from lilbee.config_meta import MODEL_ROLE_FIELDS, WRITABLE_CONFIG_FIELDS
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* and overlay its config.toml onto cfg."""
64 cfg.data_root = root
65 cfg.documents_dir = root / "documents"
66 cfg.data_dir = root / "data"
67 cfg.lancedb_dir = root / "data" / "lancedb"
68 overlay_persisted_settings(root)
71def overlay_persisted_settings(root: Path) -> None:
72 """Overlay persisted scalars from ``<root>/config.toml`` onto cfg.
74 Bad values are logged and skipped. Shared with the MCP ``init`` tool
75 so per-vault model preferences take effect on every entry point.
76 """
77 log = logging.getLogger(__name__)
78 try:
79 persisted = _settings_module.load(root)
80 except (OSError, ValueError):
81 log.warning("Failed to read %s/config.toml; using in-memory defaults", root)
82 return
83 if not persisted:
84 return
85 overlayable = set(WRITABLE_CONFIG_FIELDS) | set(MODEL_ROLE_FIELDS)
86 for key, raw in persisted.items():
87 if key not in overlayable:
88 continue
89 try:
90 setattr(cfg, key, raw)
91 except (ValueError, TypeError) as exc:
92 log.warning(
93 "Ignoring invalid persisted value for %s in %s: %s",
94 key,
95 root,
96 exc,
97 )
100def apply_overrides(
101 data_dir: Path | None = None,
102 model: str | None = None,
103 use_global: bool = False,
104 temperature: float | None = None,
105 top_p: float | None = None,
106 top_k_sampling: int | None = None,
107 repeat_penalty: float | None = None,
108 num_ctx: int | None = None,
109 seed: int | None = None,
110) -> None:
111 """Apply CLI overrides to config before any work begins.
112 Precedence (highest first):
113 --data-dir / LILBEE_DATA > .lilbee/ (local walk-up) > global platform default
114 """
115 if data_dir is not None and use_global:
116 raise typer.BadParameter("Cannot use --global with --data-dir")
118 if use_global:
119 from lilbee.platform import default_data_dir
121 _apply_data_root(default_data_dir())
122 elif data_dir is not None:
123 _apply_data_root(data_dir)
124 else:
125 data_env = os.environ.get("LILBEE_DATA", "")
126 if data_env:
127 _apply_data_root(Path(data_env))
129 if model is not None:
130 cfg.chat_model = model
131 if temperature is not None:
132 cfg.temperature = temperature
133 if top_p is not None:
134 cfg.top_p = top_p
135 if top_k_sampling is not None:
136 cfg.top_k_sampling = top_k_sampling
137 if repeat_penalty is not None:
138 cfg.repeat_penalty = repeat_penalty
139 if num_ctx is not None:
140 cfg.num_ctx = num_ctx
141 if seed is not None:
142 cfg.seed = seed
145@app.callback()
146def _default(
147 ctx: typer.Context,
148 data_dir: Path | None = data_dir_option,
149 model: str | None = model_option,
150 json_output: bool = json_option,
151 use_global: bool = global_option,
152 log_level: str | None = _log_level_option,
153 show_version: bool = typer.Option(
154 False,
155 "--version",
156 "-V",
157 help="Show version and exit.",
158 is_eager=True,
159 ),
160) -> None:
161 """Start interactive chat when no command is given."""
162 if show_version:
163 typer.echo(f"lilbee {get_version()}")
164 raise SystemExit(0)
166 if config_load_error is not None and not json_output:
167 # Print to stderr so JSON-mode output stays parseable.
168 sys.stderr.write(
169 "Warning: persisted config has values this version doesn't accept; "
170 "running with defaults until you fix it.\n"
171 f" Detail: {config_load_error}\n"
172 )
174 level_str = os.environ.get("LILBEE_LOG_LEVEL", "WARNING").upper()
175 if log_level is not None:
176 level_str = log_level.upper()
177 _log_levels = {
178 "DEBUG": logging.DEBUG,
179 "INFO": logging.INFO,
180 "WARNING": logging.WARNING,
181 "ERROR": logging.ERROR,
182 }
183 level = _log_levels.get(level_str, logging.WARNING)
184 logging.basicConfig(
185 level=level, format="%(levelname)s %(name)s: %(message)s", stream=sys.stderr
186 )
187 # basicConfig is a no-op when handlers already exist, so always set level explicitly
188 logging.getLogger().setLevel(level)
190 # Swallow lancedb's shutdown-time thread noise — opt-in side effect, not
191 # imposed on library consumers of lilbee.
192 from lilbee.store import install_lancedb_thread_error_suppressor
194 install_lancedb_thread_error_suppressor()
196 cfg.json_mode = json_output
197 # Backend-level logging toggles are applied lazily by SdkLLMProvider
198 # on first use, so nothing else is needed here.
199 if ctx.invoked_subcommand is None:
200 apply_overrides(data_dir=data_dir, model=model, use_global=use_global)
201 if cfg.json_mode:
202 json_out({"error": "Interactive chat requires a terminal, not --json"})
203 raise SystemExit(1)
204 if not sys.stdin.isatty() or not sys.stdout.isatty():
205 typer.echo("Error: Interactive chat requires a terminal.", err=True)
206 raise SystemExit(1)
207 from lilbee.cli.tui import run_tui
209 run_tui(auto_sync=True)