yaicli 0.0.19__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
yaicli/cli.py ADDED
@@ -0,0 +1,514 @@
1
+ import subprocess
2
+ import sys
3
+ import time
4
+ import traceback
5
+ from os.path import devnull
6
+ from pathlib import Path
7
+ from typing import List, Optional, Tuple
8
+
9
+ import typer
10
+ from prompt_toolkit import PromptSession, prompt
11
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
12
+ from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
13
+ from prompt_toolkit.keys import Keys
14
+ from rich import get_console
15
+ from rich.markdown import Markdown
16
+ from rich.padding import Padding
17
+ from rich.panel import Panel
18
+ from rich.prompt import Prompt
19
+
20
+ from yaicli.api import ApiClient
21
+ from yaicli.chat_manager import ChatManager, FileChatManager
22
+ from yaicli.config import CONFIG_PATH, Config
23
+ from yaicli.const import (
24
+ CHAT_MODE,
25
+ CMD_CLEAR,
26
+ CMD_DELETE_CHAT,
27
+ CMD_EXIT,
28
+ CMD_HISTORY,
29
+ CMD_LIST_CHATS,
30
+ CMD_LOAD_CHAT,
31
+ CMD_MODE,
32
+ CMD_SAVE_CHAT,
33
+ DEFAULT_CODE_THEME,
34
+ DEFAULT_INTERACTIVE_ROUND,
35
+ DEFAULT_OS_NAME,
36
+ DEFAULT_PROMPT,
37
+ DEFAULT_SHELL_NAME,
38
+ EXEC_MODE,
39
+ SHELL_PROMPT,
40
+ TEMP_MODE,
41
+ )
42
+ from yaicli.history import LimitedFileHistory
43
+ from yaicli.printer import Printer
44
+ from yaicli.utils import detect_os, detect_shell, filter_command
45
+
46
+
47
+ class CLI:
48
+ HISTORY_FILE = Path("~/.yaicli_history").expanduser()
49
+
50
+ def __init__(
51
+ self,
52
+ verbose: bool = False,
53
+ stdin: Optional[str] = None,
54
+ api_client: Optional[ApiClient] = None,
55
+ printer: Optional[Printer] = None,
56
+ chat_manager: Optional[ChatManager] = None,
57
+ ):
58
+ self.verbose = verbose
59
+ self.stdin = stdin
60
+ self.console = get_console()
61
+ self.bindings = KeyBindings()
62
+ self.config: Config = Config(self.console)
63
+ self.current_mode: str = TEMP_MODE
64
+
65
+ self.history = []
66
+ self.interactive_max_history = self.config.get("INTERACTIVE_MAX_HISTORY", DEFAULT_INTERACTIVE_ROUND)
67
+ self.chat_title = None
68
+ self.chat_start_time = None
69
+ self.is_temp_session = True
70
+
71
+ # Get and create chat history directory from configuration
72
+ self.chat_history_dir = Path(self.config["CHAT_HISTORY_DIR"])
73
+ self.chat_history_dir.mkdir(parents=True, exist_ok=True)
74
+
75
+ # Initialize session manager
76
+ self.chat_manager = chat_manager or FileChatManager(self.config)
77
+
78
+ # Detect OS and Shell if set to auto
79
+ if self.config.get("OS_NAME") == DEFAULT_OS_NAME:
80
+ self.config["OS_NAME"] = detect_os(self.config)
81
+ if self.config.get("SHELL_NAME") == DEFAULT_SHELL_NAME:
82
+ self.config["SHELL_NAME"] = detect_shell(self.config)
83
+
84
+ if self.verbose:
85
+ self.console.print("Loading Configuration:", style="bold cyan")
86
+ self.console.print(f"Config file path: {CONFIG_PATH}")
87
+ for key, value in self.config.items():
88
+ display_value = "****" if key == "API_KEY" and value else value
89
+ self.console.print(f" {key:<17}: {display_value}")
90
+ self.console.print(Markdown("---", code_theme=self.config.get("CODE_THEME", DEFAULT_CODE_THEME)))
91
+
92
+ self.api_client = api_client or ApiClient(self.config, self.console, self.verbose)
93
+ self.printer = printer or Printer(self.config, self.console, self.verbose)
94
+
95
+ _origin_stderr = None
96
+ if not sys.stdin.isatty():
97
+ _origin_stderr = sys.stderr
98
+ sys.stderr = open(devnull, "w", encoding="utf-8")
99
+ try:
100
+ self.session = PromptSession(key_bindings=self.bindings)
101
+ finally:
102
+ if _origin_stderr:
103
+ sys.stderr.flush()
104
+ sys.stderr.close()
105
+ sys.stderr = _origin_stderr
106
+
107
+ def get_prompt_tokens(self) -> List[Tuple[str, str]]:
108
+ """Return prompt tokens for current mode"""
109
+ mode_icon = "💬" if self.current_mode == CHAT_MODE else "🚀" if self.current_mode == EXEC_MODE else "📝"
110
+ return [("class:qmark", f" {mode_icon} "), ("class:prompt", "> ")]
111
+
112
+ def _check_history_len(self) -> None:
113
+ """Check history length and remove oldest messages if necessary"""
114
+ target_len = self.interactive_max_history * 2
115
+ if len(self.history) > target_len:
116
+ self.history = self.history[-target_len:]
117
+ if self.verbose:
118
+ self.console.print(f"History trimmed to {target_len} messages.", style="dim")
119
+
120
+ def _save_chat(self, title: Optional[str] = None) -> None:
121
+ """Save current chat history to a file using session manager."""
122
+ saved_title = self.chat_manager.save_chat(self.history, title)
123
+
124
+ if not saved_title:
125
+ self.console.print("Failed to save chat.", style="bold red")
126
+ return
127
+
128
+ # Session list will be refreshed automatically by the save method
129
+ self.console.print(f"Chat saved as: {saved_title}", style="bold green")
130
+
131
+ # If this was a temporary session, mark it as non-temporary now that it's saved
132
+ if self.is_temp_session:
133
+ self.is_temp_session = False
134
+ self.chat_title = saved_title
135
+ self.chat_start_time = int(time.time())
136
+ self.console.print(
137
+ "Session is now marked as persistent and will be auto-saved on exit.", style="bold green"
138
+ )
139
+
140
+ def _list_chats(self) -> None:
141
+ """List all saved chat sessions using session manager."""
142
+ chats = self.chat_manager.list_chats()
143
+
144
+ if not chats:
145
+ self.console.print("No saved chats found.", style="yellow")
146
+ return
147
+
148
+ self.console.print("Saved Chats:", style="bold underline")
149
+ for chat in chats:
150
+ index = chat["index"]
151
+ title = chat["title"]
152
+ date = chat.get("date", "")
153
+
154
+ if date:
155
+ self.console.print(f"[dim]{index}.[/dim] [bold blue]{title}[/bold blue] - {date}")
156
+ else:
157
+ self.console.print(f"[dim]{index}.[/dim] [bold blue]{title}[/bold blue]")
158
+
159
+ def _refresh_chats(self) -> None:
160
+ """Force refresh the chat list."""
161
+ self.chat_manager.refresh_chats()
162
+
163
+ def _load_chat_by_index(self, index: int) -> bool:
164
+ """Load a chat session by its index using session manager."""
165
+ if not self.chat_manager.validate_chat_index(index):
166
+ self.console.print("Invalid chat index.", style="bold red")
167
+ return False
168
+
169
+ chat_data = self.chat_manager.load_chat_by_index(index)
170
+
171
+ if not chat_data:
172
+ self.console.print("Invalid chat index or chat not found.", style="bold red")
173
+ return False
174
+
175
+ self.history = chat_data.get("history", [])
176
+ self.chat_title = chat_data.get("title")
177
+ self.chat_start_time = chat_data.get("timestamp", int(time.time()))
178
+ self.is_temp_session = False
179
+
180
+ self.console.print(f"Loaded chat: {self.chat_title}", style="bold green")
181
+ return True
182
+
183
+ def _delete_chat_by_index(self, index: int) -> bool:
184
+ """Delete a chat session by its index using session manager."""
185
+ if not self.chat_manager.validate_chat_index(index):
186
+ self.console.print("Invalid chat index.", style="bold red")
187
+ return False
188
+
189
+ chat_data = self.chat_manager.load_chat_by_index(index)
190
+
191
+ if not chat_data:
192
+ self.console.print("Invalid chat index or chat not found.", style="bold red")
193
+ return False
194
+
195
+ if self.chat_manager.delete_chat(index):
196
+ self.console.print(f"Deleted chat: {chat_data['title']}", style="bold green")
197
+ return True
198
+ else:
199
+ self.console.print(f"Failed to delete chat: {chat_data['title']}", style="bold red")
200
+ return False
201
+
202
+ def _handle_special_commands(self, user_input: str) -> Optional[bool]:
203
+ """Handle special command return: True-continue loop, False-exit loop, None-non-special command"""
204
+ command = user_input.lower().strip()
205
+ if command == CMD_EXIT:
206
+ return False
207
+ if command == CMD_CLEAR and self.current_mode == CHAT_MODE:
208
+ self.history.clear()
209
+ self.console.print("Chat history cleared", style="bold yellow")
210
+ return True
211
+ if command == CMD_HISTORY:
212
+ if not self.history:
213
+ self.console.print("History is empty.", style="yellow")
214
+ else:
215
+ self.console.print("Chat History:", style="bold underline")
216
+ for i in range(0, len(self.history), 2):
217
+ user_msg = self.history[i]
218
+ assistant_msg = self.history[i + 1] if (i + 1) < len(self.history) else None
219
+ self.console.print(f"[dim]{i // 2 + 1}[/dim] [bold blue]User:[/bold blue] {user_msg['content']}")
220
+ if assistant_msg:
221
+ md = Markdown(assistant_msg["content"], code_theme=self.config["CODE_THEME"])
222
+ padded_md = Padding(md, (0, 0, 0, 4))
223
+ self.console.print(" Assistant:", style="bold green")
224
+ self.console.print(padded_md)
225
+ return True
226
+
227
+ # Handle /save command - optional title parameter
228
+ if command.startswith(CMD_SAVE_CHAT):
229
+ parts = command.split(maxsplit=1)
230
+ title = parts[1] if len(parts) > 1 else self.chat_title
231
+ self._save_chat(title)
232
+ return True
233
+
234
+ # Handle /load command - requires index parameter
235
+ if command.startswith(CMD_LOAD_CHAT):
236
+ parts = command.split(maxsplit=1)
237
+ if len(parts) == 2 and parts[1].isdigit():
238
+ # Try to parse as an index first
239
+ index = int(parts[1])
240
+ self._load_chat_by_index(index=index)
241
+ else:
242
+ self.console.print(f"Usage: {CMD_LOAD_CHAT} <index>", style="yellow")
243
+ self._list_chats()
244
+ return True
245
+
246
+ # Handle /delete command - requires index parameter
247
+ if command.startswith(CMD_DELETE_CHAT):
248
+ parts = command.split(maxsplit=1)
249
+ if len(parts) == 2 and parts[1].isdigit():
250
+ index = int(parts[1])
251
+ self._delete_chat_by_index(index=index)
252
+ else:
253
+ self.console.print(f"Usage: {CMD_DELETE_CHAT} <index>", style="yellow")
254
+ self._list_chats()
255
+ return True
256
+
257
+ # Handle /list command to list saved chats
258
+ if command == CMD_LIST_CHATS:
259
+ self._list_chats()
260
+ return True
261
+
262
+ # Handle /mode command
263
+ if command.startswith(CMD_MODE):
264
+ parts = command.split(maxsplit=1)
265
+ if len(parts) == 2 and parts[1] in [CHAT_MODE, EXEC_MODE]:
266
+ new_mode = parts[1]
267
+ if self.current_mode != new_mode:
268
+ self.current_mode = new_mode
269
+ mode_name = "Chat" if self.current_mode == CHAT_MODE else "Exec"
270
+ self.console.print(f"Switched to [bold yellow]{mode_name}[/bold yellow] mode")
271
+ else:
272
+ self.console.print(f"Already in {self.current_mode} mode.", style="yellow")
273
+ else:
274
+ self.console.print(f"Usage: {CMD_MODE} {CHAT_MODE}|{EXEC_MODE}", style="yellow")
275
+ return True
276
+ return None
277
+
278
+ def _confirm_and_execute(self, raw_content: str) -> None:
279
+ """Review, edit and execute the command"""
280
+ cmd = filter_command(raw_content)
281
+ if not cmd:
282
+ self.console.print("No command generated or command is empty.", style="bold red")
283
+ return
284
+ self.console.print(
285
+ Panel(cmd, title="Suggest Command", title_align="left", border_style="bold magenta", expand=False)
286
+ )
287
+ _input = Prompt.ask(
288
+ r"Execute command? \[e]dit, \[y]es, \[n]o",
289
+ choices=["y", "n", "e"],
290
+ default="n",
291
+ case_sensitive=False,
292
+ show_choices=False,
293
+ )
294
+ executed_cmd = None
295
+ if _input == "y":
296
+ executed_cmd = cmd
297
+ elif _input == "e":
298
+ try:
299
+ edited_cmd = prompt("Edit command: ", default=cmd).strip()
300
+ if edited_cmd and edited_cmd != cmd:
301
+ executed_cmd = edited_cmd
302
+ elif edited_cmd:
303
+ executed_cmd = cmd
304
+ else:
305
+ self.console.print("Execution cancelled.", style="yellow")
306
+ except EOFError:
307
+ self.console.print("\nEdit cancelled.", style="yellow")
308
+ return
309
+ if executed_cmd:
310
+ self.console.print("--- Executing --- ", style="bold green")
311
+ try:
312
+ subprocess.call(executed_cmd, shell=True)
313
+ except Exception as e:
314
+ self.console.print(f"[red]Failed to execute command: {e}[/red]")
315
+ self.console.print("--- Finished ---", style="bold green")
316
+ elif _input != "e":
317
+ self.console.print("Execution cancelled.", style="yellow")
318
+
319
+ def get_system_prompt(self) -> str:
320
+ """Return system prompt for current mode"""
321
+ prompt_template = SHELL_PROMPT if self.current_mode == EXEC_MODE else DEFAULT_PROMPT
322
+ stdin = f"\n\nSTDIN\n{self.stdin}" if self.stdin else ""
323
+ if self.verbose and stdin:
324
+ self.console.print("Added STDIN to prompt", style="dim")
325
+ return prompt_template.format(_os=self.config["OS_NAME"], _shell=self.config["SHELL_NAME"]) + stdin
326
+
327
+ def _build_messages(self, user_input: str) -> List[dict]:
328
+ """Build the list of messages for the API call."""
329
+ return [
330
+ {"role": "system", "content": self.get_system_prompt()},
331
+ *self.history,
332
+ {"role": "user", "content": user_input},
333
+ ]
334
+
335
+ def _handle_llm_response(self, user_input: str) -> Optional[str]:
336
+ """Get response from API (streaming or normal) and print it.
337
+ Returns the full content string or None if an error occurred.
338
+
339
+ Args:
340
+ user_input (str): The user's input text.
341
+
342
+ Returns:
343
+ Optional[str]: The assistant's response content or None if an error occurred.
344
+ """
345
+ messages = self._build_messages(user_input)
346
+ content = None
347
+ reasoning = None
348
+
349
+ try:
350
+ if self.config.get("STREAM", True):
351
+ stream_iterator = self.api_client.stream_completion(messages)
352
+ content, reasoning = self.printer.display_stream(stream_iterator)
353
+ else:
354
+ content, reasoning = self.api_client.completion(messages)
355
+ self.printer.display_normal(content, reasoning)
356
+
357
+ if content is not None:
358
+ # Add only the content (not reasoning) to history
359
+ self.history.extend(
360
+ [{"role": "user", "content": user_input}, {"role": "assistant", "content": content}]
361
+ )
362
+ self._check_history_len()
363
+ return content
364
+ else:
365
+ return None
366
+ except Exception as e:
367
+ self.console.print(f"[red]Error processing LLM response: {e}[/red]")
368
+ if self.verbose:
369
+ traceback.print_exc()
370
+ return None
371
+
372
+ def _process_user_input(self, user_input: str) -> bool:
373
+ """Process user input: get response, print, update history, maybe execute.
374
+ Returns True to continue REPL, False to exit on critical error.
375
+ """
376
+ content = self._handle_llm_response(user_input)
377
+
378
+ if content is None:
379
+ return True
380
+
381
+ if self.current_mode == EXEC_MODE:
382
+ self._confirm_and_execute(content)
383
+ return True
384
+
385
+ def _print_welcome_message(self) -> None:
386
+ """Prints the initial welcome banner and instructions."""
387
+ self.console.print(
388
+ """
389
+ ██ ██ █████ ██ ██████ ██ ██
390
+ ██ ██ ██ ██ ██ ██ ██ ██
391
+ ████ ███████ ██ ██ ██ ██
392
+ ██ ██ ██ ██ ██ ██ ██
393
+ ██ ██ ██ ██ ██████ ███████ ██
394
+ """,
395
+ style="bold cyan",
396
+ )
397
+ self.console.print("Welcome to YAICLI!", style="bold")
398
+
399
+ # Display session type
400
+ if self.is_temp_session:
401
+ self.console.print("Current: [bold yellow]Temporary Session[/bold yellow] (use /save to make persistent)")
402
+ else:
403
+ self.console.print(
404
+ f"Current: [bold green]Persistent Session[/bold green]{f': {self.chat_title}' if self.chat_title else ''}"
405
+ )
406
+
407
+ self.console.print("Press [bold yellow]TAB[/bold yellow] to switch mode")
408
+ self.console.print(f"{CMD_CLEAR:<19}: Clear chat history")
409
+ self.console.print(f"{CMD_HISTORY:<19}: Show chat history")
410
+ self.console.print(f"{CMD_LIST_CHATS:<19}: List saved chats")
411
+ save_cmd = f"{CMD_SAVE_CHAT} <title>"
412
+ self.console.print(f"{save_cmd:<19}: Save current chat")
413
+ load_cmd = f"{CMD_LOAD_CHAT} <index>"
414
+ self.console.print(f"{load_cmd:<19}: Load a saved chat")
415
+ delete_cmd = f"{CMD_DELETE_CHAT} <index>"
416
+ self.console.print(f"{delete_cmd:<19}: Delete a saved chat")
417
+ cmd_exit = f"{CMD_EXIT}|Ctrl+D|Ctrl+C"
418
+ self.console.print(f"{cmd_exit:<19}: Exit")
419
+ cmd_mode = f"{CMD_MODE} {CHAT_MODE}|{EXEC_MODE}"
420
+ self.console.print(f"{cmd_mode:<19}: Switch mode (Case insensitive)", style="dim")
421
+
422
+ def _run_repl(self) -> None:
423
+ """Run the main Read-Eval-Print Loop (REPL)."""
424
+ self.prepare_chat_loop()
425
+ self._print_welcome_message()
426
+ while True:
427
+ self.console.print(Markdown("---", code_theme=self.config["CODE_THEME"]))
428
+ try:
429
+ user_input = self.session.prompt(self.get_prompt_tokens)
430
+ user_input = user_input.strip()
431
+ if not user_input:
432
+ continue
433
+ command_result = self._handle_special_commands(user_input)
434
+ if command_result is False:
435
+ break
436
+ if command_result is True:
437
+ continue
438
+ if not self._process_user_input(user_input):
439
+ break
440
+ except (KeyboardInterrupt, EOFError):
441
+ break
442
+
443
+ # Auto-save chat history when exiting if there are messages and not a temporary session
444
+ if not self.is_temp_session:
445
+ self._save_chat(self.chat_title)
446
+
447
+ self.console.print("\nExiting YAICLI... Goodbye!", style="bold green")
448
+
449
+ def _run_once(self, prompt_arg: str, is_shell_mode: bool) -> None:
450
+ """Run a single command (non-interactive)."""
451
+ self.current_mode = EXEC_MODE if is_shell_mode else TEMP_MODE
452
+ if not self.config.get("API_KEY"):
453
+ self.console.print("[bold red]Error:[/bold red] API key not found.")
454
+ raise typer.Exit(code=1)
455
+
456
+ content = self._handle_llm_response(prompt_arg)
457
+
458
+ if content is None:
459
+ raise typer.Exit(code=1)
460
+
461
+ if is_shell_mode:
462
+ self._confirm_and_execute(content)
463
+
464
+ def prepare_chat_loop(self) -> None:
465
+ """Setup key bindings and history for interactive modes."""
466
+ self._setup_key_bindings()
467
+ self.HISTORY_FILE.touch(exist_ok=True)
468
+ try:
469
+ self.session = PromptSession(
470
+ key_bindings=self.bindings,
471
+ history=LimitedFileHistory(self.HISTORY_FILE, max_entries=self.interactive_max_history),
472
+ auto_suggest=AutoSuggestFromHistory() if self.config.get("AUTO_SUGGEST", True) else None,
473
+ enable_history_search=True,
474
+ )
475
+ except Exception as e:
476
+ self.console.print(f"[red]Error initializing prompt session history: {e}[/red]")
477
+ self.session = PromptSession(key_bindings=self.bindings)
478
+ if self.chat_title:
479
+ chat_info = self.chat_manager.load_chat_by_title(self.chat_title)
480
+ self.history = chat_info.get("history", [])
481
+
482
+ def _setup_key_bindings(self) -> None:
483
+ """Setup keyboard shortcuts (e.g., TAB for mode switching)."""
484
+
485
+ @self.bindings.add(Keys.ControlI) # TAB
486
+ def _(event: KeyPressEvent) -> None:
487
+ self.current_mode = EXEC_MODE if self.current_mode == CHAT_MODE else CHAT_MODE
488
+
489
+ def run(self, chat: bool, shell: bool, prompt: Optional[str]) -> None:
490
+ """Main entry point to run the CLI (REPL or single command)."""
491
+ if chat:
492
+ if not self.config.get("API_KEY"):
493
+ self.console.print("[bold red]Error:[/bold red] API key not found. Cannot start chat mode.")
494
+ return
495
+ self.current_mode = CHAT_MODE
496
+
497
+ # Only set chat start time and title if a prompt is provided
498
+ if prompt:
499
+ self.chat_start_time = int(time.time())
500
+ self.chat_title = self.chat_manager.make_chat_title(prompt)
501
+ self.console.print(f"Chat title: [bold]{self.chat_title}[/bold]")
502
+ self.is_temp_session = False
503
+ else:
504
+ # Mark as temporary session if no prompt
505
+ self.is_temp_session = True
506
+ self.console.print(
507
+ "Starting a temporary chat session (will not be saved automatically)", style="yellow"
508
+ )
509
+
510
+ self._run_repl()
511
+ elif prompt:
512
+ self._run_once(prompt, shell)
513
+ else:
514
+ self.console.print("No chat or prompt provided. Exiting.")
yaicli/config.py ADDED
@@ -0,0 +1,153 @@
1
+ import configparser
2
+ from os import getenv
3
+ from typing import Optional
4
+
5
+ from rich import get_console
6
+ from rich.console import Console
7
+
8
+ from yaicli.const import (
9
+ CONFIG_PATH,
10
+ DEFAULT_CHAT_HISTORY_DIR,
11
+ DEFAULT_CONFIG_INI,
12
+ DEFAULT_CONFIG_MAP,
13
+ DEFAULT_MAX_SAVED_CHATS,
14
+ )
15
+ from yaicli.utils import str2bool
16
+
17
+
18
+ class CasePreservingConfigParser(configparser.RawConfigParser):
19
+ """Case preserving config parser"""
20
+
21
+ def optionxform(self, optionstr):
22
+ return optionstr
23
+
24
+
25
+ class Config(dict):
26
+ """Configuration class that loads settings on initialization.
27
+
28
+ This class encapsulates the configuration loading logic with priority:
29
+ 1. Environment variables (highest priority)
30
+ 2. Configuration file
31
+ 3. Default values (lowest priority)
32
+
33
+ It handles type conversion and validation based on DEFAULT_CONFIG_MAP.
34
+ """
35
+
36
+ def __init__(self, console: Optional[Console] = None):
37
+ """Initializes and loads the configuration."""
38
+ self.console = console or get_console()
39
+ super().__init__()
40
+ self.reload()
41
+
42
+ def reload(self) -> None:
43
+ """Reload configuration from all sources.
44
+
45
+ Follows priority order: env vars > config file > defaults
46
+ """
47
+ # Start with defaults
48
+ self.clear()
49
+ self.update(self._load_defaults())
50
+
51
+ # Load from config file
52
+ self._load_from_file()
53
+
54
+ # Load from environment variables and apply type conversion
55
+ self._load_from_env()
56
+ self._apply_type_conversion()
57
+
58
+ def _load_defaults(self) -> dict[str, str]:
59
+ """Load default configuration values as strings.
60
+
61
+ Returns:
62
+ Dictionary with default configuration values
63
+ """
64
+ return {k: v["value"] for k, v in DEFAULT_CONFIG_MAP.items()}
65
+
66
+ def _ensure_version_updated_config_keys(self):
67
+ """Ensure configuration keys added in version updates exist in the config file.
68
+ Appends missing keys to the config file if they don't exist.
69
+ """
70
+ with open(CONFIG_PATH, "r+", encoding="utf-8") as f:
71
+ config_content = f.read()
72
+ if "CHAT_HISTORY_DIR" not in config_content.strip(): # Check for empty lines
73
+ f.write(f"\nCHAT_HISTORY_DIR={DEFAULT_CHAT_HISTORY_DIR}\n")
74
+ if "MAX_SAVED_CHATS" not in config_content.strip(): # Check for empty lines
75
+ f.write(f"\nMAX_SAVED_CHATS={DEFAULT_MAX_SAVED_CHATS}\n")
76
+
77
+ def _load_from_file(self) -> None:
78
+ """Load configuration from the config file.
79
+
80
+ Creates default config file if it doesn't exist.
81
+ """
82
+ if not CONFIG_PATH.exists():
83
+ self.console.print("Creating default configuration file.", style="bold yellow")
84
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
85
+ with open(CONFIG_PATH, "w", encoding="utf-8") as f:
86
+ f.write(DEFAULT_CONFIG_INI)
87
+ return
88
+
89
+ config_parser = CasePreservingConfigParser()
90
+ config_parser.read(CONFIG_PATH, encoding="utf-8")
91
+
92
+ # Check if "core" section exists in the config file
93
+ if "core" not in config_parser or not config_parser["core"]:
94
+ return
95
+
96
+ for k, v in {"SHELL_NAME": "Unknown Shell", "OS_NAME": "Unknown OS"}.items():
97
+ if not config_parser["core"].get(k, "").strip():
98
+ config_parser["core"][k] = v
99
+
100
+ self.update(config_parser["core"])
101
+
102
+ # Check if keys added in version updates are missing and add them
103
+ self._ensure_version_updated_config_keys()
104
+
105
+ def _load_from_env(self) -> None:
106
+ """Load configuration from environment variables.
107
+
108
+ Updates the configuration dictionary in-place.
109
+ """
110
+ for key, config_info in DEFAULT_CONFIG_MAP.items():
111
+ env_value = getenv(config_info["env_key"])
112
+ if env_value is not None:
113
+ self[key] = env_value
114
+
115
+ def _apply_type_conversion(self) -> None:
116
+ """Apply type conversion to configuration values.
117
+
118
+ Updates the configuration dictionary in-place with properly typed values.
119
+ Falls back to default values if conversion fails.
120
+ """
121
+ default_values_str = self._load_defaults()
122
+
123
+ for key, config_info in DEFAULT_CONFIG_MAP.items():
124
+ target_type = config_info["type"]
125
+ raw_value = self.get(key, default_values_str.get(key))
126
+ converted_value = None
127
+
128
+ try:
129
+ if target_type is bool:
130
+ converted_value = str2bool(raw_value)
131
+ elif target_type in (int, float, str):
132
+ converted_value = target_type(raw_value)
133
+ except (ValueError, TypeError) as e:
134
+ self.console.print(
135
+ f"[yellow]Warning:[/yellow] Invalid value '{raw_value}' for '{key}'. "
136
+ f"Expected type '{target_type.__name__}'. Using default value '{default_values_str[key]}'. Error: {e}",
137
+ style="dim",
138
+ )
139
+ # Fallback to default string value if conversion fails
140
+ try:
141
+ if target_type is bool:
142
+ converted_value = str2bool(default_values_str[key])
143
+ else:
144
+ converted_value = target_type(default_values_str[key])
145
+ except (ValueError, TypeError):
146
+ # If default also fails (unlikely), keep the raw merged value or a sensible default
147
+ self.console.print(
148
+ f"[red]Error:[/red] Could not convert default value for '{key}'. Using raw value.",
149
+ style="error",
150
+ )
151
+ converted_value = raw_value # Or assign a hardcoded safe default
152
+
153
+ self[key] = converted_value