yaicli 0.2.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 +46 -19
- yaicli/cli.py +101 -68
- yaicli/config.py +17 -1
- yaicli/console.py +66 -0
- yaicli/const.py +51 -6
- yaicli/entry.py +173 -50
- 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.2.0.dist-info → yaicli-0.3.0.dist-info}/METADATA +105 -89
- yaicli-0.3.0.dist-info/RECORD +20 -0
- yaicli-0.2.0.dist-info/RECORD +0 -16
- {yaicli-0.2.0.dist-info → yaicli-0.3.0.dist-info}/WHEEL +0 -0
- {yaicli-0.2.0.dist-info → yaicli-0.3.0.dist-info}/entry_points.txt +0 -0
- {yaicli-0.2.0.dist-info → yaicli-0.3.0.dist-info}/licenses/LICENSE +0 -0
yaicli/printer.py
CHANGED
@@ -1,20 +1,31 @@
|
|
1
|
-
import itertools
|
2
1
|
import time
|
3
2
|
import traceback
|
4
3
|
from typing import (
|
5
4
|
Any,
|
6
5
|
Dict,
|
7
6
|
Iterator,
|
7
|
+
List,
|
8
8
|
Optional,
|
9
9
|
Tuple,
|
10
10
|
)
|
11
11
|
|
12
|
-
from rich import
|
13
|
-
from rich.console import Console
|
12
|
+
from rich.console import Console, Group, RenderableType
|
14
13
|
from rich.live import Live
|
15
|
-
from rich.markdown import Markdown
|
16
14
|
|
17
|
-
from yaicli.
|
15
|
+
from yaicli.console import get_console
|
16
|
+
from yaicli.const import EventTypeEnum
|
17
|
+
from yaicli.render import JustifyMarkdown as Markdown
|
18
|
+
from yaicli.render import plain_formatter
|
19
|
+
|
20
|
+
|
21
|
+
def cursor_animation() -> Iterator[str]:
|
22
|
+
"""Generate a cursor animation for the console."""
|
23
|
+
cursors = ["_", " "]
|
24
|
+
while True:
|
25
|
+
# Use current time to determine cursor state (changes every 0.5 seconds)
|
26
|
+
current_time = time.time()
|
27
|
+
# Alternate between cursors based on time
|
28
|
+
yield cursors[int(current_time * 2) % 2]
|
18
29
|
|
19
30
|
|
20
31
|
class Printer:
|
@@ -23,12 +34,40 @@ class Printer:
|
|
23
34
|
_REASONING_PREFIX = "> "
|
24
35
|
_CURSOR_ANIMATION_SLEEP = 0.005
|
25
36
|
|
26
|
-
def __init__(
|
37
|
+
def __init__(
|
38
|
+
self,
|
39
|
+
config: Dict[str, Any],
|
40
|
+
console: Console,
|
41
|
+
verbose: bool = False,
|
42
|
+
markdown: bool = True,
|
43
|
+
reasoning_markdown: Optional[bool] = None,
|
44
|
+
content_markdown: Optional[bool] = None,
|
45
|
+
):
|
46
|
+
"""Initialize the Printer class.
|
47
|
+
|
48
|
+
Args:
|
49
|
+
config (Dict[str, Any]): The configuration dictionary.
|
50
|
+
console (Console): The console object.
|
51
|
+
verbose (bool): Whether to print verbose output.
|
52
|
+
markdown (bool): Whether to use Markdown formatting for all output (legacy).
|
53
|
+
reasoning_markdown (Optional[bool]): Whether to use Markdown for reasoning sections.
|
54
|
+
content_markdown (Optional[bool]): Whether to use Markdown for content sections.
|
55
|
+
"""
|
27
56
|
self.config = config
|
28
57
|
self.console = console or get_console()
|
29
58
|
self.verbose = verbose
|
30
|
-
self.code_theme = config
|
59
|
+
self.code_theme = config["CODE_THEME"]
|
31
60
|
self.in_reasoning: bool = False
|
61
|
+
# Print reasoning content or not
|
62
|
+
self.show_reasoning = config["SHOW_REASONING"]
|
63
|
+
|
64
|
+
# Use explicit settings if provided, otherwise fall back to the global markdown setting
|
65
|
+
self.reasoning_markdown = reasoning_markdown if reasoning_markdown is not None else markdown
|
66
|
+
self.content_markdown = content_markdown if content_markdown is not None else markdown
|
67
|
+
|
68
|
+
# Set formatters for reasoning and content
|
69
|
+
self.reasoning_formatter = Markdown if self.reasoning_markdown else plain_formatter
|
70
|
+
self.content_formatter = Markdown if self.content_markdown else plain_formatter
|
32
71
|
|
33
72
|
def _reset_state(self) -> None:
|
34
73
|
"""Resets the printer state for a new stream."""
|
@@ -118,7 +157,7 @@ class Printer:
|
|
118
157
|
|
119
158
|
return content, reasoning
|
120
159
|
|
121
|
-
def _format_display_text(self, content: str, reasoning: str) ->
|
160
|
+
def _format_display_text(self, content: str, reasoning: str) -> RenderableType:
|
122
161
|
"""Format the text for display, combining content and reasoning if needed.
|
123
162
|
|
124
163
|
Args:
|
@@ -126,26 +165,39 @@ class Printer:
|
|
126
165
|
reasoning (str): The reasoning text.
|
127
166
|
|
128
167
|
Returns:
|
129
|
-
|
168
|
+
RenderableType: The formatted text ready for display as a Rich renderable.
|
130
169
|
"""
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
#
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
170
|
+
# Create list of display elements to avoid type issues with concatenation
|
171
|
+
display_elements: List[RenderableType] = []
|
172
|
+
|
173
|
+
reasoning = reasoning.strip()
|
174
|
+
# Format reasoning with proper formatting if it exists
|
175
|
+
if reasoning and self.show_reasoning:
|
176
|
+
raw_reasoning = reasoning.replace("\n", f"\n{self._REASONING_PREFIX}")
|
177
|
+
if not raw_reasoning.startswith(self._REASONING_PREFIX):
|
178
|
+
raw_reasoning = self._REASONING_PREFIX + raw_reasoning
|
179
|
+
|
180
|
+
# Format the reasoning section
|
181
|
+
reasoning_header = "\nThinking:\n"
|
182
|
+
formatted_reasoning = self.reasoning_formatter(reasoning_header + raw_reasoning, code_theme=self.code_theme)
|
183
|
+
display_elements.append(formatted_reasoning)
|
184
|
+
|
185
|
+
content = content.strip()
|
186
|
+
# Format content if it exists
|
187
|
+
if content:
|
188
|
+
formatted_content = self.content_formatter(content, code_theme=self.code_theme)
|
189
|
+
|
190
|
+
# Add spacing between reasoning and content if both exist
|
191
|
+
if reasoning and self.show_reasoning:
|
192
|
+
display_elements.append("")
|
193
|
+
|
194
|
+
display_elements.append(formatted_content)
|
195
|
+
|
196
|
+
# Return based on what we have
|
197
|
+
if not display_elements:
|
198
|
+
return ""
|
199
|
+
# Use Rich Group to combine multiple renderables
|
200
|
+
return Group(*display_elements)
|
149
201
|
|
150
202
|
def _update_live_display(self, live: Live, content: str, reasoning: str, cursor: Iterator[str]) -> None:
|
151
203
|
"""Update live display content and execute cursor animation
|
@@ -157,52 +209,57 @@ class Printer:
|
|
157
209
|
reasoning (str): The current reasoning text.
|
158
210
|
cursor (Iterator[str]): The cursor animation iterator.
|
159
211
|
"""
|
160
|
-
|
161
|
-
display_text = self._format_display_text(content, reasoning)
|
212
|
+
|
162
213
|
cursor_char = next(cursor)
|
163
214
|
|
164
|
-
#
|
165
|
-
if self.in_reasoning:
|
166
|
-
#
|
215
|
+
# Handle cursor placement based on current state
|
216
|
+
if self.in_reasoning and self.show_reasoning:
|
217
|
+
# For reasoning, add cursor in plaintext to reasoning section
|
167
218
|
if reasoning:
|
168
|
-
# Append cursor directly if reasoning ends without newline, otherwise append after prefix
|
169
219
|
if reasoning.endswith("\n"):
|
170
|
-
|
220
|
+
cursor_line = f"\n{self._REASONING_PREFIX}{cursor_char}"
|
171
221
|
else:
|
172
|
-
|
222
|
+
cursor_line = cursor_char
|
223
|
+
|
224
|
+
# Re-format with cursor added
|
225
|
+
raw_reasoning = reasoning + cursor_line.replace(self._REASONING_PREFIX, "")
|
226
|
+
formatted_display = self._format_display_text(content, raw_reasoning)
|
173
227
|
else:
|
174
|
-
# If reasoning just started
|
175
|
-
|
176
|
-
|
228
|
+
# If reasoning just started with no content yet
|
229
|
+
reasoning_header = f"\nThinking:\n{self._REASONING_PREFIX}{cursor_char}"
|
230
|
+
formatted_reasoning = self.reasoning_formatter(reasoning_header, code_theme=self.code_theme)
|
231
|
+
formatted_display = Group(formatted_reasoning)
|
177
232
|
else:
|
178
|
-
#
|
179
|
-
|
233
|
+
# For content, add cursor to content section
|
234
|
+
formatted_content_with_cursor = content + cursor_char
|
235
|
+
formatted_display = self._format_display_text(formatted_content_with_cursor, reasoning)
|
180
236
|
|
181
|
-
|
182
|
-
live.update(markdown)
|
237
|
+
live.update(formatted_display)
|
183
238
|
time.sleep(self._CURSOR_ANIMATION_SLEEP)
|
184
239
|
|
185
|
-
def display_stream(
|
240
|
+
def display_stream(
|
241
|
+
self, stream_iterator: Iterator[Dict[str, Any]], with_assistant_prefix: bool = True
|
242
|
+
) -> Tuple[Optional[str], Optional[str]]:
|
186
243
|
"""Display streaming response content
|
187
244
|
Handle stream events and update the live display accordingly.
|
188
245
|
This method separates content and reasoning blocks for display and further processing.
|
189
246
|
|
190
247
|
Args:
|
191
248
|
stream_iterator (Iterator[Dict[str, Any]]): The stream iterator to process.
|
249
|
+
with_assistant_prefix (bool): Whether to display the "Assistant:" prefix.
|
192
250
|
Returns:
|
193
251
|
Tuple[Optional[str], Optional[str]]: The final content and reasoning texts if successful, None otherwise.
|
194
252
|
"""
|
195
|
-
|
253
|
+
if with_assistant_prefix:
|
254
|
+
self.console.print("Assistant:", style="bold green")
|
196
255
|
self._reset_state() # Reset state for the new stream
|
197
256
|
content = ""
|
198
257
|
reasoning = ""
|
199
|
-
|
200
|
-
cursor = itertools.cycle(["_", " "])
|
258
|
+
cursor = cursor_animation()
|
201
259
|
|
202
260
|
with Live(console=self.console) as live:
|
203
261
|
try:
|
204
262
|
for event in stream_iterator:
|
205
|
-
# Pass only content and reasoning, rely on self.in_reasoning
|
206
263
|
content, reasoning = self._handle_event(event, content, reasoning)
|
207
264
|
|
208
265
|
if event.get("type") in (
|
@@ -210,35 +267,34 @@ class Printer:
|
|
210
267
|
EventTypeEnum.REASONING,
|
211
268
|
EventTypeEnum.REASONING_END,
|
212
269
|
):
|
213
|
-
# Pass only necessary variables, rely on self.in_reasoning
|
214
270
|
self._update_live_display(live, content, reasoning, cursor)
|
215
271
|
|
216
272
|
# Remove cursor and finalize display
|
217
|
-
|
218
|
-
live.update(Markdown(display_text, code_theme=self.code_theme))
|
273
|
+
live.update(self._format_display_text(content, reasoning))
|
219
274
|
return content, reasoning
|
220
275
|
|
221
276
|
except Exception as e:
|
222
277
|
self.console.print(f"An error occurred during stream display: {e}", style="red")
|
223
|
-
display_text = self._format_display_text(content, reasoning) + " [Display Error]"
|
224
|
-
live.update(Markdown(markup=display_text, code_theme=self.code_theme))
|
225
278
|
if self.verbose:
|
226
279
|
traceback.print_exc()
|
227
280
|
return None, None
|
228
281
|
|
229
|
-
def display_normal(
|
282
|
+
def display_normal(
|
283
|
+
self, content: Optional[str], reasoning: Optional[str] = None, with_assistant_prefix: bool = True
|
284
|
+
) -> None:
|
230
285
|
"""Display a complete, non-streamed response.
|
231
286
|
|
232
287
|
Args:
|
233
288
|
content (Optional[str]): The main content to display.
|
234
289
|
reasoning (Optional[str]): The reasoning content to display.
|
290
|
+
with_assistant_prefix (bool): Whether to display the "Assistant:" prefix.
|
235
291
|
"""
|
236
|
-
|
237
|
-
|
292
|
+
if with_assistant_prefix:
|
293
|
+
self.console.print("Assistant:", style="bold green")
|
238
294
|
if content or reasoning:
|
239
295
|
# Use the existing _format_display_text method
|
240
|
-
|
241
|
-
self.console.print(
|
296
|
+
formatted_display = self._format_display_text(content or "", reasoning or "")
|
297
|
+
self.console.print(formatted_display)
|
242
298
|
self.console.print() # Add a newline for spacing
|
243
299
|
else:
|
244
300
|
self.console.print("Assistant did not provide any content.", style="yellow")
|
yaicli/render.py
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
from typing import Any
|
2
|
+
|
3
|
+
from rich.markdown import Markdown
|
4
|
+
|
5
|
+
from yaicli.config import cfg
|
6
|
+
|
7
|
+
|
8
|
+
class JustifyMarkdown(Markdown):
|
9
|
+
"""Custom Markdown class that defaults to the configured justify value."""
|
10
|
+
|
11
|
+
def __init__(self, *args, **kwargs):
|
12
|
+
if "justify" not in kwargs:
|
13
|
+
kwargs["justify"] = cfg["JUSTIFY"]
|
14
|
+
super().__init__(*args, **kwargs)
|
15
|
+
|
16
|
+
|
17
|
+
def plain_formatter(text: str, **kwargs: Any) -> str:
|
18
|
+
"""Format the text for display, without Markdown formatting."""
|
19
|
+
return text
|
yaicli/roles.py
ADDED
@@ -0,0 +1,248 @@
|
|
1
|
+
import json
|
2
|
+
from pathlib import Path
|
3
|
+
from typing import Any, Dict, Optional, Union
|
4
|
+
|
5
|
+
import typer
|
6
|
+
from rich.console import Console
|
7
|
+
from rich.prompt import Prompt
|
8
|
+
from rich.table import Table
|
9
|
+
|
10
|
+
from yaicli.config import cfg
|
11
|
+
from yaicli.console import get_console
|
12
|
+
from yaicli.const import DEFAULT_ROLES, ROLES_DIR, DefaultRoleNames
|
13
|
+
from yaicli.exceptions import RoleAlreadyExistsError, RoleCreationError
|
14
|
+
from yaicli.utils import detect_os, detect_shell, option_callback
|
15
|
+
|
16
|
+
|
17
|
+
class Role:
|
18
|
+
def __init__(
|
19
|
+
self, name: str, prompt: str, variables: Optional[Dict[str, Any]] = None, filepath: Optional[str] = None
|
20
|
+
):
|
21
|
+
self.name = name
|
22
|
+
self.prompt = prompt
|
23
|
+
if not variables:
|
24
|
+
variables = {"_os": detect_os(cfg), "_shell": detect_shell(cfg)}
|
25
|
+
self.variables = variables
|
26
|
+
self.filepath = filepath
|
27
|
+
|
28
|
+
self.prompt = self.prompt.format(**self.variables)
|
29
|
+
|
30
|
+
def to_dict(self) -> Dict[str, Any]:
|
31
|
+
"""Convert Role to dictionary for serialization"""
|
32
|
+
return {
|
33
|
+
"name": self.name,
|
34
|
+
"prompt": self.prompt,
|
35
|
+
}
|
36
|
+
|
37
|
+
@classmethod
|
38
|
+
def from_dict(cls, role_id: str, data: Dict[str, Any], filepath: Optional[str] = None) -> "Role":
|
39
|
+
"""Create Role object from dictionary"""
|
40
|
+
return cls(
|
41
|
+
name=data.get("name", role_id),
|
42
|
+
prompt=data.get("prompt", ""),
|
43
|
+
variables=data.get("variables", {}),
|
44
|
+
filepath=filepath,
|
45
|
+
)
|
46
|
+
|
47
|
+
def __str__(self):
|
48
|
+
return f"Role(name={self.name}, prompt={self.prompt[:30]}...)"
|
49
|
+
|
50
|
+
|
51
|
+
class RoleManager:
|
52
|
+
roles_dir: Path = ROLES_DIR
|
53
|
+
console: Console = get_console()
|
54
|
+
|
55
|
+
def __init__(self):
|
56
|
+
self.roles: Dict[str, Role] = self._load_roles()
|
57
|
+
|
58
|
+
def _load_roles(self) -> Dict[str, Role]:
|
59
|
+
"""Load all role configurations"""
|
60
|
+
roles = {}
|
61
|
+
self.roles_dir.mkdir(parents=True, exist_ok=True)
|
62
|
+
|
63
|
+
# Check if any role files exist
|
64
|
+
role_files: list[Path] = list(self.roles_dir.glob("*.json"))
|
65
|
+
|
66
|
+
if not role_files:
|
67
|
+
# Fast path: no existing roles, just create defaults
|
68
|
+
for role_id, role_config in DEFAULT_ROLES.items():
|
69
|
+
role_file = self.roles_dir / f"{role_id}.json"
|
70
|
+
filepath = str(role_file)
|
71
|
+
roles[role_id] = Role.from_dict(role_id, role_config, filepath)
|
72
|
+
with role_file.open("w", encoding="utf-8") as f:
|
73
|
+
json.dump(role_config, f, indent=2)
|
74
|
+
return roles
|
75
|
+
|
76
|
+
# Load existing role files
|
77
|
+
for role_file in role_files:
|
78
|
+
role_id = role_file.stem
|
79
|
+
filepath = str(role_file)
|
80
|
+
try:
|
81
|
+
with role_file.open("r", encoding="utf-8") as f:
|
82
|
+
role_data = json.load(f)
|
83
|
+
roles[role_id] = Role.from_dict(role_id, role_data, filepath)
|
84
|
+
except Exception as e:
|
85
|
+
self.console.print(f"Error loading role {role_id}: {e}", style="red")
|
86
|
+
|
87
|
+
# Ensure default roles exist
|
88
|
+
for role_id, role_config in DEFAULT_ROLES.items():
|
89
|
+
if role_id not in roles:
|
90
|
+
role_file = self.roles_dir / f"{role_id}.json"
|
91
|
+
filepath = str(role_file)
|
92
|
+
roles[role_id] = Role.from_dict(role_id, role_config, filepath)
|
93
|
+
with role_file.open("w", encoding="utf-8") as f:
|
94
|
+
json.dump(role_config, f, indent=2)
|
95
|
+
|
96
|
+
return roles
|
97
|
+
|
98
|
+
@classmethod
|
99
|
+
@option_callback
|
100
|
+
def print_list_option(cls, _: Any):
|
101
|
+
"""Print the list of roles.
|
102
|
+
This method is a cli option callback.
|
103
|
+
"""
|
104
|
+
table = Table(show_header=True, show_footer=False)
|
105
|
+
table.add_column("Name", style="dim")
|
106
|
+
table.add_column("Filepath", style="dim")
|
107
|
+
for file in sorted(cls.roles_dir.glob("*.json"), key=lambda f: f.stat().st_mtime):
|
108
|
+
table.add_row(file.stem, str(file))
|
109
|
+
cls.console.print(table)
|
110
|
+
cls.console.print("Use `ai --show-role <name>` to view a role.", style="dim")
|
111
|
+
|
112
|
+
def list_roles(self) -> list:
|
113
|
+
"""List all available roles info"""
|
114
|
+
roles_list = []
|
115
|
+
for role_id, role in sorted(self.roles.items()):
|
116
|
+
roles_list.append(
|
117
|
+
{
|
118
|
+
"id": role_id,
|
119
|
+
"name": role.name,
|
120
|
+
"prompt": role.prompt,
|
121
|
+
"is_default": role_id in DEFAULT_ROLES,
|
122
|
+
"filepath": role.filepath,
|
123
|
+
}
|
124
|
+
)
|
125
|
+
return roles_list
|
126
|
+
|
127
|
+
@classmethod
|
128
|
+
@option_callback
|
129
|
+
def show_role_option(cls, name: str):
|
130
|
+
"""Show a role's prompt.
|
131
|
+
This method is a cli option callback.
|
132
|
+
"""
|
133
|
+
self = cls()
|
134
|
+
role = self.get_role(name)
|
135
|
+
if not role:
|
136
|
+
self.console.print(f"Role '{name}' does not exist", style="red")
|
137
|
+
return
|
138
|
+
self.console.print(role.prompt)
|
139
|
+
|
140
|
+
def get_role(self, role_id: str) -> Optional[Role]:
|
141
|
+
"""Get role by ID"""
|
142
|
+
return self.roles.get(role_id)
|
143
|
+
|
144
|
+
@classmethod
|
145
|
+
def check_id_ok(cls, role_id: str):
|
146
|
+
"""Check if role exists by ID.
|
147
|
+
This method is a cli option callback.
|
148
|
+
If role does not exist, exit with error.
|
149
|
+
"""
|
150
|
+
if not role_id:
|
151
|
+
return role_id
|
152
|
+
self = cls()
|
153
|
+
if not self.role_exists(role_id):
|
154
|
+
self.console.print(f"Role '{role_id}' does not exist", style="red")
|
155
|
+
raise typer.Abort()
|
156
|
+
return role_id
|
157
|
+
|
158
|
+
def role_exists(self, role_id: str) -> bool:
|
159
|
+
"""Check if role exists"""
|
160
|
+
return role_id in self.roles
|
161
|
+
|
162
|
+
def save_role(self, role_id: str, role: Role) -> None:
|
163
|
+
"""Save role configuration"""
|
164
|
+
try:
|
165
|
+
self.roles[role_id] = role
|
166
|
+
role_file = self.roles_dir / f"{role_id}.json"
|
167
|
+
role.filepath = str(role_file)
|
168
|
+
with role_file.open("w", encoding="utf-8") as f:
|
169
|
+
json.dump(role.to_dict(), f, indent=2)
|
170
|
+
except Exception as e:
|
171
|
+
raise RoleCreationError(f"RoleCreationError {e}") from e
|
172
|
+
|
173
|
+
@classmethod
|
174
|
+
@option_callback
|
175
|
+
def create_role_option(cls, name: str):
|
176
|
+
"""Create a new role and save it to file.
|
177
|
+
This method is a cli option callback.
|
178
|
+
"""
|
179
|
+
self = cls()
|
180
|
+
if name in self.roles:
|
181
|
+
self.console.print(f"Role '{name}' already exists", style="yellow")
|
182
|
+
return
|
183
|
+
description = Prompt.ask("Enter role description")
|
184
|
+
|
185
|
+
# Format the prompt as "You are {role_id}, {description}"
|
186
|
+
prompt = f"You are {name}, {description}"
|
187
|
+
|
188
|
+
role = Role(name=name, prompt=prompt)
|
189
|
+
self.create_role(name, role)
|
190
|
+
self.console.print(f"Role '{name}' created successfully", style="green")
|
191
|
+
|
192
|
+
def create_role(self, role_id: str, role: Union[Role, Dict[str, Any]]) -> None:
|
193
|
+
"""Create a new role and save it to file"""
|
194
|
+
if role_id in self.roles:
|
195
|
+
raise RoleAlreadyExistsError(f"Role '{role_id}' already exists")
|
196
|
+
if isinstance(role, dict):
|
197
|
+
if "name" not in role or "prompt" not in role:
|
198
|
+
raise RoleCreationError("Role must have 'name' and 'prompt' keys")
|
199
|
+
# Convert dict to Role object
|
200
|
+
role = Role.from_dict(role_id, role)
|
201
|
+
self.save_role(role_id, role)
|
202
|
+
|
203
|
+
@classmethod
|
204
|
+
@option_callback
|
205
|
+
def delete_role_option(cls, name: str):
|
206
|
+
"""Delete a role and its file.
|
207
|
+
This method is a cli option callback.
|
208
|
+
"""
|
209
|
+
self = cls()
|
210
|
+
if self.delete_role(name):
|
211
|
+
self.console.print(f"Role '{name}' deleted successfully", style="green")
|
212
|
+
|
213
|
+
def delete_role(self, role_id: str) -> bool:
|
214
|
+
"""Delete a role and its file"""
|
215
|
+
if role_id not in self.roles:
|
216
|
+
self.console.print(f"Role '{role_id}' does not exist", style="red")
|
217
|
+
return False
|
218
|
+
|
219
|
+
# Don't allow deleting default roles
|
220
|
+
if role_id in DEFAULT_ROLES:
|
221
|
+
self.console.print(f"Cannot delete default role: '{role_id}'", style="red")
|
222
|
+
return False
|
223
|
+
|
224
|
+
try:
|
225
|
+
role = self.roles[role_id]
|
226
|
+
if role.filepath:
|
227
|
+
Path(role.filepath).unlink(missing_ok=True)
|
228
|
+
del self.roles[role_id]
|
229
|
+
return True
|
230
|
+
except Exception as e:
|
231
|
+
self.console.print(f"Error deleting role: {e}", style="red")
|
232
|
+
return False
|
233
|
+
|
234
|
+
def get_system_prompt(self, role_id: str) -> str:
|
235
|
+
"""Get prompt from file by role ID"""
|
236
|
+
role = self.get_role(role_id)
|
237
|
+
if not role:
|
238
|
+
# Fall back to default role if specified role doesn't exist
|
239
|
+
self.console.print(f"Role {role_id} not found, using default role", style="yellow")
|
240
|
+
role = self.get_role(DefaultRoleNames.DEFAULT)
|
241
|
+
if not role:
|
242
|
+
# Last resort fallback
|
243
|
+
default_config = DEFAULT_ROLES[DefaultRoleNames.DEFAULT]
|
244
|
+
role = Role.from_dict(DefaultRoleNames.DEFAULT, default_config)
|
245
|
+
|
246
|
+
# Create a copy of the role with system variables
|
247
|
+
system_role = Role(name=role.name, prompt=role.prompt)
|
248
|
+
return system_role.prompt
|
yaicli/utils.py
CHANGED
@@ -1,13 +1,32 @@
|
|
1
1
|
import platform
|
2
2
|
from os import getenv
|
3
3
|
from os.path import basename, pathsep
|
4
|
-
from typing import Any, Optional, TypeVar
|
4
|
+
from typing import Any, Callable, Optional, TypeVar
|
5
5
|
|
6
|
+
import typer
|
6
7
|
from distro import name as distro_name
|
7
8
|
|
8
9
|
from yaicli.const import DEFAULT_OS_NAME, DEFAULT_SHELL_NAME
|
9
10
|
|
10
|
-
T = TypeVar("T", int, float, str)
|
11
|
+
T = TypeVar("T", int, float, str, bool)
|
12
|
+
|
13
|
+
|
14
|
+
def option_callback(func: Callable) -> Callable: # pragma: no cover
|
15
|
+
"""
|
16
|
+
A decorator for Typer option callbacks that ensures the application exits
|
17
|
+
after the callback function is executed.
|
18
|
+
|
19
|
+
Args:
|
20
|
+
func (Callable): The callback classmethod to wrap.
|
21
|
+
"""
|
22
|
+
|
23
|
+
def wrapper(cls, value: T) -> T:
|
24
|
+
if not value:
|
25
|
+
return value
|
26
|
+
func(cls, value)
|
27
|
+
raise typer.Exit()
|
28
|
+
|
29
|
+
return wrapper
|
11
30
|
|
12
31
|
|
13
32
|
def detect_os(config: dict[str, Any]) -> str:
|