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.
- pyproject.toml +1 -1
- yaicli/api.py +14 -26
- yaicli/chat_manager.py +290 -0
- yaicli/cli.py +271 -56
- yaicli/config.py +51 -65
- yaicli/console.py +66 -0
- yaicli/const.py +70 -9
- yaicli/entry.py +178 -39
- yaicli/exceptions.py +46 -0
- yaicli/printer.py +113 -57
- yaicli/render.py +19 -0
- yaicli/roles.py +248 -0
- yaicli/utils.py +21 -2
- {yaicli-0.1.0.dist-info → yaicli-0.3.0.dist-info}/METADATA +199 -68
- yaicli-0.3.0.dist-info/RECORD +20 -0
- yaicli-0.1.0.dist-info/RECORD +0 -15
- {yaicli-0.1.0.dist-info → yaicli-0.3.0.dist-info}/WHEEL +0 -0
- {yaicli-0.1.0.dist-info → yaicli-0.3.0.dist-info}/entry_points.txt +0 -0
- {yaicli-0.1.0.dist-info → yaicli-0.3.0.dist-info}/licenses/LICENSE +0 -0
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.
|
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,
|
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 =
|
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:<
|
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
|
-
|
86
|
-
return [("class:qmark", f" {
|
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=
|
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
|
-
"""
|
178
|
-
|
179
|
-
return
|
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
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
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
|
-
|
203
|
-
|
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
|
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
|
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
|
-
|
285
|
-
|
286
|
-
|
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
|
-
|
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
|
322
|
-
"""
|
323
|
-
if
|
324
|
-
|
325
|
-
|
326
|
-
|
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
|
330
|
-
|
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
|
-
|
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
|
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
|
-
|
132
|
-
|
133
|
-
|
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()
|