yaicli 0.1.0__py3-none-any.whl → 0.3.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 CHANGED
@@ -1,40 +1,46 @@
1
1
  import subprocess
2
2
  import sys
3
+ import time
3
4
  import traceback
4
5
  from os.path import devnull
5
6
  from pathlib import Path
6
- from typing import List, Optional, Tuple
7
+ from typing import List, Literal, Optional, Tuple
7
8
 
8
9
  import typer
9
10
  from prompt_toolkit import PromptSession, prompt
10
11
  from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
11
12
  from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
12
13
  from prompt_toolkit.keys import Keys
13
- from rich import get_console
14
14
  from rich.markdown import Markdown
15
15
  from rich.padding import Padding
16
16
  from rich.panel import Panel
17
17
  from rich.prompt import Prompt
18
18
 
19
19
  from yaicli.api import ApiClient
20
- from yaicli.config import CONFIG_PATH, Config
20
+ from yaicli.chat_manager import ChatFileInfo, ChatManager, FileChatManager
21
+ from yaicli.config import CONFIG_PATH, Config, cfg
22
+ from yaicli.console import get_console
21
23
  from yaicli.const import (
22
24
  CHAT_MODE,
23
25
  CMD_CLEAR,
26
+ CMD_DELETE_CHAT,
24
27
  CMD_EXIT,
25
28
  CMD_HISTORY,
29
+ CMD_LIST_CHATS,
30
+ CMD_LOAD_CHAT,
26
31
  CMD_MODE,
32
+ CMD_SAVE_CHAT,
27
33
  DEFAULT_CODE_THEME,
28
34
  DEFAULT_INTERACTIVE_ROUND,
29
35
  DEFAULT_OS_NAME,
30
- DEFAULT_PROMPT,
31
36
  DEFAULT_SHELL_NAME,
32
37
  EXEC_MODE,
33
- SHELL_PROMPT,
34
38
  TEMP_MODE,
39
+ DefaultRoleNames,
35
40
  )
36
41
  from yaicli.history import LimitedFileHistory
37
42
  from yaicli.printer import Printer
43
+ from yaicli.roles import RoleManager
38
44
  from yaicli.utils import detect_os, detect_shell, filter_command
39
45
 
40
46
 
@@ -42,15 +48,44 @@ class CLI:
42
48
  HISTORY_FILE = Path("~/.yaicli_history").expanduser()
43
49
 
44
50
  def __init__(
45
- self, verbose: bool = False, api_client: Optional[ApiClient] = None, printer: Optional[Printer] = None
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
+ role: Optional[str] = None,
46
58
  ):
59
+ # General settings
47
60
  self.verbose = verbose
61
+ self.stdin = stdin
48
62
  self.console = get_console()
49
63
  self.bindings = KeyBindings()
50
- self.config: Config = Config(self.console)
64
+ self.config: Config = cfg
51
65
  self.current_mode: str = TEMP_MODE
66
+ self.role: str = role or DefaultRoleNames.DEFAULT.value
67
+
68
+ # Initialize role manager
69
+ self.role_manager = RoleManager()
70
+
71
+ # Validate role
72
+ if not self.role_manager.role_exists(self.role):
73
+ self.console.print(f"Role '{self.role}' not found, using default role.", style="yellow")
74
+ self.role = DefaultRoleNames.DEFAULT.value
75
+
76
+ # Interactive chat mode settings
52
77
  self.history = []
53
78
  self.interactive_max_history = self.config.get("INTERACTIVE_MAX_HISTORY", DEFAULT_INTERACTIVE_ROUND)
79
+ self.chat_title = None
80
+ self.chat_start_time = None
81
+ self.is_temp_session = True
82
+
83
+ # Get and create chat history directory from configuration
84
+ self.chat_history_dir = Path(self.config["CHAT_HISTORY_DIR"])
85
+ self.chat_history_dir.mkdir(parents=True, exist_ok=True)
86
+
87
+ # Initialize chat manager
88
+ self.chat_manager = chat_manager or FileChatManager()
54
89
 
55
90
  # Detect OS and Shell if set to auto
