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

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

2 

3import logging 

4import os 

5import sys 

6from pathlib import Path 

7 

8import typer 

9from rich.console import Console 

10 

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 

16 

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

18console = Console() 

19 

20data_dir_option = typer.Option( 

21 None, 

22 "--data-dir", 

23 "-d", 

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

25) 

26 

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) 

33 

34json_option = typer.Option( 

35 False, 

36 "--json", 

37 "-j", 

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

39) 

40 

41global_option = typer.Option( 

42 False, 

43 "--global", 

44 "-g", 

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

46) 

47 

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) 

53 

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

60 

61 

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) 

69 

70 

71def overlay_persisted_settings(root: Path) -> None: 

72 """Overlay persisted scalars from ``<root>/config.toml`` onto cfg. 

73 

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 ) 

98 

99 

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

117 

118 if use_global: 

119 from lilbee.platform import default_data_dir 

120 

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

128 

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 

143 

144 

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) 

165 

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 ) 

173 

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) 

189 

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 

193 

194 install_lancedb_thread_error_suppressor() 

195 

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 

208 

209 run_tui(auto_sync=True)