Coverage for src/lilbee/cli/app.py: 100%

134 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""App creation, console, and global callback.""" 

2 

3import logging 

4import os 

5import sys 

6from pathlib import Path 

7from typing import Any 

8 

9import typer 

10from pydantic import ValidationError 

11from typer._click import Context 

12from typer.core import TyperCommand 

13 

14from lilbee.app.services import install_engine_lifecycle_hooks, report_library_upgrade 

15from lilbee.app.version import get_version 

16from lilbee.cli.helpers import json_output as json_out 

17from lilbee.cli.upgrade_line import UpgradeLine 

18from lilbee.core.config import RefusedVariableError, cfg, refuse_environment 

19from lilbee.core.config.model import export_download_environment 

20from lilbee.core.settings import overlay_persisted_settings 

21from lilbee.runtime.console import PlainConsole 

22from lilbee.runtime.onefile_cache import cleanup_stale_onefile_caches 

23 

24app = typer.Typer(help="lilbee: Local RAG knowledge base", invoke_without_command=True) 

25console = PlainConsole() 

26 

27_MODEL_FLAG = "--model" 

28_TEMPERATURE_FLAG = "--temperature" 

29_TOP_P_FLAG = "--top-p" 

30_TOP_K_SAMPLING_FLAG = "--top-k-sampling" 

31_REPEAT_PENALTY_FLAG = "--repeat-penalty" 

32_NUM_CTX_FLAG = "--num-ctx" 

33_SEED_FLAG = "--seed" 

34 

35data_dir_option = typer.Option( 

36 None, 

37 "--data-dir", 

38 "-d", 

39 help="Override data directory (default: platform-specific, see 'lilbee status')", 

40) 

41 

42model_option = typer.Option( 

43 None, 

44 _MODEL_FLAG, 

45 "-m", 

46 help="Override chat model (default: $LILBEE_CHAT_MODEL or the configured chat model)", 

47) 

48 

49json_option = typer.Option( 

50 False, 

51 "--json", 

52 "-j", 

53 help="Emit structured JSON output (for agent/script consumption).", 

54) 

55 

56global_option = typer.Option( 

57 False, 

58 "--global", 

59 "-g", 

60 help="Use the global database, ignoring any local .lilbee/ directory.", 

61) 

62 

63_log_level_option = typer.Option( 

64 None, 

65 "--log-level", 

66 help="Set log level (DEBUG, INFO, WARNING, ERROR). Overrides LILBEE_LOG_LEVEL.", 

67) 

68 

69temperature_option = typer.Option(None, _TEMPERATURE_FLAG, "-t", help="Sampling temperature.") 

70top_p_option = typer.Option(None, _TOP_P_FLAG, help="Top-p (nucleus) sampling threshold.") 

71top_k_sampling_option = typer.Option(None, _TOP_K_SAMPLING_FLAG, help="Top-k sampling count.") 

72repeat_penalty_option = typer.Option(None, _REPEAT_PENALTY_FLAG, help="Repeat penalty factor.") 

73num_ctx_option = typer.Option(None, _NUM_CTX_FLAG, help="Context window size (tokens).") 

74seed_option = typer.Option(None, _SEED_FLAG, help="Random seed for reproducibility.") 

75 

76 

77def _apply_data_root(root: Path) -> None: 

78 """Point cfg paths at *root*, export ``LILBEE_DATA``, overlay config.toml. 

79 

80 Exporting the env var keeps spawn-context worker subprocesses on the 

81 same data root after their fresh ``import lilbee``. The root is 

82 canonicalized so a symlinked or relative ``--data-dir`` keys the same lock 

83 as another process on the same directory. 

