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.
Files changed (101) hide show
  1. tsugite/__init__.py +6 -0
  2. tsugite/agent_composition.py +163 -0
  3. tsugite/agent_inheritance.py +479 -0
  4. tsugite/agent_preparation.py +236 -0
  5. tsugite/agent_runner/__init__.py +45 -0
  6. tsugite/agent_runner/helpers.py +106 -0
  7. tsugite/agent_runner/history_integration.py +248 -0
  8. tsugite/agent_runner/metrics.py +100 -0
  9. tsugite/agent_runner/runner.py +1879 -0
  10. tsugite/agent_runner/validation.py +70 -0
  11. tsugite/agent_utils.py +167 -0
  12. tsugite/attachments/__init__.py +65 -0
  13. tsugite/attachments/auto_context.py +199 -0
  14. tsugite/attachments/base.py +34 -0
  15. tsugite/attachments/file.py +51 -0
  16. tsugite/attachments/inline.py +31 -0
  17. tsugite/attachments/storage.py +178 -0
  18. tsugite/attachments/url.py +59 -0
  19. tsugite/attachments/youtube.py +101 -0
  20. tsugite/benchmark/__init__.py +62 -0
  21. tsugite/benchmark/config.py +183 -0
  22. tsugite/benchmark/core.py +292 -0
  23. tsugite/benchmark/discovery.py +377 -0
  24. tsugite/benchmark/evaluators.py +671 -0
  25. tsugite/benchmark/execution.py +657 -0
  26. tsugite/benchmark/metrics.py +204 -0
  27. tsugite/benchmark/reports.py +420 -0
  28. tsugite/benchmark/utils.py +288 -0
  29. tsugite/builtin_agents/chat-assistant.md +53 -0
  30. tsugite/builtin_agents/default.md +140 -0
  31. tsugite/builtin_agents.py +5 -0
  32. tsugite/cache.py +195 -0
  33. tsugite/cli/__init__.py +1042 -0
  34. tsugite/cli/agents.py +148 -0
  35. tsugite/cli/attachments.py +193 -0
  36. tsugite/cli/benchmark.py +663 -0
  37. tsugite/cli/cache.py +113 -0
  38. tsugite/cli/config.py +272 -0
  39. tsugite/cli/helpers.py +534 -0
  40. tsugite/cli/history.py +193 -0
  41. tsugite/cli/init.py +387 -0
  42. tsugite/cli/mcp.py +193 -0
  43. tsugite/cli/tools.py +419 -0
  44. tsugite/config.py +204 -0
  45. tsugite/console.py +48 -0
  46. tsugite/constants.py +21 -0
  47. tsugite/core/__init__.py +19 -0
  48. tsugite/core/agent.py +774 -0
  49. tsugite/core/executor.py +300 -0
  50. tsugite/core/memory.py +67 -0
  51. tsugite/core/tools.py +271 -0
  52. tsugite/docker_cli.py +270 -0
  53. tsugite/events/__init__.py +55 -0
  54. tsugite/events/base.py +46 -0
  55. tsugite/events/bus.py +62 -0
  56. tsugite/events/events.py +224 -0
  57. tsugite/exceptions.py +40 -0
  58. tsugite/history/__init__.py +29 -0
  59. tsugite/history/index.py +210 -0
  60. tsugite/history/models.py +106 -0
  61. tsugite/history/storage.py +157 -0
  62. tsugite/mcp_client.py +219 -0
  63. tsugite/mcp_config.py +174 -0
  64. tsugite/md_agents.py +751 -0
  65. tsugite/models.py +257 -0
  66. tsugite/renderer.py +151 -0
  67. tsugite/shell_tool_config.py +265 -0
  68. tsugite/templates/assistant.md +14 -0
  69. tsugite/tools/__init__.py +265 -0
  70. tsugite/tools/agents.py +312 -0
  71. tsugite/tools/edit_strategies.py +393 -0
  72. tsugite/tools/fs.py +329 -0
  73. tsugite/tools/http.py +239 -0
  74. tsugite/tools/interactive.py +430 -0
  75. tsugite/tools/shell.py +129 -0
  76. tsugite/tools/shell_tools.py +214 -0
  77. tsugite/tools/tasks.py +339 -0
  78. tsugite/tsugite.py +7 -0
  79. tsugite/ui/__init__.py +46 -0
  80. tsugite/ui/base.py +638 -0
  81. tsugite/ui/chat.py +265 -0
  82. tsugite/ui/chat.tcss +92 -0
  83. tsugite/ui/chat_history.py +286 -0
  84. tsugite/ui/helpers.py +102 -0
  85. tsugite/ui/jsonl.py +125 -0
  86. tsugite/ui/live_template.py +529 -0
  87. tsugite/ui/plain.py +419 -0
  88. tsugite/ui/textual_chat.py +642 -0
  89. tsugite/ui/textual_handler.py +225 -0
  90. tsugite/ui/widgets/__init__.py +6 -0
  91. tsugite/ui/widgets/base_scroll_log.py +27 -0
  92. tsugite/ui/widgets/message_list.py +121 -0
  93. tsugite/ui/widgets/thought_log.py +80 -0
  94. tsugite/ui_context.py +90 -0
  95. tsugite/utils.py +367 -0
  96. tsugite/xdg.py +104 -0
  97. tsugite_cli-0.3.3.dist-info/METADATA +325 -0
  98. tsugite_cli-0.3.3.dist-info/RECORD +101 -0
  99. tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
  100. tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
  101. 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)