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

91 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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 

17 

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

19console = Console() 

20 

21data_dir_option = typer.Option( 

22 None, 

23 "--data-dir", 

24 "-d", 

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

26) 

27 

28model_option = typer.Option( 

29 None, 

30 "--model", 

31 "-m", 

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

33) 

34 

35json_option = typer.Option( 

36 False, 

37 "--json", 

38 "-j", 

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

40) 

41 

42global_option = typer.Option( 

43 False, 

44 "--global", 

45 "-g", 

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

47) 

48 

49_log_level_option = typer.Option( 

50 None, 

51 "--log-level", 

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

53) 

54 

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

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

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

58repeat_penalty_option = typer.Option(None, "--repeat-penalty", help="Repeat penalty factor.") 

59num_ctx_option = typer.Option(None, "--num-ctx", help="Context window size (tokens).") 

60seed_option = typer.Option(None, "--seed", help="Random seed for reproducibility.") 

61 

62 

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

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

65 

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

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

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

69 as another process on the same directory. 

70 """ 

71 from lilbee.core.system import canonical_data_root 

72 

73 root = canonical_data_root(root) 

74 cfg.data_root = root 

75 cfg.documents_dir = root / "documents" 

76 cfg.data_dir = root / "data" 

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

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

79 overlay_persisted_settings(root) 

80 

81 

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

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

84 if use_global: 

85 from lilbee.core.system import default_data_dir 

86 

87 _apply_data_root(default_data_dir()) 

88 return 

89 if data_dir is not None: 

90 _apply_data_root(data_dir) 

91 return 

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

93 if data_env: 

94 _apply_data_root(Path(data_env)) 

95 

96 

97class _OverrideState: 

98 """Which one-off CLI overrides were given this invocation, wherever the 

99 flag sat (typer binds --model both before and after the subcommand).""" 

100 

101 def __init__(self) -> None: 

102 self.chat_model = False 

103 

104 

105_override_state = _OverrideState() 

106 

107 

108def chat_model_overridden() -> bool: 

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

110 return _override_state.chat_model 

111 

112 

113def apply_overrides( 

114 data_dir: Path | None = None, 

115 model: str | None = None, 

116 use_global: bool = False, 

117 temperature: float | None = None, 

118 top_p: float | None = None, 

119 top_k_sampling: int | None = None, 

120 repeat_penalty: float | None = None, 

121 num_ctx: int | None = None, 

122 seed: int | None = None, 

123) -> None: 

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

125 Precedence (highest first): 

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

127 """ 

128 if data_dir is not None and use_global: 

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

130 

131 _resolve_data_root(data_dir, use_global) 

132 

133 if model is not None: 

134 _override_state.chat_model = True 

135 overrides: dict[str, Any] = { 

136 "chat_model": model, 

137 "temperature": temperature, 

138 "top_p": top_p, 

139 "top_k_sampling": top_k_sampling, 

140 "repeat_penalty": repeat_penalty, 

141 "num_ctx": num_ctx, 

142 "seed": seed, 

143 } 

144 for attr, value in overrides.items(): 

145 if value is not None: 

146 setattr(cfg, attr, value) 

147 

148 

149@app.callback() 

150def _default( 

151 ctx: typer.Context, 

152 data_dir: Path | None = data_dir_option, 

153 model: str | None = model_option, 

154 json_output: bool = json_option, 

155 use_global: bool = global_option, 

156 log_level: str | None = _log_level_option, 

157 show_version: bool = typer.Option( 

158 False, 

159 "--version", 

160 "-V", 

161 help="Show version and exit.", 

162 is_eager=True, 

163 ), 

164) -> None: 

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

166 _override_state.chat_model = False 

167 if show_version: 

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

169 raise SystemExit(0) 

170 

171 if config_load_error is not None and not json_output: 

172 # Print to stderr so JSON-mode output stays parseable. 

173 sys.stderr.write( 

174 "Warning: persisted config has values this version doesn't accept; " 

175 "running with defaults until you fix it.\n" 

176 f" Detail: {config_load_error}\n" 

177 ) 

178 

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

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

181 from lilbee.cli.log_routing import set_explicit_verbosity 

182 

183 set_explicit_verbosity(bool(log_level or env_level)) 

184 _log_levels = { 

185 "DEBUG": logging.DEBUG, 

186 "INFO": logging.INFO, 

187 "WARNING": logging.WARNING, 

188 "ERROR": logging.ERROR, 

189 } 

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

191 logging.basicConfig( 

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

193 ) 

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

195 logging.getLogger().setLevel(level) 

196 

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

198 # imposed on library consumers of lilbee. 

199 from lilbee.data.store import install_lancedb_thread_error_suppressor 

200 

201 install_lancedb_thread_error_suppressor() 

202 

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

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

205 install_engine_lifecycle_hooks() 

206 

207 cfg.json_mode = json_output 

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

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

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

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

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

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

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

215 if ctx.invoked_subcommand is None: 

216 if cfg.json_mode: 

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

218 raise SystemExit(1) 

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

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

221 raise SystemExit(1) 

222 from lilbee.cli.tui import run_tui 

223 

224 run_tui()