84 """ 

85 from lilbee.core.system import canonical_data_root 

86 

87 root = canonical_data_root(root) 

88 cfg.data_root = root 

89 # An explicit LILBEE_DOCUMENTS_DIR wins over the root-derived default, 

90 # matching the env precedence a bare ``import lilbee`` applies. 

91 documents_env = os.environ.get("LILBEE_DOCUMENTS_DIR", "") 

92 cfg.documents_dir = Path(documents_env) if documents_env.strip() else root / "documents" 

93 cfg.data_dir = root / "data" 

94 cfg.lancedb_dir = root / "data" / "lancedb" 

95 os.environ["LILBEE_DATA"] = str(root) 

96 overlay_persisted_settings(root) 

97 export_download_environment() 

98 

99 

100def _resolve_data_root(data_dir: Path | None, use_global: bool) -> None: 

101 """Resolve the data-root precedence: --data-dir | --global | LILBEE_DATA | default.""" 

102 if use_global: 

103 from lilbee.core.system import default_data_dir 

104 

105 _apply_data_root(default_data_dir()) 

106 return 

107 if data_dir is not None: 

108 from lilbee.core.system import LOCAL_ROOT_DIRNAME 

109 

110 # A project folder holding .lilbee/ names that knowledge base, as it does from the cwd. 

111 local = data_dir / LOCAL_ROOT_DIRNAME 

112 _apply_data_root(local if local.is_dir() else data_dir) 

113 return 

114 data_env = os.environ.get("LILBEE_DATA", "") 

115 if data_env: 

116 _apply_data_root(Path(data_env)) 

117 

118 

119class _OverrideState: 

120 """The one-off CLI overrides given this invocation, wherever the flag sat 

121 (typer binds --model both before and after the subcommand).""" 

122 

123 def __init__(self) -> None: 

124 self.values: dict[str, Any] = {} 

125 

126 

127_override_state = _OverrideState() 

128 

129 

130def clear_overrides() -> None: 

131 """Forget the one-off CLI overrides; each invocation starts with none.""" 

132 _override_state.values.clear() 

133 

134 

135def chat_model_overridden() -> bool: 

136 """Whether --model was passed anywhere on this invocation's command line.""" 

137 return "chat_model" in _override_state.values 

138 

139 

140def apply_overrides( 

141 data_dir: Path | None = None, 

142 model: str | None = None, 

143 use_global: bool = False, 

144 temperature: float | None = None, 

145 top_p: float | None = None, 

146 top_k_sampling: int | None = None, 

147 repeat_penalty: float | None = None, 

148 num_ctx: int | None = None, 

149 seed: int | None = None, 

150) -> None: 

151 """Apply CLI overrides to config before any work begins. 

152 Precedence (highest first): 

153 --data-dir / LILBEE_DATA > .lilbee/ (local walk-up) > global platform default 

154 """ 

155 if data_dir is not None and use_global: 

156 raise typer.BadParameter("Cannot use --global with --data-dir") 

157 

158 _resolve_data_root(data_dir, use_global) 

159 

160 # attr -> (flag, value) 

161 overrides: dict[str, tuple[str, Any]] = { 

162 "chat_model": (_MODEL_FLAG, model), 

163 "temperature": (_TEMPERATURE_FLAG, temperature), 

164 "top_p": (_TOP_P_FLAG, top_p), 

165 "top_k_sampling": (_TOP_K_SAMPLING_FLAG, top_k_sampling), 

166 "repeat_penalty": (_REPEAT_PENALTY_FLAG, repeat_penalty), 

167 "num_ctx": (_NUM_CTX_FLAG, num_ctx), 

168 "seed": (_SEED_FLAG, seed), 

169 } 

170 given = {attr: value for attr, (_, value) in overrides.items() if value is not None} 

171 for attr, value in given.items(): 

172 try: 

173 setattr(cfg, attr, value) 

174 except ValidationError as exc: 

175 flag = overrides[attr][0] 

176 raise typer.BadParameter(f"{flag}: {exc.errors()[0]['msg']}") from None 

177 _override_state.values.update(given) 

178 # Re-applied every call: a later call's data-root overlay resets the fields. 

179 for attr, value in _override_state.values.items(): 

180 setattr(cfg, attr, value) 

181 

182 

183def _refuse_environment(json_output: bool) -> None: 

184 """Exit with one error line when the environment sets a variable lilbee does not run with.""" 

185 try: 

186 refuse_environment() 

187 except RefusedVariableError as exc: 

188 if json_output: 

189 json_out({"error": str(exc)}) 

