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

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 rich.console import Console 

11 

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 

18 

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

20console = Console() 

21 

22data_dir_option = typer.Option( 

23 None, 

24 "--data-dir", 

25 "-d", 

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

27) 

28 

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) 

35 

36json_option = typer.Option( 

37 False, 

38 "--json", 

39 "-j", 

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

41) 

42 

43global_option = typer.Option( 

44 False, 

45 "--global", 

46 "-g", 

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

48) 

49 

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) 

55 

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.") 

62 

63 

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

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

66 

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 

73 

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) 

84 

85 

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 

90 

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)) 

99 

100 

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).""" 

104 

105 def __init__(self) -> None: 

106 self.chat_model = False 

107 

108 

109_override_state = _OverrideState() 

110 

111 

112def chat_model_overridden() -> bool: 

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

114 return _override_state.chat_model 

115 

116 

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") 

134 

135 _resolve_data_root(data_dir, use_global) 

136 

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) 

151 

152 

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) 

174 

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 ) 

182 

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 

186 

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) 

200 

201 cleanup_stale_onefile_caches() 

202 

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 

206 

207 install_lancedb_thread_error_suppressor() 

208 

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() 

212 

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 

229 

230 run_tui()