56
91
  if self.config.get("OS_NAME") == DEFAULT_OS_NAME:
@@ -63,11 +98,12 @@ class CLI:
63
98
  self.console.print(f"Config file path: {CONFIG_PATH}")
64
99
  for key, value in self.config.items():
65
100
  display_value = "****" if key == "API_KEY" and value else value
66
- self.console.print(f" {key:<16}: {display_value}")
101
+ self.console.print(f" {key:<17}: {display_value}")
102
+ self.console.print(f"Current role: {self.role}")
67
103
  self.console.print(Markdown("---", code_theme=self.config.get("CODE_THEME", DEFAULT_CODE_THEME)))
68
104
 
69
105
  self.api_client = api_client or ApiClient(self.config, self.console, self.verbose)
70
- self.printer = printer or Printer(self.config, self.console, self.verbose)
106
+ self.printer = printer or Printer(self.config, self.console, self.verbose, markdown=True)
71
107
 
72
108
  _origin_stderr = None
73
109
  if not sys.stdin.isatty():
@@ -77,20 +113,118 @@ class CLI:
77
113
  self.session = PromptSession(key_bindings=self.bindings)
78
114
  finally:
79
115
  if _origin_stderr:
116
+ sys.stderr.flush()
80
117
  sys.stderr.close()
81
118
  sys.stderr = _origin_stderr
82
119
 
83
120
  def get_prompt_tokens(self) -> List[Tuple[str, str]]:
84
121
  """Return prompt tokens for current mode"""
85
- qmark = "💬" if self.current_mode == CHAT_MODE else "🚀" if self.current_mode == EXEC_MODE else "📝"
86
- return [("class:qmark", f" {qmark} "), ("class:prompt", "> ")]
122
+ mode_icon = "💬" if self.current_mode == CHAT_MODE else "🚀" if self.current_mode == EXEC_MODE else "📝"
123
+ return [("class:qmark", f" {mode_icon} "), ("class:prompt", "> ")]
87
124
 
88
125
  def _check_history_len(self) -> None:
89
- """Check history length and remove oldest messages if necessary"""
126
+ """Check history length and remove the oldest messages if necessary"""
90
127
  target_len = self.interactive_max_history * 2
91
128
  if len(self.history) > target_len:
92
129
  self.history = self.history[-target_len:]
130
+ if self.verbose:
131
+ self.console.print(f"History trimmed to {target_len} messages.", style="dim")
132
+
133
+ # ------------------- Role Command Methods -------------------
134
+ def set_role(self, role: str) -> None:
135
+ """Set the current role for the assistant"""
136
+ if not self.role_manager.role_exists(role):
137
+ self.console.print(f"Role '{role}' not found.", style="bold red")
138
+ return
139
+
140
+ self.role = role
141
+ if self.role == DefaultRoleNames.CODER:
142
+ self.printer = Printer(self.config, self.console, self.verbose, content_markdown=False)
143
+
144
+ # ------------------- Chat Command Methods -------------------
145
+ def _save_chat(self, title: Optional[str] = None) -> None:
146
+ """Save current chat history to a file using session manager."""
147
+ saved_title = self.chat_manager.save_chat(self.history, title)
148
+
149
+ if not saved_title:
150
+ self.console.print("Failed to save chat.", style="bold red")
151
+ return
152
+
153
+ # Session list will be refreshed automatically by the save method
154
+ self.console.print(f"Chat saved as: {saved_title}", style="bold green")
155
+
156
+ # If this was a temporary session, mark it as non-temporary now that it's saved
157
+ if self.is_temp_session:
158
+ self.is_temp_session = False
159
+ self.chat_title = saved_title
160
+ self.chat_start_time = int(time.time())
161
+ self.console.print(
162
+ "Session is now marked as persistent and will be auto-saved on exit.", style="bold green"
163
+ )
164
+
165
+ def _list_chats(self) -> None:
166
+ """List all saved chat sessions using session manager."""
167
+ chats: list[ChatFileInfo] = self.chat_manager.list_chats()
168
+
169
+ if not chats:
170
+ self.console.print("No saved chats found.", style="yellow")
171
+ return
172
+
173
+ self.console.print("Saved Chats:", style="bold underline")
174
+ for chat in chats:
175
+ index = chat["index"]
176
+ title = chat["title"]
177
+ date = chat.get("date", "")
178
+
179
+ if date:
180
+ self.console.print(f"[dim]{index}.[/dim] [bold blue]{title}[/bold blue] - {date}")
181
+ else:
182
+ self.console.print(f"[dim]{index}.[/dim] [bold blue]{title}[/bold blue]")
183
+
184
+ def _refresh_chats(self) -> None:
185
+ """Force refresh the chat list."""
186
+ self.chat_manager.refresh_chats()
93
187
 
