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

77 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-05-15 20:55 +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.version import get_version 

13from lilbee.cli.helpers import json_output as json_out 

14from lilbee.core.config import cfg, config_load_error 

15from lilbee.core.settings import overlay_persisted_settings 

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*, export ``LILBEE_DATA``, overlay config.toml. 

64 

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

66 same data root after their fresh ``import lilbee``. 

67 """ 

68 cfg.data_root = root 

69 cfg.documents_dir = root / "documents" 

70 cfg.data_dir = root / "data" 

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

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

73 overlay_persisted_settings(root) 

74 

75 

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

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

78 if use_global: 

79 from lilbee.core.system import default_data_dir 

80 

81 _apply_data_root(default_data_dir()) 

82 return 

83 if data_dir is not None: 

84 _apply_data_root(data_dir) 

85 return 

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

87 if data_env: 

88 _apply_data_root(Path(data_env)) 

89 

90 

91def apply_overrides( 

92 data_dir: Path | None = None, 

93 model: str | None = None, 

94 use_global: bool = False, 

95 temperature: float | None = None, 

96 top_p: float | None = None, 

97 top_k_sampling: int | None = None, 

98 repeat_penalty: float | None = None, 

99 num_ctx: int | None = None, 

100 seed: int | None = None, 

101) -> None: 

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

103 Precedence (highest first): 

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

105 """ 

106 if data_dir is not None and use_global: 

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

108 

109 _resolve_data_root(data_dir, use_global) 

110 

111 overrides: dict[str, Any] = { 

112 "chat_model": model, 

113 "temperature": temperature, 

114 "top_p": top_p, 

115 "top_k_sampling": top_k_sampling, 

116 "repeat_penalty": repeat_penalty, 

117 "num_ctx": num_ctx, 

118 "seed": seed, 

119 } 

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

121 if value is not None: 

122 setattr(cfg, attr, value) 

123 

124 

125@app.callback() 

126def _default( 

127 ctx: typer.Context, 

128 data_dir: Path | None = data_dir_option, 

129 model: str | None = model_option, 

130 json_output: bool = json_option, 

131 use_global: bool = global_option, 

132 log_level: str | None = _log_level_option, 

133 show_version: bool = typer.Option( 

134 False, 

135 "--version", 

136 "-V", 

137 help="Show version and exit.", 

138 is_eager=True, 

139 ), 

140) -> None: 

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

142 if show_version: 

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

144 raise SystemExit(0) 

145 

146 if config_load_error is not None and not json_output: 

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

148 sys.stderr.write( 

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

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

151 f" Detail: {config_load_error}\n" 

152 ) 

153 

154 level_str = os.environ.get("LILBEE_LOG_LEVEL", "WARNING").upper() 

155 if log_level is not None: 

156 level_str = log_level.upper() 

157 _log_levels = { 

158 "DEBUG": logging.DEBUG, 

159 "INFO": logging.INFO, 

160 "WARNING": logging.WARNING, 

161 "ERROR": logging.ERROR, 

162 } 

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

164 logging.basicConfig( 

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

166 ) 

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

168 logging.getLogger().setLevel(level) 

169 

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

171 # imposed on library consumers of lilbee. 

172 from lilbee.data.store import install_lancedb_thread_error_suppressor 

173 

174 install_lancedb_thread_error_suppressor() 

175 

176 cfg.json_mode = json_output 

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

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

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

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

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

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

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

184 if ctx.invoked_subcommand is None: 

185 if cfg.json_mode: 

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

187 raise SystemExit(1) 

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

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

190 raise SystemExit(1) 

191 from lilbee.cli.tui import run_tui 

192 

193 run_tui()