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

77 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-16 08:27 +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.cli.helpers import auto_sync, get_version 

12from lilbee.cli.helpers import json_output as json_out 

13from lilbee.config import cfg 

14from lilbee.models import ensure_tag 

15 

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

17console = Console() 

18 

19data_dir_option = typer.Option( 

20 None, 

21 "--data-dir", 

22 "-d", 

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

24) 

25 

26model_option = typer.Option( 

27 None, 

28 "--model", 

29 "-m", 

30 help="Override chat model (default: $LILBEE_CHAT_MODEL or 'qwen3:8b')", 

31) 

32 

33_json_option = typer.Option( 

34 False, 

35 "--json", 

36 "-j", 

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

38) 

39 

40_global_option = typer.Option( 

41 False, 

42 "--global", 

43 "-g", 

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

45) 

46 

47_log_level_option = typer.Option( 

48 None, 

49 "--log-level", 

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

51) 

52 

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

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

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

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

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

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

59 

60 

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

62 """Point all cfg data paths at *root*.""" 

63 cfg.data_root = root 

64 cfg.documents_dir = root / "documents" 

65 cfg.data_dir = root / "data" 

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

67 

68 

69def apply_overrides( 

70 data_dir: Path | None = None, 

71 model: str | None = None, 

72 use_global: bool = False, 

73 temperature: float | None = None, 

74 top_p: float | None = None, 

75 top_k_sampling: int | None = None, 

76 repeat_penalty: float | None = None, 

77 num_ctx: int | None = None, 

78 seed: int | None = None, 

79) -> None: 

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

81 

82 Precedence (highest first): 

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

84 """ 

85 if data_dir is not None and use_global: 

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

87 

88 if use_global: 

89 from lilbee.platform import default_data_dir 

90 

91 _apply_data_root(default_data_dir()) 

92 elif data_dir is not None: 

93 _apply_data_root(data_dir) 

94 else: 

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

96 if data_env: 

97 _apply_data_root(Path(data_env)) 

98 

99 if model is not None: 

100 cfg.chat_model = ensure_tag(model) 

101 if temperature is not None: 

102 cfg.temperature = temperature 

103 if top_p is not None: 

104 cfg.top_p = top_p 

105 if top_k_sampling is not None: 

106 cfg.top_k_sampling = top_k_sampling 

107 if repeat_penalty is not None: 

108 cfg.repeat_penalty = repeat_penalty 

109 if num_ctx is not None: 

110 cfg.num_ctx = num_ctx 

111 if seed is not None: 

112 cfg.seed = seed 

113 

114 

115@app.callback() 

116def _default( 

117 ctx: typer.Context, 

118 data_dir: Path | None = data_dir_option, 

119 model: str | None = model_option, 

120 json_output: bool = _json_option, 

121 use_global: bool = _global_option, 

122 log_level: str | None = _log_level_option, 

123 show_version: bool = typer.Option( 

124 False, 

125 "--version", 

126 "-V", 

127 help="Show version and exit.", 

128 is_eager=True, 

129 ), 

130) -> None: 

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

132 if show_version: 

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

134 raise SystemExit(0) 

135 

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

137 if log_level is not None: 

138 level_str = log_level.upper() 

139 level = getattr(logging, level_str, logging.WARNING) 

140 logging.basicConfig( 

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

142 ) 

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

144 logging.getLogger().setLevel(level) 

145 

146 cfg.json_mode = json_output 

147 if ctx.invoked_subcommand is None: 

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

149 if cfg.json_mode: 

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

151 raise SystemExit(1) 

152 from lilbee.embedder import validate_model 

153 from lilbee.models import ensure_chat_model 

154 

155 ensure_chat_model() 

156 validate_model() 

157 auto_sync(console) 

158 from lilbee.cli.chat import chat_loop 

159 

160 chat_loop(console)