188
+ def _load_chat_by_index(self, index: int) -> bool:
189
+ """Load a chat session by its index using session manager."""
190
+ if not self.chat_manager.validate_chat_index(index):
191
+ self.console.print("Invalid chat index.", style="bold red")
192
+ return False
193
+
194
+ chat_data = self.chat_manager.load_chat_by_index(index)
195
+
196
+ if not chat_data:
197
+ self.console.print("Invalid chat index or chat not found.", style="bold red")
198
+ return False
199
+
200
+ self.history = chat_data.get("history", [])
201
+ self.chat_title = chat_data.get("title")
202
+ self.chat_start_time = chat_data.get("timestamp", int(time.time()))
203
+ self.is_temp_session = False
204
+
205
+ self.console.print(f"Loaded chat: {self.chat_title}", style="bold green")
206
+ return True
207
+
208
+ def _delete_chat_by_index(self, index: int) -> bool:
209
+ """Delete a chat session by its index using session manager."""
210
+ if not self.chat_manager.validate_chat_index(index):
211
+ self.console.print("Invalid chat index.", style="bold red")
212
+ return False
213
+
214
+ chat_data = self.chat_manager.load_chat_by_index(index)
215
+
216
+ if not chat_data:
217
+ self.console.print("Invalid chat index or chat not found.", style="bold red")
218
+ return False
219
+
220
+ if self.chat_manager.delete_chat(index):
221
+ self.console.print(f"Deleted chat: {chat_data['title']}", style="bold green")
222
+ return True
223
+ else:
224
+ self.console.print(f"Failed to delete chat: {chat_data['title']}", style="bold red")
225
+ return False
226
+
227
+ # ------------------- Special commands -------------------
94
228
  def _handle_special_commands(self, user_input: str) -> Optional[bool]:
95
229
  """Handle special command return: True-continue loop, False-exit loop, None-non-special command"""
96
230
  command = user_input.lower().strip()
@@ -105,17 +239,52 @@ class CLI:
105
239
  self.console.print("History is empty.", style="yellow")
106
240
  else:
107
241
  self.console.print("Chat History:", style="bold underline")
108
- code_theme = self.config.get("CODE_THEME", "monokai")
109
242
  for i in range(0, len(self.history), 2):
110
243
  user_msg = self.history[i]
111
244
  assistant_msg = self.history[i + 1] if (i + 1) < len(self.history) else None
112
245
  self.console.print(f"[dim]{i // 2 + 1}[/dim] [bold blue]User:[/bold blue] {user_msg['content']}")
113
246
  if assistant_msg:
114
- md = Markdown(assistant_msg["content"], code_theme=code_theme)
247
+ md = Markdown(assistant_msg["content"], code_theme=self.config["CODE_THEME"])
115
248
  padded_md = Padding(md, (0, 0, 0, 4))
116
249
  self.console.print(" Assistant:", style="bold green")
117
250
  self.console.print(padded_md)
118
251
  return True
