yaicli 0.0.19__py3-none-any.whl → 0.1.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/cli.py +332 -0
- yaicli/config.py +183 -0
- yaicli/const.py +119 -0
- yaicli/entry.py +95 -0
- yaicli/history.py +72 -0
- yaicli/printer.py +244 -0
- yaicli/utils.py +112 -0
- {yaicli-0.0.19.dist-info → yaicli-0.1.0.dist-info}/METADATA +280 -233
- yaicli-0.1.0.dist-info/RECORD +15 -0
- yaicli-0.1.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.1.0.dist-info}/WHEEL +0 -0
- {yaicli-0.0.19.dist-info → yaicli-0.1.0.dist-info}/licenses/LICENSE +0 -0
yaicli/cli.py
ADDED
@@ -0,0 +1,332 @@
|
|
1
|
+
import subprocess
|
2
|
+
import sys
|
3
|
+
import traceback
|
4
|
+
from os.path import devnull
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import List, Optional, Tuple
|
7
|
+
|
8
|
+
import typer
|
9
|
+
from prompt_toolkit import PromptSession, prompt
|
10
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
11
|
+
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
|
12
|
+
from prompt_toolkit.keys import Keys
|
13
|
+
from rich import get_console
|
14
|
+
from rich.markdown import Markdown
|
15
|
+
from rich.padding import Padding
|
16
|
+
from rich.panel import Panel
|
17
|
+
from rich.prompt import Prompt
|
18
|
+
|
19
|
+
from yaicli.api import ApiClient
|
20
|
+
from yaicli.config import CONFIG_PATH, Config
|
21
|
+
from yaicli.const import (
|
22
|
+
CHAT_MODE,
|
23
|
+
CMD_CLEAR,
|
24
|
+
CMD_EXIT,
|
25
|
+
CMD_HISTORY,
|
26
|
+
CMD_MODE,
|
27
|
+
DEFAULT_CODE_THEME,
|
28
|
+
DEFAULT_INTERACTIVE_ROUND,
|
29
|
+
DEFAULT_OS_NAME,
|
30
|
+
DEFAULT_PROMPT,
|
31
|
+
DEFAULT_SHELL_NAME,
|
32
|
+
EXEC_MODE,
|
33
|
+
SHELL_PROMPT,
|
34
|
+
TEMP_MODE,
|
35
|
+
)
|
36
|
+
from yaicli.history import LimitedFileHistory
|
37
|
+
from yaicli.printer import Printer
|
38
|
+
from yaicli.utils import detect_os, detect_shell, filter_command
|
39
|
+
|
40
|
+
|
41
|
+
class CLI:
|
42
|
+
HISTORY_FILE = Path("~/.yaicli_history").expanduser()
|
43
|
+
|
44
|
+
def __init__(
|
45
|
+
self, verbose: bool = False, api_client: Optional[ApiClient] = None, printer: Optional[Printer] = None
|
46
|
+
):
|
47
|
+
self.verbose = verbose
|
48
|
+
self.console = get_console()
|
49
|
+
self.bindings = KeyBindings()
|
50
|
+
self.config: Config = Config(self.console)
|
51
|
+
self.current_mode: str = TEMP_MODE
|
52
|
+
self.history = []
|
53
|
+
self.interactive_max_history = self.config.get("INTERACTIVE_MAX_HISTORY", DEFAULT_INTERACTIVE_ROUND)
|
54
|
+
|
55
|
+
# Detect OS and Shell if set to auto
|
56
|
+
if self.config.get("OS_NAME") == DEFAULT_OS_NAME:
|
57
|
+
self.config["OS_NAME"] = detect_os(self.config)
|
58
|
+
if self.config.get("SHELL_NAME") == DEFAULT_SHELL_NAME:
|
59
|
+
self.config["SHELL_NAME"] = detect_shell(self.config)
|
60
|
+
|
61
|
+
if self.verbose:
|
62
|
+
self.console.print("Loading Configuration:", style="bold cyan")
|
63
|
+
self.console.print(f"Config file path: {CONFIG_PATH}")
|
64
|
+
for key, value in self.config.items():
|
65
|
+
display_value = "****" if key == "API_KEY" and value else value
|
66
|
+
self.console.print(f" {key:<16}: {display_value}")
|
67
|
+
self.console.print(Markdown("---", code_theme=self.config.get("CODE_THEME", DEFAULT_CODE_THEME)))
|
68
|
+
|
69
|
+
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)
|
71
|
+
|
72
|
+
_origin_stderr = None
|
73
|
+
if not sys.stdin.isatty():
|
74
|
+
_origin_stderr = sys.stderr
|
75
|
+
sys.stderr = open(devnull, "w", encoding="utf-8")
|
76
|
+
try:
|
77
|
+
self.session = PromptSession(key_bindings=self.bindings)
|
78
|
+
finally:
|
79
|
+
if _origin_stderr:
|
80
|
+
sys.stderr.close()
|
81
|
+
sys.stderr = _origin_stderr
|
82
|
+
|
83
|
+
def get_prompt_tokens(self) -> List[Tuple[str, str]]:
|
84
|
+
"""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", "> ")]
|
87
|
+
|
88
|
+
def _check_history_len(self) -> None:
|
89
|
+
"""Check history length and remove oldest messages if necessary"""
|
90
|
+
target_len = self.interactive_max_history * 2
|
91
|
+
if len(self.history) > target_len:
|
92
|
+
self.history = self.history[-target_len:]
|
93
|
+
|
94
|
+
def _handle_special_commands(self, user_input: str) -> Optional[bool]:
|
95
|
+
"""Handle special command return: True-continue loop, False-exit loop, None-non-special command"""
|
96
|
+
command = user_input.lower().strip()
|
97
|
+
if command == CMD_EXIT:
|
98
|
+
return False
|
99
|
+
if command == CMD_CLEAR and self.current_mode == CHAT_MODE:
|
100
|
+
self.history.clear()
|
101
|
+
self.console.print("Chat history cleared", style="bold yellow")
|
102
|
+
return True
|
103
|
+
if command == CMD_HISTORY:
|
104
|
+
if not self.history:
|
105
|
+
self.console.print("History is empty.", style="yellow")
|
106
|
+
else:
|
107
|
+
self.console.print("Chat History:", style="bold underline")
|
108
|
+
code_theme = self.config.get("CODE_THEME", "monokai")
|
109
|
+
for i in range(0, len(self.history), 2):
|
110
|
+
user_msg = self.history[i]
|
111
|
+
assistant_msg = self.history[i + 1] if (i + 1) < len(self.history) else None
|
112
|
+
self.console.print(f"[dim]{i // 2 + 1}[/dim] [bold blue]User:[/bold blue] {user_msg['content']}")
|
113
|
+
if assistant_msg:
|
114
|
+
md = Markdown(assistant_msg["content"], code_theme=code_theme)
|
115
|
+
padded_md = Padding(md, (0, 0, 0, 4))
|
116
|
+
self.console.print(" Assistant:", style="bold green")
|
117
|
+
self.console.print(padded_md)
|
118
|
+
return True
|
119
|
+
# Handle /mode command
|
120
|
+
if command.startswith(CMD_MODE):
|
121
|
+
parts = command.split(maxsplit=1)
|
122
|
+
if len(parts) == 2 and parts[1] in [CHAT_MODE, EXEC_MODE]:
|
123
|
+
new_mode = parts[1]
|
124
|
+
if self.current_mode != new_mode:
|
125
|
+
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
|
+
else:
|
129
|
+
self.console.print(f"Already in {self.current_mode} mode.", style="yellow")
|
130
|
+
else:
|
131
|
+
self.console.print(f"Usage: {CMD_MODE} {CHAT_MODE}|{EXEC_MODE}", style="yellow")
|
132
|
+
return True
|
133
|
+
return None
|
134
|
+
|
135
|
+
def _confirm_and_execute(self, raw_content: str) -> None:
|
136
|
+
"""Review, edit and execute the command"""
|
137
|
+
cmd = filter_command(raw_content)
|
138
|
+
if not cmd:
|
139
|
+
self.console.print("No command generated or command is empty.", style="bold red")
|
140
|
+
return
|
141
|
+
self.console.print(
|
142
|
+
Panel(cmd, title="Suggest Command", title_align="left", border_style="bold magenta", expand=False)
|
143
|
+
)
|
144
|
+
_input = Prompt.ask(
|
145
|
+
r"Execute command? \[e]dit, \[y]es, \[n]o",
|
146
|
+
choices=["y", "n", "e"],
|
147
|
+
default="n",
|
148
|
+
case_sensitive=False,
|
149
|
+
show_choices=False,
|
150
|
+
)
|
151
|
+
executed_cmd = None
|
152
|
+
if _input == "y":
|
153
|
+
executed_cmd = cmd
|
154
|
+
elif _input == "e":
|
155
|
+
try:
|
156
|
+
edited_cmd = prompt("Edit command: ", default=cmd).strip()
|
157
|
+
if edited_cmd and edited_cmd != cmd:
|
158
|
+
executed_cmd = edited_cmd
|
159
|
+
elif edited_cmd:
|
160
|
+
executed_cmd = cmd
|
161
|
+
else:
|
162
|
+
self.console.print("Execution cancelled.", style="yellow")
|
163
|
+
except EOFError:
|
164
|
+
self.console.print("\nEdit cancelled.", style="yellow")
|
165
|
+
return
|
166
|
+
if executed_cmd:
|
167
|
+
self.console.print("--- Executing --- ", style="bold green")
|
168
|
+
try:
|
169
|
+
subprocess.call(executed_cmd, shell=True)
|
170
|
+
except Exception as e:
|
171
|
+
self.console.print(f"[red]Failed to execute command: {e}[/red]")
|
172
|
+
self.console.print("--- Finished ---", style="bold green")
|
173
|
+
elif _input != "e":
|
174
|
+
self.console.print("Execution cancelled.", style="yellow")
|
175
|
+
|
176
|
+
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
|
+
)
|
182
|
+
|
183
|
+
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
|
+
]
|
190
|
+
|
191
|
+
def _handle_llm_response(self, user_input: str) -> Optional[str]:
|
192
|
+
"""Get response from API (streaming or normal) and print it.
|
193
|
+
Returns the full content string or None if an error occurred.
|
194
|
+
|
195
|
+
Args:
|
196
|
+
user_input (str): The user's input text.
|
197
|
+
|
198
|
+
Returns:
|
199
|
+
Optional[str]: The assistant's response content or None if an error occurred.
|
200
|
+
"""
|
201
|
+
messages = self._build_messages(user_input)
|
202
|
+
content = None
|
203
|
+
reasoning = None
|
204
|
+
|
205
|
+
try:
|
206
|
+
if self.config.get("STREAM", True):
|
207
|
+
stream_iterator = self.api_client.stream_completion(messages)
|
208
|
+
content, reasoning = self.printer.display_stream(stream_iterator)
|
209
|
+
else:
|
210
|
+
content, reasoning = self.api_client.completion(messages)
|
211
|
+
self.printer.display_normal(content, reasoning)
|
212
|
+
|
213
|
+
if content is not None:
|
214
|
+
# Add only the content (not reasoning) to history
|
215
|
+
self.history.extend(
|
216
|
+
[{"role": "user", "content": user_input}, {"role": "assistant", "content": content}]
|
217
|
+
)
|
218
|
+
self._check_history_len()
|
219
|
+
return content
|
220
|
+
else:
|
221
|
+
return None
|
222
|
+
except Exception as e:
|
223
|
+
self.console.print(f"[red]Error processing LLM response: {e}[/red]")
|
224
|
+
if self.verbose:
|
225
|
+
traceback.print_exc()
|
226
|
+
return None
|
227
|
+
|
228
|
+
def _process_user_input(self, user_input: str) -> bool:
|
229
|
+
"""Process user input: get response, print, update history, maybe execute.
|
230
|
+
Returns True to continue REPL, False to exit on critical error.
|
231
|
+
"""
|
232
|
+
content = self._handle_llm_response(user_input)
|
233
|
+
|
234
|
+
if content is None:
|
235
|
+
return True
|
236
|
+
|
237
|
+
if self.current_mode == EXEC_MODE:
|
238
|
+
self._confirm_and_execute(content)
|
239
|
+
return True
|
240
|
+
|
241
|
+
def _print_welcome_message(self) -> None:
|
242
|
+
"""Prints the initial welcome banner and instructions."""
|
243
|
+
self.console.print(
|
244
|
+
"""
|
245
|
+
██ ██ █████ ██ ██████ ██ ██
|
246
|
+
██ ██ ██ ██ ██ ██ ██ ██
|
247
|
+
████ ███████ ██ ██ ██ ██
|
248
|
+
██ ██ ██ ██ ██ ██ ██
|
249
|
+
██ ██ ██ ██ ██████ ███████ ██
|
250
|
+
""",
|
251
|
+
style="bold cyan",
|
252
|
+
)
|
253
|
+
self.console.print("Welcome to YAICLI!", style="bold")
|
254
|
+
self.console.print("Press [bold yellow]TAB[/bold yellow] to switch mode")
|
255
|
+
self.console.print(f"{CMD_CLEAR:<19}: Clear chat history")
|
256
|
+
self.console.print(f"{CMD_HISTORY:<19}: Show chat history")
|
257
|
+
cmd_exit = f"{CMD_EXIT}|Ctrl+D|Ctrl+C"
|
258
|
+
self.console.print(f"{cmd_exit:<19}: Exit")
|
259
|
+
cmd_mode = f"{CMD_MODE} {CHAT_MODE}|{EXEC_MODE}"
|
260
|
+
self.console.print(f"{cmd_mode:<19}: Switch mode (Case insensitive)", style="dim")
|
261
|
+
|
262
|
+
def _run_repl(self) -> None:
|
263
|
+
"""Run the main Read-Eval-Print Loop (REPL)."""
|
264
|
+
self.prepare_chat_loop()
|
265
|
+
self._print_welcome_message()
|
266
|
+
while True:
|
267
|
+
self.console.print(Markdown("---", code_theme=self.config.get("CODE_THEME", "monokai")))
|
268
|
+
try:
|
269
|
+
user_input = self.session.prompt(self.get_prompt_tokens)
|
270
|
+
user_input = user_input.strip()
|
271
|
+
if not user_input:
|
272
|
+
continue
|
273
|
+
command_result = self._handle_special_commands(user_input)
|
274
|
+
if command_result is False:
|
275
|
+
break
|
276
|
+
if command_result is True:
|
277
|
+
continue
|
278
|
+
if not self._process_user_input(user_input):
|
279
|
+
break
|
280
|
+
except (KeyboardInterrupt, EOFError):
|
281
|
+
break
|
282
|
+
self.console.print("\nExiting YAICLI... Goodbye!", style="bold green")
|
283
|
+
|
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)
|
295
|
+
|
296
|
+
if is_shell_mode:
|
297
|
+
self._confirm_and_execute(content)
|
298
|
+
|
299
|
+
def prepare_chat_loop(self) -> None:
|
300
|
+
"""Setup key bindings and history for interactive modes."""
|
301
|
+
self._setup_key_bindings()
|
302
|
+
self.HISTORY_FILE.touch(exist_ok=True)
|
303
|
+
try:
|
304
|
+
self.session = PromptSession(
|
305
|
+
key_bindings=self.bindings,
|
306
|
+
history=LimitedFileHistory(self.HISTORY_FILE, max_entries=self.interactive_max_history),
|
307
|
+
auto_suggest=AutoSuggestFromHistory() if self.config.get("AUTO_SUGGEST", True) else None,
|
308
|
+
enable_history_search=True,
|
309
|
+
)
|
310
|
+
except Exception as e:
|
311
|
+
self.console.print(f"[red]Error initializing prompt session history: {e}[/red]")
|
312
|
+
self.session = PromptSession(key_bindings=self.bindings)
|
313
|
+
|
314
|
+
def _setup_key_bindings(self) -> None:
|
315
|
+
"""Setup keyboard shortcuts (e.g., TAB for mode switching)."""
|
316
|
+
|
317
|
+
@self.bindings.add(Keys.ControlI) # TAB
|
318
|
+
def _(event: KeyPressEvent) -> None:
|
319
|
+
self.current_mode = EXEC_MODE if self.current_mode == CHAT_MODE else CHAT_MODE
|
320
|
+
|
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
|
327
|
+
self.current_mode = CHAT_MODE
|
328
|
+
self._run_repl()
|
329
|
+
elif prompt:
|
330
|
+
self._run_once(prompt, shell)
|
331
|
+
else:
|
332
|
+
self.console.print("No chat or prompt provided. Exiting.")
|
yaicli/config.py
ADDED
@@ -0,0 +1,183 @@
|
|
1
|
+
import configparser
|
2
|
+
from os import getenv
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Optional
|
5
|
+
|
6
|
+
from rich import get_console
|
7
|
+
from rich.console import Console
|
8
|
+
|
9
|
+
from yaicli.utils import str2bool
|
10
|
+
|
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
|
+
|
64
|
+
class CasePreservingConfigParser(configparser.RawConfigParser):
|
65
|
+
"""Case preserving config parser"""
|
66
|
+
|
67
|
+
def optionxform(self, optionstr):
|
68
|
+
return optionstr
|
69
|
+
|
70
|
+
|
71
|
+
class Config(dict):
|
72
|
+
"""Configuration class that loads settings on initialization.
|
73
|
+
|
74
|
+
This class encapsulates the configuration loading logic with priority:
|
75
|
+
1. Environment variables (highest priority)
|
76
|
+
2. Configuration file
|
77
|
+
3. Default values (lowest priority)
|
78
|
+
|
79
|
+
It handles type conversion and validation based on DEFAULT_CONFIG_MAP.
|
80
|
+
"""
|
81
|
+
|
82
|
+
def __init__(self, console: Optional[Console] = None):
|
83
|
+
"""Initializes and loads the configuration."""
|
84
|
+
self.console = console or get_console()
|
85
|
+
super().__init__()
|
86
|
+
self.reload()
|
87
|
+
|
88
|
+
def reload(self) -> None:
|
89
|
+
"""Reload configuration from all sources.
|
90
|
+
|
91
|
+
Follows priority order: env vars > config file > defaults
|
92
|
+
"""
|
93
|
+
# Start with defaults
|
94
|
+
self.clear()
|
95
|
+
self.update(self._load_defaults())
|
96
|
+
|
97
|
+
# Load from config file
|
98
|
+
file_config = self._load_from_file()
|
99
|
+
if file_config:
|
100
|
+
self.update(file_config)
|
101
|
+
|
102
|
+
# Load from environment variables and apply type conversion
|
103
|
+
self._load_from_env()
|
104
|
+
self._apply_type_conversion()
|
105
|
+
|
106
|
+
def _load_defaults(self) -> dict[str, str]:
|
107
|
+
"""Load default configuration values as strings.
|
108
|
+
|
109
|
+
Returns:
|
110
|
+
Dictionary with default configuration values
|
111
|
+
"""
|
112
|
+
return {k: v["value"] for k, v in DEFAULT_CONFIG_MAP.items()}
|
113
|
+
|
114
|
+
def _load_from_file(self) -> dict[str, str]:
|
115
|
+
"""Load configuration from the config file.
|
116
|
+
|
117
|
+
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
|
+
"""
|
122
|
+
if not CONFIG_PATH.exists():
|
123
|
+
self.console.print("Creating default configuration file.", style="bold yellow")
|
124
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
125
|
+
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
126
|
+
f.write(DEFAULT_CONFIG_INI)
|
127
|
+
return {}
|
128
|
+
|
129
|
+
config_parser = CasePreservingConfigParser()
|
130
|
+
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 {}
|
134
|
+
|
135
|
+
def _load_from_env(self) -> None:
|
136
|
+
"""Load configuration from environment variables.
|
137
|
+
|
138
|
+
Updates the configuration dictionary in-place.
|
139
|
+
"""
|
140
|
+
for key, config_info in DEFAULT_CONFIG_MAP.items():
|
141
|
+
env_value = getenv(config_info["env_key"])
|
142
|
+
if env_value is not None:
|
143
|
+
self[key] = env_value
|
144
|
+
|
145
|
+
def _apply_type_conversion(self) -> None:
|
146
|
+
"""Apply type conversion to configuration values.
|
147
|
+
|
148
|
+
Updates the configuration dictionary in-place with properly typed values.
|
149
|
+
Falls back to default values if conversion fails.
|
150
|
+
"""
|
151
|
+
default_values_str = self._load_defaults()
|
152
|
+
|
153
|
+
for key, config_info in DEFAULT_CONFIG_MAP.items():
|
154
|
+
target_type = config_info["type"]
|
155
|
+
raw_value = self.get(key, default_values_str.get(key))
|
156
|
+
converted_value = None
|
157
|
+
|
158
|
+
try:
|
159
|
+
if target_type is bool:
|
160
|
+
converted_value = str2bool(raw_value)
|
161
|
+
elif target_type in (int, float, str):
|
162
|
+
converted_value = target_type(raw_value)
|
163
|
+
except (ValueError, TypeError) as e:
|
164
|
+
self.console.print(
|
165
|
+
f"[yellow]Warning:[/yellow] Invalid value '{raw_value}' for '{key}'. "
|
166
|
+
f"Expected type '{target_type.__name__}'. Using default value '{default_values_str[key]}'. Error: {e}",
|
167
|
+
style="dim",
|
168
|
+
)
|
169
|
+
# Fallback to default string value if conversion fails
|
170
|
+
try:
|
171
|
+
if target_type is bool:
|
172
|
+
converted_value = str2bool(default_values_str[key])
|
173
|
+
else:
|
174
|
+
converted_value = target_type(default_values_str[key])
|
175
|
+
except (ValueError, TypeError):
|
176
|
+
# If default also fails (unlikely), keep the raw merged value or a sensible default
|
177
|
+
self.console.print(
|
178
|
+
f"[red]Error:[/red] Could not convert default value for '{key}'. Using raw value.",
|
179
|
+
style="error",
|
180
|
+
)
|
181
|
+
converted_value = raw_value # Or assign a hardcoded safe default
|
182
|
+
|
183
|
+
self[key] = converted_value
|
yaicli/const.py
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
from enum import StrEnum
|
2
|
+
|
3
|
+
CMD_CLEAR = "/clear"
|
4
|
+
CMD_EXIT = "/exit"
|
5
|
+
CMD_HISTORY = "/his"
|
6
|
+
CMD_MODE = "/mode"
|
7
|
+
|
8
|
+
EXEC_MODE = "exec"
|
9
|
+
CHAT_MODE = "chat"
|
10
|
+
TEMP_MODE = "temp"
|
11
|
+
|
12
|
+
DEFAULT_CONFIG_PATH = "~/.config/yaicli/config.ini"
|
13
|
+
DEFAULT_CODE_THEME = "monokai"
|
14
|
+
DEFAULT_COMPLETION_PATH = "chat/completions"
|
15
|
+
DEFAULT_ANSWER_PATH = "choices[0].message.content"
|
16
|
+
DEFAULT_PROVIDER = "openai"
|
17
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
18
|
+
DEFAULT_MODEL = "gpt-4o"
|
19
|
+
DEFAULT_SHELL_NAME = "auto"
|
20
|
+
DEFAULT_OS_NAME = "auto"
|
21
|
+
DEFAULT_STREAM = "true"
|
22
|
+
DEFAULT_TEMPERATURE: float = 0.7
|
23
|
+
DEFAULT_TOP_P: float = 1.0
|
24
|
+
DEFAULT_MAX_TOKENS: int = 1024
|
25
|
+
DEFAULT_MAX_HISTORY: int = 500
|
26
|
+
DEFAULT_AUTO_SUGGEST = "true"
|
27
|
+
DEFAULT_TIMEOUT: int = 60
|
28
|
+
DEFAULT_INTERACTIVE_ROUND: int = 25
|
29
|
+
|
30
|
+
|
31
|
+
class EventTypeEnum(StrEnum):
|
32
|
+
"""Enumeration of possible event types from the SSE stream."""
|
33
|
+
|
34
|
+
ERROR = "error"
|
35
|
+
REASONING = "reasoning"
|
36
|
+
REASONING_END = "reasoning_end"
|
37
|
+
CONTENT = "content"
|
38
|
+
FINISH = "finish"
|
39
|
+
|
40
|
+
|
41
|
+
SHELL_PROMPT = """Your are a Shell Command Generator.
|
42
|
+
Generate a command EXCLUSIVELY for {_os} OS with {_shell} shell.
|
43
|
+
If details are missing, offer the most logical solution.
|
44
|
+
Ensure the output is a valid shell command.
|
45
|
+
Combine multiple steps with `&&` when possible.
|
46
|
+
Supply plain text only, avoiding Markdown formatting."""
|
47
|
+
|
48
|
+
DEFAULT_PROMPT = (
|
49
|
+
"You are YAICLI, a system management and programing assistant, "
|
50
|
+
"You are managing {_os} operating system with {_shell} shell. "
|
51
|
+
"Your responses should be concise and use Markdown format (but dont't use ```markdown), "
|
52
|
+
"unless the user explicitly requests more details."
|
53
|
+
)
|
54
|
+
|
55
|
+
# DEFAULT_CONFIG_MAP is a dictionary of the configuration options.
|
56
|
+
# The key is the name of the configuration option.
|
57
|
+
# The value is a dictionary with the following keys:
|
58
|
+
# - value: the default value of the configuration option
|
59
|
+
# - env_key: the environment variable key of the configuration option
|
60
|
+
# - type: the type of the configuration option
|
61
|
+
DEFAULT_CONFIG_MAP = {
|
62
|
+
# Core API settings
|
63
|
+
"BASE_URL": {"value": DEFAULT_BASE_URL, "env_key": "YAI_BASE_URL", "type": str},
|
64
|
+
"API_KEY": {"value": "", "env_key": "YAI_API_KEY", "type": str},
|
65
|
+
"MODEL": {"value": DEFAULT_MODEL, "env_key": "YAI_MODEL", "type": str},
|
66
|
+
# System detection hints
|
67
|
+
"SHELL_NAME": {"value": DEFAULT_SHELL_NAME, "env_key": "YAI_SHELL_NAME", "type": str},
|
68
|
+
"OS_NAME": {"value": DEFAULT_OS_NAME, "env_key": "YAI_OS_NAME", "type": str},
|
69
|
+
# API response parsing
|
70
|
+
"COMPLETION_PATH": {"value": DEFAULT_COMPLETION_PATH, "env_key": "YAI_COMPLETION_PATH", "type": str},
|
71
|
+
"ANSWER_PATH": {"value": DEFAULT_ANSWER_PATH, "env_key": "YAI_ANSWER_PATH", "type": str},
|
72
|
+
# API call parameters
|
73
|
+
"STREAM": {"value": DEFAULT_STREAM, "env_key": "YAI_STREAM", "type": bool},
|
74
|
+
"TEMPERATURE": {"value": DEFAULT_TEMPERATURE, "env_key": "YAI_TEMPERATURE", "type": float},
|
75
|
+
"TOP_P": {"value": DEFAULT_TOP_P, "env_key": "YAI_TOP_P", "type": float},
|
76
|
+
"MAX_TOKENS": {"value": DEFAULT_MAX_TOKENS, "env_key": "YAI_MAX_TOKENS", "type": int},
|
77
|
+
"TIMEOUT": {"value": DEFAULT_TIMEOUT, "env_key": "YAI_TIMEOUT", "type": int},
|
78
|
+
"INTERACTIVE_ROUND": {
|
79
|
+
"value": DEFAULT_INTERACTIVE_ROUND,
|
80
|
+
"env_key": "YAI_INTERACTIVE_ROUND",
|
81
|
+
"type": int,
|
82
|
+
},
|
83
|
+
# UI/UX settings
|
84
|
+
"CODE_THEME": {"value": DEFAULT_CODE_THEME, "env_key": "YAI_CODE_THEME", "type": str},
|
85
|
+
"MAX_HISTORY": {"value": DEFAULT_MAX_HISTORY, "env_key": "YAI_MAX_HISTORY", "type": int},
|
86
|
+
"AUTO_SUGGEST": {"value": DEFAULT_AUTO_SUGGEST, "env_key": "YAI_AUTO_SUGGEST", "type": bool},
|
87
|
+
}
|
88
|
+
|
89
|
+
DEFAULT_CONFIG_INI = f"""[core]
|
90
|
+
PROVIDER={DEFAULT_PROVIDER}
|
91
|
+
BASE_URL={DEFAULT_CONFIG_MAP["BASE_URL"]["value"]}
|
92
|
+
API_KEY={DEFAULT_CONFIG_MAP["API_KEY"]["value"]}
|
93
|
+
MODEL={DEFAULT_CONFIG_MAP["MODEL"]["value"]}
|
94
|
+
|
95
|
+
# auto detect shell and os (or specify manually, e.g., bash, zsh, powershell.exe)
|
96
|
+
SHELL_NAME={DEFAULT_CONFIG_MAP["SHELL_NAME"]["value"]}
|
97
|
+
OS_NAME={DEFAULT_CONFIG_MAP["OS_NAME"]["value"]}
|
98
|
+
|
99
|
+
# API paths (usually no need to change for OpenAI compatible APIs)
|
100
|
+
COMPLETION_PATH={DEFAULT_CONFIG_MAP["COMPLETION_PATH"]["value"]}
|
101
|
+
ANSWER_PATH={DEFAULT_CONFIG_MAP["ANSWER_PATH"]["value"]}
|
102
|
+
|
103
|
+
# true: streaming response, false: non-streaming
|
104
|
+
STREAM={DEFAULT_CONFIG_MAP["STREAM"]["value"]}
|
105
|
+
|
106
|
+
# LLM parameters
|
107
|
+
TEMPERATURE={DEFAULT_CONFIG_MAP["TEMPERATURE"]["value"]}
|
108
|
+
TOP_P={DEFAULT_CONFIG_MAP["TOP_P"]["value"]}
|
109
|
+
MAX_TOKENS={DEFAULT_CONFIG_MAP["MAX_TOKENS"]["value"]}
|
110
|
+
TIMEOUT={DEFAULT_CONFIG_MAP["TIMEOUT"]["value"]}
|
111
|
+
|
112
|
+
# Interactive mode parameters
|
113
|
+
INTERACTIVE_ROUND={DEFAULT_CONFIG_MAP["INTERACTIVE_ROUND"]["value"]}
|
114
|
+
|
115
|
+
# UI/UX
|
116
|
+
CODE_THEME={DEFAULT_CONFIG_MAP["CODE_THEME"]["value"]}
|
117
|
+
MAX_HISTORY={DEFAULT_CONFIG_MAP["MAX_HISTORY"]["value"]} # Max entries kept in history file
|
118
|
+
AUTO_SUGGEST={DEFAULT_CONFIG_MAP["AUTO_SUGGEST"]["value"]}
|
119
|
+
"""
|