Coverage for src/lilbee/cli/commands/settings.py: 100%

98 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Settings commands: list, get, set, and unset through the shared write boundary.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from typing import TYPE_CHECKING, Any 

7 

8import typer 

9from rich.table import Column, Table 

10from rich.text import Text 

11 

12from lilbee.cli import theme 

13from lilbee.cli.app import apply_overrides, console, data_dir_option, global_option 

14from lilbee.cli.commands._shared import REBUILD_HINT, emit, line, run_or_fail, shown_value 

15from lilbee.cli.tui import messages as msg 

16from lilbee.core.config import cfg 

17 

18if TYPE_CHECKING: 

19 from lilbee.app.settings import SettingInfo, SettingsUpdateResult 

20 from lilbee.server.models import ConfigUpdateResponse 

21 

22settings_app = typer.Typer( 

23 help="Show and change settings, with each value's source.", no_args_is_help=True 

24) 

25 

26_key_argument = typer.Argument(help="A setting's name, as shown by 'lilbee settings list'.") 

27_keys_argument = typer.Argument( 

28 help="One or more settings to remove your value of; env, profile or default applies." 

29) 

30 

31 

32def _is_secret(key: str) -> bool: 

33 from lilbee.app.settings_map import SETTINGS_MAP 

34 

35 definition = SETTINGS_MAP.get(key) 

36 return definition is not None and definition.secret 

37 

38 

39def _render_list(infos: list[SettingInfo]) -> None: 

40 table = Table(Column("Key", no_wrap=True), "Group", "Value", "Source") 

41 for info in infos: 

42 source = info.source.value.replace("_", " ") 

43 table.add_row(info.key, info.group.value, Text(shown_value(info.value)), source) 

44 console.print(table) 

45 

46 

47def _render_get(info: SettingInfo) -> None: 

48 source = info.source.value.replace("_", " ") 

49 line(f"{info.key} = {shown_value(info.value)}", theme.ACCENT) 

50 line(f"source: {source}") 

51 line(info.help_text, theme.MUTED) 

52 

53 

54def _render_update(result: SettingsUpdateResult) -> None: 

55 if result.reindex_required: 

56 line(REBUILD_HINT, theme.WARNING) 

57 for warning in result.warnings: 

58 line(warning, theme.WARNING) 

59 

60 

61def _update_response(result: SettingsUpdateResult) -> ConfigUpdateResponse: 

62 from lilbee.server.models import ConfigUpdateResponse 

63 

64 return ConfigUpdateResponse.from_result(result) 

65 

66 

67@settings_app.command(name="list") 

68def settings_list( 

69 group: str | None = typer.Option( 

70 None, "--group", help="Filter to one group (case-insensitive); omit for every group." 

71 ), 

72 advanced: bool = typer.Option( 

73 False, 

74 "--advanced", 

75 help="Also list the advanced settings (engine internals, timeouts, thresholds).", 

76 ), 

77 data_dir: Path | None = data_dir_option, 

78 use_global: bool = global_option, 

79) -> None: 

80 """List the writable settings with their current value and source. 

81 

82 The advanced settings are left out unless you pass --advanced. 

83 'get', 'set' and 'unset' work on any setting, advanced or not. 

84 """ 

85 from lilbee.app.settings import config_read_failure_message, list_settings 

86 from lilbee.server.models import SettingsListResponse 

87 

88 apply_overrides(data_dir=data_dir, use_global=use_global) 

89 listed = run_or_fail(lambda: list_settings(group), config_read_failure_message) 

90 infos = [info for info in listed if advanced or not info.advanced] 

91 emit(SettingsListResponse.from_infos(infos), lambda: _render_list(infos)) 

92 

93 

94@settings_app.command(name="get") 

95def settings_get( 

96 key: str = _key_argument, 

97 data_dir: Path | None = data_dir_option, 

98 use_global: bool = global_option, 

99) -> None: 

100 """Show one setting's current value and source.""" 

101 from lilbee.app.settings import config_read_failure_message, get_setting 

102 from lilbee.server.models import SettingValueResponse 

103 

104 apply_overrides(data_dir=data_dir, use_global=use_global) 

105 info = run_or_fail(lambda: get_setting(key), config_read_failure_message) 

106 emit(SettingValueResponse.from_info(info), lambda: _render_get(info)) 

107 

108 

109def _parse_cli_value(key: str, raw: str) -> Any: 

110 """A list setting is one item per line; any other type is left to pydantic.""" 

111 from lilbee.app.settings import list_setting_value 

112 from lilbee.core.config.schema import field_type_name 

113 

114 try: 

115 is_list = field_type_name(key) == "list" 

116 except KeyError: 

117 is_list = False 

118 return list_setting_value(raw) if is_list else raw 

119 

120 

121def _stored_text(key: str, typed: str) -> str: 

122 """What the write stored, on one line: a list joins its items, a blank value reads auto. 

123 

124 A bool shows as true, false or auto; any other setting echoes *typed*. 

125 """ 

126 from lilbee.app.settings_map import SETTINGS_MAP 

127 

128 stored = getattr(cfg, key) 

129 if isinstance(stored, list): 

130 return ", ".join(str(item) for item in stored) 

131 if stored is None and not typed.strip(): 

132 return "auto" 

133 definition = SETTINGS_MAP.get(key) 

134 if definition is None or definition.type is not bool: 

135 return typed 

136 return "auto" if stored is None else shown_value(stored) 

137 

138 

139@settings_app.command(name="set") 

140def settings_set( 

141 key: str = _key_argument, 

142 value: str = typer.Argument(help="The new value."), 

143 data_dir: Path | None = data_dir_option, 

144 use_global: bool = global_option, 

145) -> None: 

146 """Set a writable setting; model roles are refused (use 'lilbee model' instead).""" 

147 from lilbee.app.settings import apply_settings_update, config_write_failure_message 

148 

149 apply_overrides(data_dir=data_dir, use_global=use_global) 

150 parsed = _parse_cli_value(key, value) 

151 result = run_or_fail( 

152 lambda: apply_settings_update({key: parsed}, allow_model_roles=False), 

153 config_write_failure_message, 

154 ) 

155 shown = msg.MASKED_VALUE if _is_secret(key) and value else _stored_text(key, value) 

156 

157 def _render() -> None: 

158 line(f"Set {key} to {shown}.", theme.ACCENT) 

159 _render_update(result) 

160 

161 emit(_update_response(result), _render) 

162 

163 

164@settings_app.command(name="unset") 

165def settings_unset( 

166 keys: list[str] = _keys_argument, 

167 data_dir: Path | None = data_dir_option, 

168 use_global: bool = global_option, 

169) -> None: 

170 """Remove your value of one or more settings; the next source applies.""" 

171 from lilbee.app.settings import config_write_failure_message, get_setting, reset_settings 

172 

173 apply_overrides(data_dir=data_dir, use_global=use_global) 

174 result = run_or_fail( 

175 lambda: reset_settings(keys, allow_model_roles=False), config_write_failure_message 

176 ) 

177 

178 def _render() -> None: 

179 for key in result.updated: 

180 try: 

181 info = get_setting(key) 

182 except ValueError: 

183 line(f"{key}: removed your value (write-only; new value not shown)") 

184 continue 

185 source = info.source.value.replace("_", " ") 

186 line(f"{key} = {shown_value(info.value)} ({source})") 

187 _render_update(result) 

188 

189 emit(_update_response(result), _render)