252
+
253
+ # Handle /save command - optional title parameter
254
+ if command.startswith(CMD_SAVE_CHAT):
255
+ parts = command.split(maxsplit=1)
256
+ title = parts[1] if len(parts) > 1 else self.chat_title
257
+ self._save_chat(title)
258
+ return True
259
+
260
+ # Handle /load command - requires index parameter
261
+ if command.startswith(CMD_LOAD_CHAT):
262
+ parts = command.split(maxsplit=1)
263
+ if len(parts) == 2 and parts[1].isdigit():
264
+ # Try to parse as an index first
265
+ index = int(parts[1])
266
+ self._load_chat_by_index(index=index)
267
+ else:
268
+ self.console.print(f"Usage: {CMD_LOAD_CHAT} <index>", style="yellow")
269
+ self._list_chats()
270
+ return True
271
+
272
+ # Handle /delete command - requires index parameter
273
+ if command.startswith(CMD_DELETE_CHAT):
274
+ parts = command.split(maxsplit=1)
275
+ if len(parts) == 2 and parts[1].isdigit():
276
+ index = int(parts[1])
277
+ self._delete_chat_by_index(index=index)
278
+ else:
279
+ self.console.print(f"Usage: {CMD_DELETE_CHAT} <index>", style="yellow")
280
+ self._list_chats()
281
+ return True
282
+
283
+ # Handle /list command to list saved chats
284
+ if command == CMD_LIST_CHATS:
285
+ self._list_chats()
286
+ return True
287
+
119
288
  # Handle /mode command
120
289
  if command.startswith(CMD_MODE):
121
290
  parts = command.split(maxsplit=1)
@@ -123,8 +292,6 @@ class CLI:
123
292
  new_mode = parts[1]
124
293
  if self.current_mode != new_mode:
125
294
  self.current_mode = new_mode
126
- mode_name = "Chat" if self.current_mode == CHAT_MODE else "Exec"
127
- self.console.print(f"Switched to [bold yellow]{mode_name}[/bold yellow] mode")
128
295
  else:
129
296
  self.console.print(f"Already in {self.current_mode} mode.", style="yellow")
130
297
  else:
@@ -173,20 +340,24 @@ class CLI:
173
340
  elif _input != "e":
174
341
  self.console.print("Execution cancelled.", style="yellow")
175
342
 
343
+ # ------------------- LLM Methods -------------------
176
344
  def get_system_prompt(self) -> str:
177
- """Return system prompt for current mode"""
178
- prompt_template = SHELL_PROMPT if self.current_mode == EXEC_MODE else DEFAULT_PROMPT
179
- return prompt_template.format(
180
- _os=self.config.get("OS_NAME", "Unknown OS"), _shell=self.config.get("SHELL_NAME", "Unknown Shell")
181
- )
345
+ """Get the system prompt based on current role and mode"""
346
+ # Use the role manager to get the system prompt
347
+ return self.role_manager.get_system_prompt(self.role)
182
348
 
183
349
  def _build_messages(self, user_input: str) -> List[dict]:
184
- """Build the list of messages for the API call."""
185
- return [
186
- {"role": "system", "content": self.get_system_prompt()},
187
- *self.history,
188
- {"role": "user", "content": user_input},
189
- ]
350
+ """Build message list for LLM API"""
351
+ # Create the message list
352
+ messages = [{"role": "system", "content": self.get_system_prompt()}]
353
+
354
+ # Add previous conversation if available
355
+ for msg in self.history:
356
+ messages.append(msg)
357
+
358
+ # Add user input
359
+ messages.append({"role": "user", "content": user_input})
360
+ return messages
190
361
 
191
362
  def _handle_llm_response(self, user_input: str) -> Optional[str]:
192
363
  """Get response from API (streaming or normal) and print it.
@@ -199,16 +370,16 @@ class CLI:
199
370
  Optional[str]: The assistant's response content or None if an error occurred.
200
371
  """
201
372
  messages = self._build_messages(user_input)
202
- content = None
203
- reasoning = None
204
-
373
+ if self.verbose:
374
+ self.console.print(messages)
375
+ is_code_mode = self.role == DefaultRoleNames.CODER
205
376
  try:
206
- if self.config.get("STREAM", True):
377
+ if self.config["STREAM"]:
207
378
  stream_iterator = self.api_client.stream_completion(messages)
208
- content, reasoning = self.printer.display_stream(stream_iterator)
379
+ content, reasoning = self.printer.display_stream(stream_iterator, not is_code_mode)
209
380
  else:
210
381
  content, reasoning = self.api_client.completion(messages)
211
- self.printer.display_normal(content, reasoning)
382
+ self.printer.display_normal(content, reasoning, not is_code_mode)
212
383
 
