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.
- pyproject.toml +10 -4
- yaicli/__init__.py +0 -0
- yaicli/api.py +324 -0
- yaicli/chat_manager.py +263 -0
- yaicli/cli.py +514 -0
- yaicli/config.py +153 -0
- yaicli/const.py +135 -0
- yaicli/entry.py +111 -0
- yaicli/history.py +72 -0
- yaicli/printer.py +244 -0
- yaicli/utils.py +112 -0
- {yaicli-0.0.19.dist-info → yaicli-0.2.0.dist-info}/METADATA +386 -224
- yaicli-0.2.0.dist-info/RECORD +16 -0
- yaicli-0.2.0.dist-info/entry_points.txt +3 -0
- yaicli-0.0.19.dist-info/RECORD +0 -7
- yaicli-0.0.19.dist-info/entry_points.txt +0 -2
- yaicli.py +0 -667
- {yaicli-0.0.19.dist-info → yaicli-0.2.0.dist-info}/WHEEL +0 -0
- {yaicli-0.0.19.dist-info → yaicli-0.2.0.dist-info}/licenses/LICENSE +0 -0
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
|