190 else: 

191 typer.echo(f"Error: {exc}", err=True) 

192 raise SystemExit(1) from None 

193 

194 

195class RefusingCommand(TyperCommand): 

196 """A command that refuses the environment when it runs, after its --help is parsed.""" 

197 

198 def invoke(self, ctx: Context) -> Any: 

199 _refuse_environment(cfg.json_mode) 

200 return super().invoke(ctx) 

201 

202 

203def refuse_environment_in(typer_app: typer.Typer) -> None: 

204 """Make every command registered on *typer_app*, nested groups included, a refusing one.""" 

205 for command in typer_app.registered_commands: 

206 command.cls = RefusingCommand 

207 for group in typer_app.registered_groups: 

208 if group.typer_instance is not None: 

209 refuse_environment_in(group.typer_instance) 

210 

211 

212@app.callback() 

213def _default( 

214 ctx: typer.Context, 

215 data_dir: Path | None = data_dir_option, 

216 model: str | None = model_option, 

217 json_output: bool = json_option, 

218 use_global: bool = global_option, 

219 log_level: str | None = _log_level_option, 

220 show_version: bool = typer.Option( 

221 False, 

222 "--version", 

223 "-V", 

224 help="Show version and exit.", 

225 is_eager=True, 

226 ), 

227) -> None: 

228 """Start interactive chat when no command is given.""" 

229 clear_overrides() 

230 if show_version: 

231 typer.echo(f"lilbee {get_version()}") 

232 raise SystemExit(0) 

233 

234 env_level = os.environ.get("LILBEE_LOG_LEVEL", "") 

235 level_str = (log_level or env_level or "WARNING").upper() 

236 from lilbee.cli.log_routing import set_explicit_verbosity 

237 

238 set_explicit_verbosity(bool(log_level or env_level)) 

239 _log_levels = { 

240 "DEBUG": logging.DEBUG, 

241 "INFO": logging.INFO, 

242 "WARNING": logging.WARNING, 

243 "ERROR": logging.ERROR, 

244 } 

245 level = _log_levels.get(level_str, logging.WARNING) 

246 logging.basicConfig( 

247 level=level, format="%(levelname)s %(name)s: %(message)s", stream=sys.stderr 

248 ) 

249 # basicConfig is a no-op when handlers already exist, so always set level explicitly 

250 logging.getLogger().setLevel(level) 

251 

252 cleanup_stale_onefile_caches() 

253 

254 # Swallow lancedb's shutdown-time thread noise: opt-in side effect, not 

255 # imposed on library consumers of lilbee. 

256 from lilbee.data.store import install_lancedb_thread_error_suppressor 

257 

258 install_lancedb_thread_error_suppressor() 

259 

260 # A terminal close or `kill` otherwise leaves the engine fleet running and its 

261 # VRAM pinned, because the default disposition skips the atexit teardown. 

262 install_engine_lifecycle_hooks() 

263 # On stderr, so stdout stays the command's output. 

264 report_library_upgrade(UpgradeLine(PlainConsole(stderr=True))) 

265 

266 cfg.json_mode = json_output 

267 # Typer binds options placed before the subcommand name to this callback; 

268 # apply them here for every invocation. Subcommands re-call apply_overrides 

269 # with their own (post-subcommand) flags, and re-applying None is a no-op, 

270 # so ``--data-dir`` / ``--model`` / ``--global`` work in either position. 

271 apply_overrides(data_dir=data_dir, model=model, use_global=use_global) 

272 # Backend-level logging toggles are applied lazily by SdkLLMProvider 

273 # on first use, so nothing else is needed here. 

274 if ctx.invoked_subcommand is None: 

275 _refuse_environment(json_output) 

276 if cfg.json_mode: 

277 json_out({"error": "Interactive chat requires a terminal, not --json"}) 

278 raise SystemExit(1) 

279 if not sys.stdin.isatty() or not sys.stdout.isatty(): 

280 typer.echo("Error: Interactive chat requires a terminal.", err=True) 

281 raise SystemExit(1) 

282 from lilbee.cli.tui import run_tui 

283 

284 run_tui()