213
384
  if content is not None:
214
385
  # Add only the content (not reasoning) to history
@@ -238,6 +409,7 @@ class CLI:
238
409
  self._confirm_and_execute(content)
239
410
  return True
240
411
 
412
+ # ------------------- REPL Methods -------------------
241
413
  def _print_welcome_message(self) -> None:
242
414
  """Prints the initial welcome banner and instructions."""
243
415
  self.console.print(
@@ -251,9 +423,25 @@ class CLI:
251
423
  style="bold cyan",
252
424
  )
253
425
  self.console.print("Welcome to YAICLI!", style="bold")
426
+
427
+ # Display session type
428
+ if self.is_temp_session:
429
+ self.console.print("Current: [bold yellow]Temporary Session[/bold yellow] (use /save to make persistent)")
430
+ else:
431
+ self.console.print(
432
+ f"Current: [bold green]Persistent Session[/bold green]{f': {self.chat_title}' if self.chat_title else ''}"
433
+ )
434
+
254
435
  self.console.print("Press [bold yellow]TAB[/bold yellow] to switch mode")
255
436
  self.console.print(f"{CMD_CLEAR:<19}: Clear chat history")
256
437
  self.console.print(f"{CMD_HISTORY:<19}: Show chat history")
438
+ self.console.print(f"{CMD_LIST_CHATS:<19}: List saved chats")
439
+ save_cmd = f"{CMD_SAVE_CHAT} <title>"
440
+ self.console.print(f"{save_cmd:<19}: Save current chat")
441
+ load_cmd = f"{CMD_LOAD_CHAT} <index>"
442
+ self.console.print(f"{load_cmd:<19}: Load a saved chat")
443
+ delete_cmd = f"{CMD_DELETE_CHAT} <index>"
444
+ self.console.print(f"{delete_cmd:<19}: Delete a saved chat")
257
445
  cmd_exit = f"{CMD_EXIT}|Ctrl+D|Ctrl+C"
258
446
  self.console.print(f"{cmd_exit:<19}: Exit")
259
447
  cmd_mode = f"{CMD_MODE} {CHAT_MODE}|{EXEC_MODE}"
@@ -264,7 +452,7 @@ class CLI:
264
452
  self.prepare_chat_loop()
265
453
  self._print_welcome_message()
266
454
  while True:
267
- self.console.print(Markdown("---", code_theme=self.config.get("CODE_THEME", "monokai")))
455
+ self.console.print(Markdown("---", code_theme=self.config["CODE_THEME"]))
268
456
  try:
269
457
  user_input = self.session.prompt(self.get_prompt_tokens)
270
458
  user_input = user_input.strip()
@@ -279,22 +467,12 @@ class CLI:
279
467
  break
280
468
  except (KeyboardInterrupt, EOFError):
281
469
  break
282
- self.console.print("\nExiting YAICLI... Goodbye!", style="bold green")
283
470
 
284
- def _run_once(self, prompt_arg: str, is_shell_mode: bool) -> None:
285
- """Run a single command (non-interactive)."""
286
- self.current_mode = EXEC_MODE if is_shell_mode else TEMP_MODE
287
- if not self.config.get("API_KEY"):
288
- self.console.print("[bold red]Error:[/bold red] API key not found.")
289
- raise typer.Exit(code=1)
290
-
291
- content = self._handle_llm_response(prompt_arg)
292
-
293
- if content is None:
294
- raise typer.Exit(code=1)
471
+ # Auto-save chat history when exiting if there are messages and not a temporary session
472
+ if not self.is_temp_session:
473
+ self._save_chat(self.chat_title)
295
474
 
296
- if is_shell_mode:
297
- self._confirm_and_execute(content)
475
+ self.console.print("\nExiting YAICLI... Goodbye!", style="bold green")
298
476
 
299
477
  def prepare_chat_loop(self) -> None:
300
478
  """Setup key bindings and history for interactive modes."""
@@ -310,6 +488,10 @@ class CLI:
310
488
  except Exception as e:
311
489
  self.console.print(f"[red]Error initializing prompt session history: {e}[/red]")
