tsugite-cli 0.3.3__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.
- tsugite/__init__.py +6 -0
- tsugite/agent_composition.py +163 -0
- tsugite/agent_inheritance.py +479 -0
- tsugite/agent_preparation.py +236 -0
- tsugite/agent_runner/__init__.py +45 -0
- tsugite/agent_runner/helpers.py +106 -0
- tsugite/agent_runner/history_integration.py +248 -0
- tsugite/agent_runner/metrics.py +100 -0
- tsugite/agent_runner/runner.py +1879 -0
- tsugite/agent_runner/validation.py +70 -0
- tsugite/agent_utils.py +167 -0
- tsugite/attachments/__init__.py +65 -0
- tsugite/attachments/auto_context.py +199 -0
- tsugite/attachments/base.py +34 -0
- tsugite/attachments/file.py +51 -0
- tsugite/attachments/inline.py +31 -0
- tsugite/attachments/storage.py +178 -0
- tsugite/attachments/url.py +59 -0
- tsugite/attachments/youtube.py +101 -0
- tsugite/benchmark/__init__.py +62 -0
- tsugite/benchmark/config.py +183 -0
- tsugite/benchmark/core.py +292 -0
- tsugite/benchmark/discovery.py +377 -0
- tsugite/benchmark/evaluators.py +671 -0
- tsugite/benchmark/execution.py +657 -0
- tsugite/benchmark/metrics.py +204 -0
- tsugite/benchmark/reports.py +420 -0
- tsugite/benchmark/utils.py +288 -0
- tsugite/builtin_agents/chat-assistant.md +53 -0
- tsugite/builtin_agents/default.md +140 -0
- tsugite/builtin_agents.py +5 -0
- tsugite/cache.py +195 -0
- tsugite/cli/__init__.py +1042 -0
- tsugite/cli/agents.py +148 -0
- tsugite/cli/attachments.py +193 -0
- tsugite/cli/benchmark.py +663 -0
- tsugite/cli/cache.py +113 -0
- tsugite/cli/config.py +272 -0
- tsugite/cli/helpers.py +534 -0
- tsugite/cli/history.py +193 -0
- tsugite/cli/init.py +387 -0
- tsugite/cli/mcp.py +193 -0
- tsugite/cli/tools.py +419 -0
- tsugite/config.py +204 -0
- tsugite/console.py +48 -0
- tsugite/constants.py +21 -0
- tsugite/core/__init__.py +19 -0
- tsugite/core/agent.py +774 -0
- tsugite/core/executor.py +300 -0
- tsugite/core/memory.py +67 -0
- tsugite/core/tools.py +271 -0
- tsugite/docker_cli.py +270 -0
- tsugite/events/__init__.py +55 -0
- tsugite/events/base.py +46 -0
- tsugite/events/bus.py +62 -0
- tsugite/events/events.py +224 -0
- tsugite/exceptions.py +40 -0
- tsugite/history/__init__.py +29 -0
- tsugite/history/index.py +210 -0
- tsugite/history/models.py +106 -0
- tsugite/history/storage.py +157 -0
- tsugite/mcp_client.py +219 -0
- tsugite/mcp_config.py +174 -0
- tsugite/md_agents.py +751 -0
- tsugite/models.py +257 -0
- tsugite/renderer.py +151 -0
- tsugite/shell_tool_config.py +265 -0
- tsugite/templates/assistant.md +14 -0
- tsugite/tools/__init__.py +265 -0
- tsugite/tools/agents.py +312 -0
- tsugite/tools/edit_strategies.py +393 -0
- tsugite/tools/fs.py +329 -0
- tsugite/tools/http.py +239 -0
- tsugite/tools/interactive.py +430 -0
- tsugite/tools/shell.py +129 -0
- tsugite/tools/shell_tools.py +214 -0
- tsugite/tools/tasks.py +339 -0
- tsugite/tsugite.py +7 -0
- tsugite/ui/__init__.py +46 -0
- tsugite/ui/base.py +638 -0
- tsugite/ui/chat.py +265 -0
- tsugite/ui/chat.tcss +92 -0
- tsugite/ui/chat_history.py +286 -0
- tsugite/ui/helpers.py +102 -0
- tsugite/ui/jsonl.py +125 -0
- tsugite/ui/live_template.py +529 -0
- tsugite/ui/plain.py +419 -0
- tsugite/ui/textual_chat.py +642 -0
- tsugite/ui/textual_handler.py +225 -0
- tsugite/ui/widgets/__init__.py +6 -0
- tsugite/ui/widgets/base_scroll_log.py +27 -0
- tsugite/ui/widgets/message_list.py +121 -0
- tsugite/ui/widgets/thought_log.py +80 -0
- tsugite/ui_context.py +90 -0
- tsugite/utils.py +367 -0
- tsugite/xdg.py +104 -0
- tsugite_cli-0.3.3.dist-info/METADATA +325 -0
- tsugite_cli-0.3.3.dist-info/RECORD +101 -0
- tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
- tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
- tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
tsugite/cli/helpers.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""CLI helper functions."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
# Re-export console utilities for backwards compatibility
|
|
14
|
+
from tsugite.console import get_error_console, get_output_console # noqa: F401
|
|
15
|
+
from tsugite.constants import TSUGITE_LOGO_NARROW, TSUGITE_LOGO_WIDE
|
|
16
|
+
|
|
17
|
+
MIN_WIDTH_FOR_WIDE_LOGO = 80
|
|
18
|
+
STDIN_ATTACHMENT_NAME = "stdin"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def deduplicate_attachments(attachments: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
|
22
|
+
"""Deduplicate attachments by canonical path and content hash.
|
|
23
|
+
|
|
24
|
+
This prevents the same file from being sent multiple times to the LLM when:
|
|
25
|
+
- Referenced through different paths (symlinks, relative vs absolute)
|
|
26
|
+
- Specified multiple times across different sources (agent, CLI, file refs)
|
|
27
|
+
- Identical content with different names (renamed/moved copies)
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
attachments: List of (name, content) tuples
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Deduplicated list of (name, content) tuples where duplicate files are combined
|
|
34
|
+
with their aliases shown in the name: "file.txt (also: symlink.txt, @other.txt)"
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
>>> deduplicate_attachments([("file.txt", "content"), ("symlink.txt", "content")])
|
|
38
|
+
[("file.txt (also: symlink.txt)", "content")]
|
|
39
|
+
"""
|
|
40
|
+
seen_paths = {} # canonical_path -> {name, content, aliases, order}
|
|
41
|
+
seen_hashes = {} # content_hash -> canonical_path
|
|
42
|
+
order_counter = 0
|
|
43
|
+
|
|
44
|
+
for name, content in attachments:
|
|
45
|
+
# Try to resolve as file path
|
|
46
|
+
canonical = None
|
|
47
|
+
try:
|
|
48
|
+
# Attempt to resolve path (handles symlinks and relative paths)
|
|
49
|
+
path = Path(name)
|
|
50
|
+
if path.exists():
|
|
51
|
+
canonical = str(path.resolve())
|
|
52
|
+
except (OSError, ValueError):
|
|
53
|
+
# Not a valid file path or can't resolve - treat as non-path attachment
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
# If we couldn't resolve to a canonical path, use name as-is for deduplication
|
|
57
|
+
if canonical is None:
|
|
58
|
+
canonical = name
|
|
59
|
+
|
|
60
|
+
# Check if already seen by path
|
|
61
|
+
if canonical in seen_paths:
|
|
62
|
+
# Add this name as an alias
|
|
63
|
+
seen_paths[canonical]["aliases"].append(name)
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
# Check by content hash (catches renamed/moved copies)
|
|
67
|
+
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
|
68
|
+
if content_hash in seen_hashes:
|
|
69
|
+
existing_canonical = seen_hashes[content_hash]
|
|
70
|
+
seen_paths[existing_canonical]["aliases"].append(name)
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# New unique attachment - preserve order of first occurrence
|
|
74
|
+
seen_paths[canonical] = {"name": name, "content": content, "aliases": [], "order": order_counter}
|
|
75
|
+
seen_hashes[content_hash] = canonical
|
|
76
|
+
order_counter += 1
|
|
77
|
+
|
|
78
|
+
# Build result preserving order of first occurrence
|
|
79
|
+
result = []
|
|
80
|
+
for entry in sorted(seen_paths.values(), key=lambda x: x["order"]):
|
|
81
|
+
if entry["aliases"]:
|
|
82
|
+
# Combine name with aliases
|
|
83
|
+
aliases_str = ", ".join(entry["aliases"])
|
|
84
|
+
combined_name = f"{entry['name']} (also: {aliases_str})"
|
|
85
|
+
else:
|
|
86
|
+
combined_name = entry["name"]
|
|
87
|
+
result.append((combined_name, entry["content"]))
|
|
88
|
+
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_logo(console: Console) -> str:
|
|
93
|
+
"""Get appropriate logo based on terminal width."""
|
|
94
|
+
return TSUGITE_LOGO_NARROW if console.width < MIN_WIDTH_FOR_WIDE_LOGO else TSUGITE_LOGO_WIDE
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def print_plain_section(console: Console, title: str, content: str, style: str = "") -> None:
|
|
98
|
+
"""Print a plain text section with simple separators.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
console: Rich console instance
|
|
102
|
+
title: Section title
|
|
103
|
+
content: Section content
|
|
104
|
+
style: Optional Rich style for content (e.g., "cyan", "green")
|
|
105
|
+
"""
|
|
106
|
+
console.print()
|
|
107
|
+
console.rule(title if not style else f"[{style}]{title}[/{style}]", style="dim")
|
|
108
|
+
if style:
|
|
109
|
+
console.print(f"[{style}]{content}[/{style}]")
|
|
110
|
+
else:
|
|
111
|
+
console.print(content)
|
|
112
|
+
console.print()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def print_plain_info(console: Console, title: str, items: dict, style: str = "cyan") -> None:
|
|
116
|
+
"""Print plain text information list.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
console: Rich console instance
|
|
120
|
+
title: Section title
|
|
121
|
+
items: Dict of label: value pairs
|
|
122
|
+
style: Optional Rich style for labels
|
|
123
|
+
"""
|
|
124
|
+
console.print()
|
|
125
|
+
# Use simple header if no_color is enabled (to avoid ANSI codes from rule)
|
|
126
|
+
if console.no_color:
|
|
127
|
+
console.print(title)
|
|
128
|
+
console.print("-" * len(title))
|
|
129
|
+
else:
|
|
130
|
+
console.rule(f"[bold]{title}[/bold]", style="dim")
|
|
131
|
+
for label, value in items.items():
|
|
132
|
+
if style and not console.no_color:
|
|
133
|
+
console.print(f"[{style}]{label}:[/{style}] {value}")
|
|
134
|
+
else:
|
|
135
|
+
console.print(f"{label}: {value}")
|
|
136
|
+
console.print()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve_attachments_with_error_handling(
|
|
140
|
+
attachments: List[str],
|
|
141
|
+
base_dir: Path,
|
|
142
|
+
refresh_cache: bool,
|
|
143
|
+
console: Console,
|
|
144
|
+
error_context: str = "Attachment",
|
|
145
|
+
) -> List[Tuple[str, str]]:
|
|
146
|
+
"""Resolve attachments with error handling.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
attachments: List of attachment names/paths
|
|
150
|
+
base_dir: Base directory for resolving paths
|
|
151
|
+
refresh_cache: Whether to refresh cached content
|
|
152
|
+
console: Console for error messages
|
|
153
|
+
error_context: Context for error message (e.g., "Agent attachment" or "Attachment")
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
List of (name, content) tuples
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
typer.Exit: If attachment resolution fails
|
|
160
|
+
"""
|
|
161
|
+
from tsugite.utils import resolve_attachments
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
return resolve_attachments(attachments, refresh_cache)
|
|
165
|
+
except ValueError as e:
|
|
166
|
+
console.print(f"[red]{error_context} error: {e}[/red]")
|
|
167
|
+
raise typer.Exit(1)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def inject_auto_context_if_enabled(
|
|
171
|
+
agent_attachments: Optional[List[str]],
|
|
172
|
+
agent_auto_context: Optional[bool],
|
|
173
|
+
cli_override: Optional[bool] = None,
|
|
174
|
+
) -> Optional[List[str]]:
|
|
175
|
+
"""Inject auto-context attachment if enabled in config or agent.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
agent_attachments: Current agent attachments list
|
|
179
|
+
agent_auto_context: Agent's auto_context setting (None = use config default)
|
|
180
|
+
cli_override: CLI flag override (None = use precedence, True/False = force)
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Updated attachments list with auto-context prepended if enabled, or original list
|
|
184
|
+
"""
|
|
185
|
+
from tsugite.config import load_config
|
|
186
|
+
|
|
187
|
+
config = load_config()
|
|
188
|
+
|
|
189
|
+
# Determine if auto-context should be enabled
|
|
190
|
+
# Priority: CLI override > agent setting > config default
|
|
191
|
+
if cli_override is not None:
|
|
192
|
+
should_enable = cli_override
|
|
193
|
+
elif agent_auto_context is not None:
|
|
194
|
+
should_enable = agent_auto_context
|
|
195
|
+
else:
|
|
196
|
+
should_enable = config.auto_context_enabled
|
|
197
|
+
|
|
198
|
+
if not should_enable:
|
|
199
|
+
return agent_attachments
|
|
200
|
+
|
|
201
|
+
# Prepend auto-context to attachments list
|
|
202
|
+
attachments = list(agent_attachments) if agent_attachments else []
|
|
203
|
+
|
|
204
|
+
# Only add if not already present
|
|
205
|
+
if "auto-context" not in attachments:
|
|
206
|
+
attachments.insert(0, "auto-context")
|
|
207
|
+
|
|
208
|
+
return attachments
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def assemble_prompt_with_attachments(
|
|
212
|
+
prompt: str,
|
|
213
|
+
agent_attachments: Optional[List[str]],
|
|
214
|
+
cli_attachments: Optional[List[str]],
|
|
215
|
+
base_dir: Path,
|
|
216
|
+
refresh_cache: bool,
|
|
217
|
+
console: Console,
|
|
218
|
+
stdin_attachment: Optional[Tuple[str, str]] = None,
|
|
219
|
+
) -> Tuple[str, List[Tuple[str, str]]]:
|
|
220
|
+
"""Resolve all attachments and file references, returning combined attachment tuples.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
prompt: Base prompt text
|
|
224
|
+
agent_attachments: Attachments from agent definition
|
|
225
|
+
cli_attachments: Attachments from CLI (-f flag)
|
|
226
|
+
base_dir: Base directory for resolving paths
|
|
227
|
+
refresh_cache: Whether to refresh cached content
|
|
228
|
+
console: Console for error messages
|
|
229
|
+
stdin_attachment: Optional stdin content as (name, content) tuple
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Tuple of (updated_prompt, combined_attachment_tuples)
|
|
233
|
+
where attachment_tuples is a list of (name, content) tuples
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
typer.Exit: If attachment or file reference resolution fails
|
|
237
|
+
"""
|
|
238
|
+
from tsugite.utils import expand_file_references
|
|
239
|
+
|
|
240
|
+
# Resolve agent attachments
|
|
241
|
+
agent_attachment_contents = (
|
|
242
|
+
resolve_attachments_with_error_handling(agent_attachments, base_dir, refresh_cache, console, "Agent attachment")
|
|
243
|
+
if agent_attachments
|
|
244
|
+
else []
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Resolve CLI attachments
|
|
248
|
+
cli_attachment_contents = (
|
|
249
|
+
resolve_attachments_with_error_handling(cli_attachments, base_dir, refresh_cache, console, "Attachment")
|
|
250
|
+
if cli_attachments
|
|
251
|
+
else []
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Expand @filename references in prompt (returns tuples now)
|
|
255
|
+
try:
|
|
256
|
+
updated_prompt, file_attachment_tuples = expand_file_references(prompt, base_dir)
|
|
257
|
+
except ValueError as e:
|
|
258
|
+
console.print(f"[red]File reference error: {e}[/red]")
|
|
259
|
+
raise typer.Exit(1)
|
|
260
|
+
|
|
261
|
+
# Combine all attachments in proper order: agent -> CLI -> file refs -> stdin
|
|
262
|
+
all_attachments = agent_attachment_contents + cli_attachment_contents + file_attachment_tuples
|
|
263
|
+
|
|
264
|
+
if stdin_attachment:
|
|
265
|
+
all_attachments.append(stdin_attachment)
|
|
266
|
+
|
|
267
|
+
deduplicated_attachments = deduplicate_attachments(all_attachments)
|
|
268
|
+
|
|
269
|
+
return updated_prompt, deduplicated_attachments
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def load_and_validate_agent(agent_path: str, console: Console) -> Tuple[Any, Path, str]:
|
|
273
|
+
"""Load and validate an agent from path or builtin name.
|
|
274
|
+
|
|
275
|
+
Consolidates agent loading logic used across run, render, and chat commands.
|
|
276
|
+
Handles both package-provided agents (e.g., "+default") and file-based agents.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
agent_path: Path to agent file or agent reference (e.g., "+default", "agent.md")
|
|
280
|
+
console: Console for error messages
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Tuple of (agent_object, agent_file_path, display_name)
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
typer.Exit: If agent cannot be loaded or validated
|
|
287
|
+
|
|
288
|
+
Examples:
|
|
289
|
+
>>> agent, path, name = load_and_validate_agent("+default", console)
|
|
290
|
+
>>> agent, path, name = load_and_validate_agent("agents/my_agent.md", console)
|
|
291
|
+
"""
|
|
292
|
+
from tsugite.agent_composition import resolve_agent_reference
|
|
293
|
+
from tsugite.md_agents import parse_agent_file
|
|
294
|
+
|
|
295
|
+
# Use resolve_agent_reference to handle +name shorthand and builtin agents
|
|
296
|
+
try:
|
|
297
|
+
base_dir = Path.cwd()
|
|
298
|
+
resolved_path = resolve_agent_reference(agent_path, base_dir)
|
|
299
|
+
except ValueError as e:
|
|
300
|
+
console.print(f"[red]{e}[/red]")
|
|
301
|
+
raise typer.Exit(1)
|
|
302
|
+
|
|
303
|
+
# All agents are now file-based (including built-ins)
|
|
304
|
+
agent_file_path = resolved_path
|
|
305
|
+
if not agent_file_path.exists():
|
|
306
|
+
console.print(f"[red]Agent file not found: {agent_file_path}[/red]")
|
|
307
|
+
raise typer.Exit(1)
|
|
308
|
+
|
|
309
|
+
if agent_file_path.suffix != ".md":
|
|
310
|
+
console.print(f"[red]Agent file must be a .md file: {agent_file_path}[/red]")
|
|
311
|
+
raise typer.Exit(1)
|
|
312
|
+
|
|
313
|
+
# Use parse_agent_file to properly resolve inheritance
|
|
314
|
+
agent = parse_agent_file(agent_file_path)
|
|
315
|
+
agent_display_name = agent_file_path.name
|
|
316
|
+
|
|
317
|
+
return agent, agent_file_path, agent_display_name
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _validate_common_option_placement(args: List[str]) -> None:
|
|
321
|
+
"""Check if common CLI options appear in positional args (common user error).
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
args: List of positional arguments from CLI
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
ValueError: If common options are found in positional arguments
|
|
328
|
+
"""
|
|
329
|
+
common_options = [
|
|
330
|
+
"--ui",
|
|
331
|
+
"--model",
|
|
332
|
+
"--verbose",
|
|
333
|
+
"--debug",
|
|
334
|
+
"--final-only",
|
|
335
|
+
"--quiet",
|
|
336
|
+
"--headless",
|
|
337
|
+
"--plain",
|
|
338
|
+
"--stream",
|
|
339
|
+
"--native-ui",
|
|
340
|
+
"--non-interactive",
|
|
341
|
+
"--no-color",
|
|
342
|
+
"--show-reasoning",
|
|
343
|
+
"--no-show-reasoning",
|
|
344
|
+
"--trust-mcp-code",
|
|
345
|
+
"--attachment",
|
|
346
|
+
"-f",
|
|
347
|
+
"--with-agents",
|
|
348
|
+
"--root",
|
|
349
|
+
"--history-dir",
|
|
350
|
+
"--log-json",
|
|
351
|
+
"--dry-run",
|
|
352
|
+
"--refresh-cache",
|
|
353
|
+
"--docker",
|
|
354
|
+
"--keep",
|
|
355
|
+
"--container",
|
|
356
|
+
"--network",
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
misplaced_options = [arg for arg in args if arg in common_options]
|
|
360
|
+
if misplaced_options:
|
|
361
|
+
option_str = ", ".join(misplaced_options)
|
|
362
|
+
raise ValueError(
|
|
363
|
+
f"Options must come before the prompt or agent name.\n"
|
|
364
|
+
f"Found: {option_str}\n\n"
|
|
365
|
+
f"Correct usage:\n"
|
|
366
|
+
f' tsugite run --ui minimal +agent "prompt"\n'
|
|
367
|
+
f' tsugite run +agent "prompt" --ui minimal\n\n'
|
|
368
|
+
f"Incorrect:\n"
|
|
369
|
+
f' tsugite run +agent --ui minimal "prompt"'
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _parse_agent_refs(args: List[str]) -> tuple[List[str], List[str]]:
|
|
374
|
+
"""Parse agent references from arguments.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
args: List of positional arguments from CLI
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Tuple of (agent_refs, remaining_prompt_parts)
|
|
381
|
+
"""
|
|
382
|
+
agents = []
|
|
383
|
+
prompt_parts = []
|
|
384
|
+
|
|
385
|
+
for arg in args:
|
|
386
|
+
has_file_reference = "@" in arg
|
|
387
|
+
has_path_separator = "/" in arg
|
|
388
|
+
has_spaces = " " in arg
|
|
389
|
+
|
|
390
|
+
is_agent = (
|
|
391
|
+
arg.startswith("+") or (arg.endswith(".md") and not has_spaces) or (has_path_separator and not has_spaces)
|
|
392
|
+
) and not has_file_reference
|
|
393
|
+
|
|
394
|
+
if is_agent and not prompt_parts:
|
|
395
|
+
agents.append(arg)
|
|
396
|
+
else:
|
|
397
|
+
prompt_parts.append(arg)
|
|
398
|
+
|
|
399
|
+
return agents, prompt_parts
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _check_stdin_data() -> Optional[tuple[str, str]]:
|
|
403
|
+
"""Check for stdin data and return as attachment if present.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
(STDIN_ATTACHMENT_NAME, content) if stdin has data, None otherwise
|
|
407
|
+
"""
|
|
408
|
+
from tsugite.utils import has_stdin_data, read_stdin
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
if has_stdin_data():
|
|
412
|
+
stdin_content = read_stdin()
|
|
413
|
+
if stdin_content.strip():
|
|
414
|
+
return (STDIN_ATTACHMENT_NAME, stdin_content)
|
|
415
|
+
except (OSError, io.UnsupportedOperation):
|
|
416
|
+
# In test environments or special contexts, stdin may not support fileno()
|
|
417
|
+
pass
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def parse_cli_arguments(
|
|
422
|
+
args: List[str], allow_empty_agents: bool = False, check_stdin: bool = True
|
|
423
|
+
) -> tuple[List[str], str, Optional[tuple[str, str]]]:
|
|
424
|
+
"""Parse CLI arguments into agent references, prompt, and optional stdin.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
args: List of positional arguments from CLI
|
|
428
|
+
allow_empty_agents: If True, allow returning empty agent list (for continuation mode)
|
|
429
|
+
check_stdin: If True, check for stdin data and read it
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Tuple of (agent_refs, prompt, stdin_attachment)
|
|
433
|
+
stdin_attachment is None or ("stdin", content)
|
|
434
|
+
|
|
435
|
+
Examples:
|
|
436
|
+
["+a", "+b", "task"] -> (["+a", "+b"], "task", None)
|
|
437
|
+
["+a", "create", "ticket"] -> (["+a"], "create ticket", None)
|
|
438
|
+
["agent.md", "helper.md", "do", "work"] -> (["agent.md", "helper.md"], "do work", None)
|
|
439
|
+
["task"], allow_empty_agents=True -> ([], "task", None)
|
|
440
|
+
["task"] + stdin data -> (["+default"], "task", ("stdin", "data"))
|
|
441
|
+
"""
|
|
442
|
+
if not args:
|
|
443
|
+
raise ValueError("No arguments provided")
|
|
444
|
+
|
|
445
|
+
_validate_common_option_placement(args)
|
|
446
|
+
|
|
447
|
+
agents, prompt_parts = _parse_agent_refs(args)
|
|
448
|
+
|
|
449
|
+
if not agents:
|
|
450
|
+
if allow_empty_agents:
|
|
451
|
+
agents = []
|
|
452
|
+
prompt = " ".join(args)
|
|
453
|
+
else:
|
|
454
|
+
agents = ["+default"]
|
|
455
|
+
prompt = " ".join(args)
|
|
456
|
+
else:
|
|
457
|
+
prompt = " ".join(prompt_parts)
|
|
458
|
+
|
|
459
|
+
stdin_attachment = None
|
|
460
|
+
if check_stdin and prompt:
|
|
461
|
+
stdin_attachment = _check_stdin_data()
|
|
462
|
+
|
|
463
|
+
return agents, prompt, stdin_attachment
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _validate_and_change_to_root(root: Optional[str], console: Console) -> Optional[str]:
|
|
467
|
+
"""Validate root directory and change to it if provided.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
root: Optional path to root directory
|
|
471
|
+
console: Console for error messages
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
Original working directory path if changed, None otherwise
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
typer.Exit: If root directory doesn't exist
|
|
478
|
+
"""
|
|
479
|
+
if not root:
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
root_path = Path(root)
|
|
483
|
+
if not root_path.exists():
|
|
484
|
+
console.print(f"[red]Working directory not found: {root}[/red]")
|
|
485
|
+
raise typer.Exit(1)
|
|
486
|
+
|
|
487
|
+
original_cwd = os.getcwd()
|
|
488
|
+
os.chdir(str(root_path))
|
|
489
|
+
return original_cwd
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@contextmanager
|
|
493
|
+
def change_to_root_directory(root: Optional[str], console: Console):
|
|
494
|
+
"""Context manager for temporarily changing to a root directory.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
root: Optional path to root directory
|
|
498
|
+
console: Console for error messages
|
|
499
|
+
|
|
500
|
+
Yields:
|
|
501
|
+
None
|
|
502
|
+
|
|
503
|
+
Raises:
|
|
504
|
+
typer.Exit: If root directory doesn't exist
|
|
505
|
+
"""
|
|
506
|
+
original_cwd = _validate_and_change_to_root(root, console)
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
yield
|
|
510
|
+
finally:
|
|
511
|
+
if original_cwd:
|
|
512
|
+
os.chdir(original_cwd)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@contextmanager
|
|
516
|
+
def agent_context(agent_path: str, root: Optional[str], console: Console):
|
|
517
|
+
"""Validate agent path and optionally change working directory."""
|
|
518
|
+
original_cwd = _validate_and_change_to_root(root, console)
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
agent_file = Path(agent_path)
|
|
522
|
+
if not agent_file.exists():
|
|
523
|
+
console.print(f"[red]Agent file not found: {agent_path}[/red]")
|
|
524
|
+
raise typer.Exit(1)
|
|
525
|
+
|
|
526
|
+
if agent_file.suffix != ".md":
|
|
527
|
+
console.print(f"[red]Agent file must be a .md file: {agent_path}[/red]")
|
|
528
|
+
raise typer.Exit(1)
|
|
529
|
+
|
|
530
|
+
yield agent_file.resolve()
|
|
531
|
+
|
|
532
|
+
finally:
|
|
533
|
+
if original_cwd:
|
|
534
|
+
os.chdir(original_cwd)
|