312
490
  self.session = PromptSession(key_bindings=self.bindings)
491
+ if self.chat_title:
492
+ chat_info = self.chat_manager.load_chat_by_title(self.chat_title)
493
+ self.is_temp_session = False
494
+ self.history = chat_info.get("history", [])
313
495
 
314
496
  def _setup_key_bindings(self) -> None:
315
497
  """Setup keyboard shortcuts (e.g., TAB for mode switching)."""
@@ -318,15 +500,48 @@ class CLI:
318
500
  def _(event: KeyPressEvent) -> None:
319
501
  self.current_mode = EXEC_MODE if self.current_mode == CHAT_MODE else CHAT_MODE
320
502
 
321
- def run(self, chat: bool, shell: bool, prompt: Optional[str]) -> None:
322
- """Main entry point to run the CLI (REPL or single command)."""
323
- if chat:
324
- if not self.config.get("API_KEY"):
325
- self.console.print("[bold red]Error:[/bold red] API key not found. Cannot start chat mode.")
326
- return
503
+ def _run_once(self, input: str, shell: bool) -> None:
504
+ """Run a single command (non-interactive)."""
505
+ self.current_mode = EXEC_MODE if shell else TEMP_MODE
506
+ if not self.config.get("API_KEY"):
507
+ self.console.print("[bold red]Error:[/bold red] API key not found.")
508
+ raise typer.Exit(code=1)
509
+
510
+ content = self._handle_llm_response(input)
511
+
512
+ if content is None:
513
+ raise typer.Exit(code=1)
514
+
515
+ if shell:
516
+ self._confirm_and_execute(content)
517
+
518
+ # ------------------- Main Entry Point -------------------
519
+ def run(
520
+ self,
521
+ chat: bool,
522
+ shell: bool,
523
+ input: Optional[str],
524
+ role: Optional[str | Literal[DefaultRoleNames.DEFAULT]] = None,
525
+ ) -> None:
526
+ """Run the CLI in the appropriate mode with the selected role."""
527
+ self.set_role(role or self.role)
528
+
529
+ # Now handle normal operation
530
+ if shell:
531
+ # Set mode to shell
532
+ self.role = DefaultRoleNames.SHELL
533
+ if input:
534
+ self._run_once(input, shell=True)
535
+ else:
536
+ self.console.print("No prompt provided for shell mode.", style="yellow")
537
+ elif chat:
538
+ # Start interactive chat mode
327
539
  self.current_mode = CHAT_MODE
540
+ self.chat_title = input if input else None
541
+ self.prepare_chat_loop()
328
542
  self._run_repl()
329
- elif prompt:
330
- self._run_once(prompt, shell)
543
+ elif input:
544
+ # Run once with the given prompt
545
+ self._run_once(input, shell=False)
331
546
  else:
332
547
  self.console.print("No chat or prompt provided. Exiting.")
yaicli/config.py CHANGED
@@ -1,65 +1,21 @@
1
1
  import configparser
2
+ from functools import lru_cache
2
3
  from os import getenv
3
- from pathlib import Path
4
4
  from typing import Optional
5
5
 
6
6
  from rich import get_console
7
7
  from rich.console import Console
8
8
 
9
+ from yaicli.const import (
10
+ CONFIG_PATH,
11
+ DEFAULT_CHAT_HISTORY_DIR,
12
+ DEFAULT_CONFIG_INI,
13
+ DEFAULT_CONFIG_MAP,
14
+ DEFAULT_JUSTIFY,
15
+ DEFAULT_MAX_SAVED_CHATS,
16
+ )
9
17
  from yaicli.utils import str2bool
10
18
 
11
- DEFAULT_CONFIG_MAP = {
12
- # Core API settings
13
- "BASE_URL": {"value": "https://api.openai.com/v1", "env_key": "YAI_BASE_URL", "type": str},
14
- "API_KEY": {"value": "", "env_key": "YAI_API_KEY", "type": str},
15
- "MODEL": {"value": "gpt-4o", "env_key": "YAI_MODEL", "type": str},
16
- # System detection hints
17
- "SHELL_NAME": {"value": "auto", "env_key": "YAI_SHELL_NAME", "type": str},
18
- "OS_NAME": {"value": "auto", "env_key": "YAI_OS_NAME", "type": str},
19
- # API response parsing
20
- "COMPLETION_PATH": {"value": "chat/completions", "env_key": "YAI_COMPLETION_PATH", "type": str},
21
- "ANSWER_PATH": {"value": "choices[0].message.content", "env_key": "YAI_ANSWER_PATH", "type": str},
22
- # API call parameters
23
- "STREAM": {"value": "true", "env_key": "YAI_STREAM", "type": bool},
24
- "TEMPERATURE": {"value": "0.7", "env_key": "YAI_TEMPERATURE", "type": float},
25
- "TOP_P": {"value": "1.0", "env_key": "YAI_TOP_P", "type": float},
26
- "MAX_TOKENS": {"value": "1024", "env_key": "YAI_MAX_TOKENS", "type": int},
27
- # UI/UX settings
28
- "CODE_THEME": {"value": "monokai", "env_key": "YAI_CODE_THEME", "type": str},
29
- "MAX_HISTORY": {"value": "500", "env_key": "YAI_MAX_HISTORY", "type": int},
30
- "AUTO_SUGGEST": {"value": "true", "env_key": "YAI_AUTO_SUGGEST", "type": bool},
31
- }
32
-
33
- DEFAULT_CONFIG_INI = f"""[core]
34
- PROVIDER=openai
35
- BASE_URL={DEFAULT_CONFIG_MAP["BASE_URL"]["value"]}
36
- API_KEY={DEFAULT_CONFIG_MAP["API_KEY"]["value"]}
37
- MODEL={DEFAULT_CONFIG_MAP["MODEL"]["value"]}
38
-
39
- # auto detect shell and os (or specify manually, e.g., bash, zsh, powershell.exe)
40
- SHELL_NAME={DEFAULT_CONFIG_MAP["SHELL_NAME"]["value"]}
41
- OS_NAME={DEFAULT_CONFIG_MAP["OS_NAME"]["value"]}
42
-
43
- # API paths (usually no need to change for OpenAI compatible APIs)
44
- COMPLETION_PATH={DEFAULT_CONFIG_MAP["COMPLETION_PATH"]["value"]}
45
- ANSWER_PATH={DEFAULT_CONFIG_MAP["ANSWER_PATH"]["value"]}
46
-
47
- # true: streaming response, false: non-streaming
48
- STREAM={DEFAULT_CONFIG_MAP["STREAM"]["value"]}
49
-
50
- # LLM parameters
51
- TEMPERATURE={DEFAULT_CONFIG_MAP["TEMPERATURE"]["value"]}
52
- TOP_P={DEFAULT_CONFIG_MAP["TOP_P"]["value"]}
53
- MAX_TOKENS={DEFAULT_CONFIG_MAP["MAX_TOKENS"]["value"]}
54
-
55
- # UI/UX
56
- CODE_THEME={DEFAULT_CONFIG_MAP["CODE_THEME"]["value"]}
57
- MAX_HISTORY={DEFAULT_CONFIG_MAP["MAX_HISTORY"]["value"]}
58
- AUTO_SUGGEST={DEFAULT_CONFIG_MAP["AUTO_SUGGEST"]["value"]}
59
- """
60
-
61
- CONFIG_PATH = Path("~/.config/yaicli/config.ini").expanduser()
62
-
63
19
 
64
20
  class CasePreservingConfigParser(configparser.RawConfigParser):
65
21
  """Case preserving config parser"""
@@ -82,6 +38,7 @@ class Config(dict):
82
38
  def __init__(self, console: Optional[Console] = None):
83
39
  """Initializes and loads the configuration."""
84
40
  self.console = console or get_console()
41
+
85
42
  super().__init__()
86
43
  self.reload()
87
44
 
@@ -95,9 +52,7 @@ class Config(dict):
95
52
  self.update(self._load_defaults())
96
53
 
97
54
  # Load from config file
98
- file_config = self._load_from_file()
99
- if file_config:
100
- self.update(file_config)
55
+ self._load_from_file()
101
56
 
102
57
  # Load from environment variables and apply type conversion
103
58
  self._load_from_env()
@@ -111,26 +66,46 @@ class Config(dict):
111
66
  """
112
67
  return {k: v["value"] for k, v in DEFAULT_CONFIG_MAP.items()}
113
68
 
114
- def _load_from_file(self) -> dict[str, str]:
69
+ def _ensure_version_updated_config_keys(self):
70
+ """Ensure configuration keys added in version updates exist in the config file.
71
+ Appends missing keys to the config file if they don't exist.
72
+ """
73
+ with open(CONFIG_PATH, "r+", encoding="utf-8") as f:
74
+ config_content = f.read()
75
+ if "CHAT_HISTORY_DIR" not in config_content.strip(): # Check for empty lines
76
+ f.write(f"\nCHAT_HISTORY_DIR={DEFAULT_CHAT_HISTORY_DIR}\n")
77
+ if "MAX_SAVED_CHATS" not in config_content.strip(): # Check for empty lines
78
+ f.write(f"\nMAX_SAVED_CHATS={DEFAULT_MAX_SAVED_CHATS}\n")
79
+ if "JUSTIFY" not in config_content.strip():
80
+ f.write(f"\nJUSTIFY={DEFAULT_JUSTIFY}\n")
81
+
82
+ def _load_from_file(self) -> None:
115
83
  """Load configuration from the config file.
116
84
 
117
85
  Creates default config file if it doesn't exist.
118
-
119
- Returns:
120
- Dictionary with configuration values from file, or empty dict if no valid values
121
86
  """
122
87
  if not CONFIG_PATH.exists():
123
- self.console.print("Creating default configuration file.", style="bold yellow")
88
+ self.console.print("Creating default configuration file.", style="bold yellow", justify=self.get("JUSTIFY"))
124
89
  CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
125
90
  with open(CONFIG_PATH, "w", encoding="utf-8") as f:
126
91
  f.write(DEFAULT_CONFIG_INI)
127
- return {}
92
+ return
128
93
 
129
94
  config_parser = CasePreservingConfigParser()
130
95
  config_parser.read(CONFIG_PATH, encoding="utf-8")
131
- if "core" in config_parser:
132
- return {k: v for k, v in config_parser["core"].items() if k in DEFAULT_CONFIG_MAP and v.strip()}
133
- return {}
96
+
97
+ # Check if "core" section exists in the config file
98
+ if "core" not in config_parser or not config_parser["core"]:
99
+ return
100
+
101
+ for k, v in {"SHELL_NAME": "Unknown Shell", "OS_NAME": "Unknown OS"}.items():
102
+ if not config_parser["core"].get(k, "").strip():
103
+ config_parser["core"][k] = v
104
+
105
+ self.update(config_parser["core"])
106
+
107
+ # Check if keys added in version updates are missing and add them
108
+ self._ensure_version_updated_config_keys()
134
109
 
135
110
  def _load_from_env(self) -> None:
136
111
  """Load configuration from environment variables.
@@ -165,6 +140,7 @@ class Config(dict):
165
140
  f"[yellow]Warning:[/yellow] Invalid value '{raw_value}' for '{key}'. "
166
141
  f"Expected type '{target_type.__name__}'. Using default value '{default_values_str[key]}'. Error: {e}",
167
142
  style="dim",
143
+ justify=self.get("JUSTIFY"),
168
144
  )
169
145
  # Fallback to default string value if conversion fails
170
146
  try:
@@ -177,7 +153,17 @@ class Config(dict):
177
153
  self.console.print(
178
154
  f"[red]Error:[/red] Could not convert default value for '{key}'. Using raw value.",
179
155
  style="error",
156
+ justify=self.get("JUSTIFY"),
180
157
  )
181
158
  converted_value = raw_value # Or assign a hardcoded safe default
182
159
 
183
160
  self[key] = converted_value
161
+
162
+
163
+ @lru_cache(maxsize=1)
164
+ def get_config() -> Config:
165
+ """Get the configuration singleton"""
166
+ return Config()
167
+
168
+
169
+ cfg = get_config()