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
@@ -0,0 +1,1042 @@
1
+ """Tsugite CLI application - main entry point."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Optional, Tuple
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.traceback import install
11
+
12
+ from .agents import agents_app
13
+ from .attachments import attachments_app
14
+ from .benchmark import benchmark_command
15
+ from .cache import cache_app
16
+ from .config import config_app
17
+ from .helpers import (
18
+ assemble_prompt_with_attachments,
19
+ change_to_root_directory,
20
+ get_error_console,
21
+ get_logo,
22
+ inject_auto_context_if_enabled,
23
+ load_and_validate_agent,
24
+ print_plain_info,
25
+ )
26
+ from .history import history_app
27
+ from .init import init
28
+ from .mcp import mcp_app
29
+ from .tools import tools_app
30
+
31
+ # Chat history limit - keeps last N turns to balance context retention vs memory usage
32
+ DEFAULT_MAX_CHAT_HISTORY = 50
33
+
34
+ # Install rich traceback handler for better error messages
35
+ # This is after imports to satisfy linting, but still early enough to catch runtime errors
36
+ install(show_locals=False, width=None, word_wrap=True)
37
+
38
+ app = typer.Typer(
39
+ name="tsugite",
40
+ help="Micro-agent runner for task automation using markdown definitions",
41
+ no_args_is_help=True,
42
+ )
43
+
44
+ # Global console for CLI messages (version, help, errors) - uses stdout
45
+ console = Console()
46
+
47
+
48
+ def _build_docker_command(
49
+ args: List[str],
50
+ network: str,
51
+ keep: bool,
52
+ container: Optional[str],
53
+ model: Optional[str],
54
+ with_agents: Optional[str],
55
+ root: Optional[str],
56
+ history_dir: Optional[str],
57
+ ui: Optional[str],
58
+ debug: bool,
59
+ verbose: bool,
60
+ headless: bool,
61
+ plain: bool,
62
+ show_reasoning: bool,
63
+ no_color: bool,
64
+ final_only: bool,
65
+ log_json: bool,
66
+ non_interactive: bool,
67
+ trust_mcp_code: bool,
68
+ attachment: Optional[List[str]],
69
+ refresh_cache: bool,
70
+ ) -> List[str]:
71
+ """Build Docker wrapper command with all flags.
72
+
73
+ Args:
74
+ args: Agent references and prompt
75
+ network: Docker network mode
76
+ keep: Keep container running flag
77
+ container: Existing container name
78
+ model: Model override
79
+ with_agents: Additional agents
80
+ root: Working directory
81
+ history_dir: History directory
82
+ ui: UI mode
83
+ debug: Debug flag
84
+ verbose: Verbose flag
85
+ headless: Headless flag
86
+ plain: Plain output flag
87
+ show_reasoning: Show reasoning flag
88
+ no_color: No color flag
89
+ final_only: Final only flag
90
+ log_json: JSON logging flag
91
+ non_interactive: Non-interactive flag
92
+ trust_mcp_code: Trust MCP code flag
93
+ attachment: Attachment list
94
+ refresh_cache: Refresh cache flag
95
+
96
+ Returns:
97
+ Complete command list for subprocess execution
98
+ """
99
+ cmd = ["tsugite-docker"]
100
+
101
+ if network != "host":
102
+ cmd.extend(["--network", network])
103
+ if keep:
104
+ cmd.append("--keep")
105
+ if container:
106
+ cmd.extend(["--container", container])
107
+
108
+ cmd.append("run")
109
+
110
+ cmd.extend(args)
111
+
112
+ if model:
113
+ cmd.extend(["--model", model])
114
+ if with_agents:
115
+ cmd.extend(["--with-agents", with_agents])
116
+ if root:
117
+ cmd.extend(["--root", str(root)])
118
+ if history_dir:
119
+ cmd.extend(["--history-dir", str(history_dir)])
120
+ if ui:
121
+ cmd.extend(["--ui", ui])
122
+ if debug:
123
+ cmd.append("--debug")
124
+ if verbose:
125
+ cmd.append("--verbose")
126
+ if headless:
127
+ cmd.append("--headless")
128
+ if plain:
129
+ cmd.append("--plain")
130
+ if show_reasoning:
131
+ cmd.append("--show-reasoning")
132
+ if no_color:
133
+ cmd.append("--no-color")
134
+ if final_only:
135
+ cmd.append("--final-only")
136
+ if log_json:
137
+ cmd.append("--log-json")
138
+ if non_interactive:
139
+ cmd.append("--non-interactive")
140
+ if trust_mcp_code:
141
+ cmd.append("--trust-mcp-code")
142
+ if attachment:
143
+ for att in attachment:
144
+ cmd.extend(["--attachment", att])
145
+ if refresh_cache:
146
+ cmd.append("--refresh-cache")
147
+
148
+ return cmd
149
+
150
+
151
+ def _resolve_ui_mode(ui: Optional[str], plain: bool, headless: bool, console: Console) -> tuple[bool, bool, bool]:
152
+ """Resolve UI mode flag to individual UI control flags.
153
+
154
+ Args:
155
+ ui: UI mode string (plain, headless, live)
156
+ plain: Plain output flag
157
+ headless: Headless mode flag
158
+ console: Console for error output
159
+
160
+ Returns:
161
+ Tuple of (plain, headless, live_ui) flags
162
+
163
+ Raises:
164
+ typer.Exit: If invalid UI mode or conflicting flags
165
+ """
166
+ live_ui = False
167
+
168
+ if not ui:
169
+ return plain, headless, live_ui
170
+
171
+ if any([plain, headless]):
172
+ console.print("[red]Error: --ui cannot be used with --plain or --headless[/red]")
173
+ raise typer.Exit(1)
174
+
175
+ ui_modes = {
176
+ "plain": {"plain": True},
177
+ "headless": {"headless": True},
178
+ "live": {"live_ui": True},
179
+ }
180
+
181
+ ui_lower = ui.lower()
182
+ if ui_lower not in ui_modes:
183
+ console.print(f"[red]Error: Invalid UI mode '{ui}'. Choose from: {', '.join(ui_modes.keys())}[/red]")
184
+ raise typer.Exit(1)
185
+
186
+ mode_settings = ui_modes[ui_lower]
187
+ plain = mode_settings.get("plain", plain)
188
+ headless = mode_settings.get("headless", headless)
189
+ live_ui = mode_settings.get("live_ui", live_ui)
190
+
191
+ return plain, headless, live_ui
192
+
193
+
194
+ def _build_executor_kwargs(
195
+ agent_file: Path,
196
+ prompt: str,
197
+ model: Optional[str],
198
+ debug: bool,
199
+ custom_logger: Any,
200
+ trust_mcp_code: bool,
201
+ delegation_agents: Any,
202
+ stream: bool,
203
+ continue_conversation_id: Optional[str],
204
+ resolved_attachments: List[Tuple[str, str]],
205
+ should_save_history: bool,
206
+ executor: Any,
207
+ ) -> Dict[str, Any]:
208
+ """Build executor kwargs dict for run_agent/run_multistep_agent.
209
+
210
+ Args:
211
+ agent_file: Path to agent file
212
+ prompt: User prompt
213
+ model: Model override
214
+ debug: Debug flag
215
+ custom_logger: Logger instance
216
+ trust_mcp_code: Trust MCP code flag
217
+ delegation_agents: Delegation agents
218
+ stream: Stream flag
219
+ continue_conversation_id: Continuation conversation ID
220
+ resolved_attachments: Resolved attachments
221
+ should_save_history: Whether to save history
222
+ executor: Executor function (run_agent or run_multistep_agent)
223
+
224
+ Returns:
225
+ Dict of executor kwargs
226
+ """
227
+ from tsugite.agent_runner import run_agent
228
+
229
+ kwargs = {
230
+ "agent_path": agent_file,
231
+ "prompt": prompt,
232
+ "model_override": model,
233
+ "debug": debug,
234
+ "custom_logger": custom_logger,
235
+ "trust_mcp_code": trust_mcp_code,
236
+ "delegation_agents": delegation_agents,
237
+ "stream": stream,
238
+ "continue_conversation_id": continue_conversation_id,
239
+ "attachments": resolved_attachments,
240
+ }
241
+ if should_save_history and executor == run_agent:
242
+ kwargs["return_token_usage"] = True
243
+ return kwargs
244
+
245
+
246
+ def _handle_docker_execution(
247
+ args: List[str],
248
+ network: str,
249
+ keep: bool,
250
+ container: Optional[str],
251
+ model: Optional[str],
252
+ with_agents: Optional[str],
253
+ root: Optional[str],
254
+ history_dir: Optional[str],
255
+ ui: Optional[str],
256
+ debug: bool,
257
+ verbose: bool,
258
+ headless: bool,
259
+ plain: bool,
260
+ show_reasoning: bool,
261
+ no_color: bool,
262
+ final_only: bool,
263
+ log_json: bool,
264
+ non_interactive: bool,
265
+ trust_mcp_code: bool,
266
+ attachment: Optional[List[str]],
267
+ refresh_cache: bool,
268
+ ) -> None:
269
+ """Handle Docker container execution and exit.
270
+
271
+ Args:
272
+ All run() command parameters
273
+
274
+ Raises:
275
+ typer.Exit: Always exits after Docker execution
276
+ """
277
+ import shutil
278
+ import subprocess
279
+
280
+ wrapper_path = shutil.which("tsugite-docker")
281
+ if not wrapper_path:
282
+ console.print("[red]Error: tsugite-docker wrapper not found in PATH[/red]")
283
+ console.print("[yellow]Install it by adding tsugite/bin/ to your PATH[/yellow]")
284
+ console.print("[dim]See bin/README.md for installation instructions[/dim]")
285
+ raise typer.Exit(1)
286
+
287
+ cmd = _build_docker_command(
288
+ args,
289
+ network,
290
+ keep,
291
+ container,
292
+ model,
293
+ with_agents,
294
+ root,
295
+ history_dir,
296
+ ui,
297
+ debug,
298
+ verbose,
299
+ headless,
300
+ plain,
301
+ show_reasoning,
302
+ no_color,
303
+ final_only,
304
+ log_json,
305
+ non_interactive,
306
+ trust_mcp_code,
307
+ attachment,
308
+ refresh_cache,
309
+ )
310
+ result = subprocess.run(cmd, check=False)
311
+ raise typer.Exit(result.returncode)
312
+
313
+
314
+ def _resolve_conversation_continuation(continue_conversation: bool, conversation_id: Optional[str]) -> Optional[str]:
315
+ """Resolve which conversation to continue.
316
+
317
+ Args:
318
+ continue_conversation: Whether to continue a conversation
319
+ conversation_id: Specific conversation ID or None for latest
320
+
321
+ Returns:
322
+ Conversation ID to continue, or None if not continuing
323
+
324
+ Raises:
325
+ typer.Exit: If no conversations found
326
+ """
327
+ if not continue_conversation:
328
+ return None
329
+
330
+ from tsugite.ui.chat_history import get_latest_conversation
331
+
332
+ if conversation_id:
333
+ console.print(f"[cyan]Continuing conversation: {conversation_id}[/cyan]")
334
+ return conversation_id
335
+
336
+ continue_conversation_id = get_latest_conversation()
337
+ if not continue_conversation_id:
338
+ console.print("[red]No conversations found to resume[/red]")
339
+ raise typer.Exit(1)
340
+
341
+ console.print(f"[cyan]Continuing latest conversation: {continue_conversation_id}[/cyan]")
342
+ return continue_conversation_id
343
+
344
+
345
+ @app.command()
346
+ def run(
347
+ args: List[str] = typer.Argument(
348
+ ..., help="Agent(s) and optional prompt (e.g., +assistant 'task' or +a +b +c 'task' or +a create ticket)"
349
+ ),
350
+ root: Optional[str] = typer.Option(None, "--root", help="Working directory"),
351
+ with_agents: Optional[str] = typer.Option(
352
+ None, "--with-agents", help="Additional agents (comma or space separated)"
353
+ ),
354
+ model: Optional[str] = typer.Option(None, "--model", help="Override agent model"),
355
+ ui: Optional[str] = typer.Option(None, "--ui", help="UI mode: plain, headless, or live (default: minimal)"),
356
+ non_interactive: bool = typer.Option(False, "--non-interactive", help="Run without interactive prompts"),
357
+ history_dir: Optional[str] = typer.Option(None, "--history-dir", help="Directory to store history files"),
358
+ no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colors"),
359
+ log_json: bool = typer.Option(False, "--log-json", help="Machine-readable output"),
360
+ debug: bool = typer.Option(False, "--debug", help="Show rendered prompt before execution"),
361
+ final_only: bool = typer.Option(
362
+ False, "--final-only", "--quiet", help="Output only the final answer (suppress progress)"
363
+ ),
364
+ show_reasoning: bool = typer.Option(
365
+ True, "--show-reasoning/--no-show-reasoning", help="Show LLM reasoning messages (default: enabled)"
366
+ ),
367
+ verbose: bool = typer.Option(False, "--verbose", help="Show all execution details"),
368
+ headless: bool = typer.Option(
369
+ False, "--headless", help="Headless mode for CI/scripts: result to stdout, optional progress to stderr"
370
+ ),
371
+ plain: bool = typer.Option(False, "--plain", help="Plain output without panels/boxes (copy-paste friendly)"),
372
+ stream: bool = typer.Option(False, "--stream", help="Stream LLM responses in real-time"),
373
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show execution plan without running agent"),
374
+ trust_mcp_code: bool = typer.Option(False, "--trust-mcp-code", help="Trust remote code from MCP servers"),
375
+ attachment: Optional[List[str]] = typer.Option(
376
+ None, "-f", "--attachment", help="Attachment(s) to include (repeatable)"
377
+ ),
378
+ refresh_cache: bool = typer.Option(False, "--refresh-cache", help="Force refresh cached attachment content"),
379
+ auto_context: Optional[bool] = typer.Option(
380
+ None,
381
+ "--auto-context/--no-auto-context",
382
+ help="Enable/disable auto-context attachments (overrides config/agent)",
383
+ ),
384
+ docker: bool = typer.Option(False, "--docker", help="Run in Docker container (delegates to tsugite-docker)"),
385
+ keep: bool = typer.Option(False, "--keep", help="Keep Docker container running (use with --docker)"),
386
+ container: Optional[str] = typer.Option(None, "--container", help="Use existing Docker container"),
387
+ network: str = typer.Option("host", "--network", help="Docker network mode (use with --docker)"),
388
+ no_history: bool = typer.Option(False, "--no-history", help="Disable conversation history persistence"),
389
+ continue_conversation: bool = typer.Option(False, "--continue", "-c", help="Continue previous conversation"),
390
+ conversation_id: Optional[str] = typer.Option(
391
+ None, "--conversation-id", help="Specific conversation ID to continue (use with --continue)"
392
+ ),
393
+ subagent_mode: bool = typer.Option(
394
+ False, "--subagent-mode", help="Run as subagent: read JSON from stdin, emit JSONL to stdout"
395
+ ),
396
+ ):
397
+ """Run an agent with the given prompt.
398
+
399
+ Examples:
400
+ tsu run agent.md "prompt"
401
+ tsu run +assistant "prompt"
402
+ tsu run +assistant +jira +coder "prompt"
403
+ tsu run +assistant create a ticket for bug 123
404
+ tsu run +assistant --with-agents "jira,coder" "prompt"
405
+ tsu run --continue "prompt" # Continue latest conversation (auto-detect agent)
406
+ tsu run +assistant --continue "prompt" # Continue latest with specific agent
407
+ tsu run --continue --conversation-id CONV_ID "prompt" # Continue specific conversation
408
+ """
409
+ # Lazy imports - only load heavy dependencies when actually running agents
410
+ from tsugite.agent_runner import get_agent_info, run_agent
411
+ from tsugite.md_agents import validate_agent_execution
412
+ from tsugite.ui import create_live_template_logger, create_plain_logger, custom_agent_ui
413
+ from tsugite.utils import should_use_plain_output
414
+
415
+ if history_dir:
416
+ Path(history_dir).mkdir(parents=True, exist_ok=True)
417
+
418
+ if no_color:
419
+ console.no_color = True
420
+
421
+ # Resolve UI mode to individual flags
422
+ plain, headless, live_ui = _resolve_ui_mode(ui, plain, headless, console)
423
+
424
+ # Handle subagent mode - override incompatible settings
425
+ if subagent_mode:
426
+ import os
427
+
428
+ # Validate no conflicting flags
429
+ if plain or headless or live_ui:
430
+ console.print("[red]Error: --subagent-mode cannot be combined with --plain, --headless, or --live[/red]")
431
+ raise typer.Exit(1)
432
+
433
+ non_interactive = True
434
+ no_history = True
435
+ os.environ["TSUGITE_SUBAGENT_MODE"] = "1"
436
+
437
+ if docker or container:
438
+ _handle_docker_execution(
439
+ args,
440
+ network,
441
+ keep,
442
+ container,
443
+ model,
444
+ with_agents,
445
+ root,
446
+ history_dir,
447
+ ui,
448
+ debug,
449
+ verbose,
450
+ headless,
451
+ plain,
452
+ show_reasoning,
453
+ no_color,
454
+ final_only,
455
+ log_json,
456
+ non_interactive,
457
+ trust_mcp_code,
458
+ attachment,
459
+ refresh_cache,
460
+ )
461
+
462
+ # Handle conversation continuation - check before parsing args
463
+ continue_conversation_id = _resolve_conversation_continuation(continue_conversation, conversation_id)
464
+
465
+ # Parse CLI arguments into agents and prompt (allow empty agents when continuing)
466
+ try:
467
+ from tsugite.cli.helpers import parse_cli_arguments
468
+
469
+ agent_refs, prompt, stdin_attachment = parse_cli_arguments(
470
+ args, allow_empty_agents=continue_conversation, check_stdin=not continue_conversation
471
+ )
472
+ except ValueError as e:
473
+ console.print(f"[red]Error: {e}[/red]")
474
+ raise typer.Exit(1)
475
+
476
+ from tsugite.agent_composition import parse_agent_references
477
+
478
+ with change_to_root_directory(root, console):
479
+ try:
480
+ base_dir = Path.cwd()
481
+
482
+ if not agent_refs and continue_conversation:
483
+ from tsugite.history import get_conversation_metadata
484
+
485
+ metadata = get_conversation_metadata(continue_conversation_id)
486
+ if not metadata:
487
+ console.print(f"[red]Could not load metadata for conversation: {continue_conversation_id}[/red]")
488
+ raise typer.Exit(1)
489
+
490
+ agent_name = metadata.agent
491
+ console.print(f"[cyan]Auto-detected agent from conversation: {agent_name}[/cyan]")
492
+ agent_refs = [f"+{agent_name}"]
493
+
494
+ primary_agent_path, delegation_agents = parse_agent_references(agent_refs, with_agents, base_dir)
495
+
496
+ if not primary_agent_path.exists():
497
+ console.print(f"[red]Agent file not found: {primary_agent_path}[/red]")
498
+ raise typer.Exit(1)
499
+
500
+ if primary_agent_path.suffix != ".md":
501
+ console.print(f"[red]Agent file must be a .md file: {primary_agent_path}[/red]")
502
+ raise typer.Exit(1)
503
+
504
+ agent_file = primary_agent_path.resolve()
505
+
506
+ except ValueError as e:
507
+ console.print(f"[red]Error: {e}[/red]")
508
+ raise typer.Exit(1)
509
+
510
+ use_plain_output = plain or should_use_plain_output()
511
+
512
+ stderr_console = Console(file=sys.stderr, no_color=no_color)
513
+
514
+ agent_info = get_agent_info(agent_file)
515
+ instruction_label = "runtime + agent" if agent_info.get("instructions") else "runtime default"
516
+
517
+ agent_attachments = inject_auto_context_if_enabled(
518
+ agent_info.get("attachments"),
519
+ agent_info.get("auto_context"),
520
+ cli_override=auto_context,
521
+ )
522
+
523
+ prompt, resolved_attachments = assemble_prompt_with_attachments(
524
+ prompt=prompt,
525
+ agent_attachments=agent_attachments,
526
+ cli_attachments=attachment,
527
+ base_dir=base_dir,
528
+ refresh_cache=refresh_cache,
529
+ console=console,
530
+ stdin_attachment=stdin_attachment,
531
+ )
532
+
533
+ if not headless:
534
+ if not use_plain_output:
535
+ stderr_console.print(get_logo(stderr_console), style="cyan")
536
+ stderr_console.print()
537
+
538
+ info_items = {
539
+ "Agent": agent_file.name,
540
+ "Task": prompt,
541
+ "Directory": str(Path.cwd()),
542
+ "Model": model or agent_info.get("model", "unknown"),
543
+ "Instructions": instruction_label,
544
+ "Tools": ", ".join(agent_info.get("tools", [])),
545
+ }
546
+
547
+ if agent_attachments:
548
+ info_items["Agent Attachments"] = ", ".join(agent_attachments)
549
+
550
+ if attachment:
551
+ info_items["CLI Attachments"] = ", ".join(attachment)
552
+
553
+ if resolved_attachments:
554
+ info_items["Attachments"] = f"{len(resolved_attachments)} file(s)"
555
+
556
+ if use_plain_output:
557
+ print_plain_info(stderr_console, "Tsugite Agent Runner", info_items, style="cyan")
558
+
559
+ is_valid, error_msg = validate_agent_execution(agent_file)
560
+ if not is_valid:
561
+ get_error_console(headless, console).print(f"[red]Agent validation failed: {error_msg}[/red]")
562
+ raise typer.Exit(1)
563
+
564
+ from tsugite.agent_runner import preview_multistep_agent, run_multistep_agent
565
+ from tsugite.md_agents import has_step_directives
566
+
567
+ agent_text = agent_file.read_text()
568
+ is_multistep = has_step_directives(agent_text)
569
+
570
+ if dry_run:
571
+ if is_multistep:
572
+ preview_multistep_agent(
573
+ agent_path=agent_file,
574
+ prompt=prompt,
575
+ console=console,
576
+ )
577
+ else:
578
+ console.print("[yellow]Dry-run mode is for multi-step agents only.[/yellow]")
579
+ console.print("[dim]This is a single-step agent. Use --debug to see the rendered prompt.[/dim]")
580
+ return
581
+
582
+ executor = run_multistep_agent if is_multistep else run_agent
583
+
584
+ should_save_history = not no_history
585
+
586
+ if not headless and not final_only:
587
+ execution_type = "multi-step agent" if is_multistep else "agent"
588
+ stderr_console.print()
589
+ stderr_console.rule(f"[bold cyan]🚀 Starting {execution_type.title()} Execution[/bold cyan]")
590
+ stderr_console.print()
591
+
592
+ try:
593
+ # Choose execution mode based on flags
594
+ if headless or final_only:
595
+ # Headless/final-only mode: stderr for progress (if verbose and not final_only), stdout for result
596
+ stderr_console = get_error_console(True, console)
597
+
598
+ # In final_only mode, suppress all progress; in headless, respect verbose flag
599
+ show_progress_items = verbose and not final_only
600
+
601
+ with custom_agent_ui(
602
+ console=stderr_console,
603
+ show_code=show_progress_items,
604
+ show_observations=show_progress_items,
605
+ show_progress=False,
606
+ show_llm_messages=show_progress_items,
607
+ show_execution_results=show_progress_items,
608
+ show_execution_logs=show_progress_items,
609
+ show_panels=False,
610
+ show_debug_messages=verbose,
611
+ ) as custom_logger:
612
+ executor_kwargs = _build_executor_kwargs(
613
+ agent_file,
614
+ prompt,
615
+ model,
616
+ debug,
617
+ custom_logger,
618
+ trust_mcp_code,
619
+ delegation_agents,
620
+ stream,
621
+ continue_conversation_id,
622
+ resolved_attachments,
623
+ should_save_history,
624
+ executor,
625
+ )
626
+ result = executor(**executor_kwargs)
627
+ elif live_ui:
628
+ custom_logger = create_live_template_logger(interactive=not non_interactive)
629
+ with custom_logger.ui_handler.progress_context():
630
+ executor_kwargs = _build_executor_kwargs(
631
+ agent_file,
632
+ prompt,
633
+ model,
634
+ debug,
635
+ custom_logger,
636
+ trust_mcp_code,
637
+ delegation_agents,
638
+ stream,
639
+ continue_conversation_id,
640
+ resolved_attachments,
641
+ should_save_history,
642
+ executor,
643
+ )
644
+ result = executor(**executor_kwargs)
645
+ else:
646
+ if use_plain_output:
647
+ custom_logger = create_plain_logger()
648
+ with custom_logger.ui_handler.progress_context():
649
+ executor_kwargs = _build_executor_kwargs(
650
+ agent_file,
651
+ prompt,
652
+ model,
653
+ debug,
654
+ custom_logger,
655
+ trust_mcp_code,
656
+ delegation_agents,
657
+ stream,
658
+ continue_conversation_id,
659
+ resolved_attachments,
660
+ should_save_history,
661
+ executor,
662
+ )
663
+ result = executor(**executor_kwargs)
664
+ else:
665
+ default_console = Console(file=sys.stderr, force_terminal=True, no_color=no_color)
666
+ with custom_agent_ui(
667
+ console=default_console,
668
+ show_code=not non_interactive,
669
+ show_observations=not non_interactive,
670
+ show_progress=not no_color,
671
+ show_llm_messages=show_reasoning,
672
+ show_execution_results=True,
673
+ show_execution_logs=verbose,
674
+ show_panels=False,
675
+ show_debug_messages=verbose,
676
+ ) as custom_logger:
677
+ executor_kwargs = _build_executor_kwargs(
678
+ agent_file,
679
+ prompt,
680
+ model,
681
+ debug,
682
+ custom_logger,
683
+ trust_mcp_code,
684
+ delegation_agents,
685
+ stream,
686
+ continue_conversation_id,
687
+ resolved_attachments,
688
+ should_save_history,
689
+ executor,
690
+ )
691
+ result = executor(**executor_kwargs)
692
+
693
+ # Unpack result if history was enabled (run_agent returns tuple with metadata)
694
+ if should_save_history and executor == run_agent and isinstance(result, tuple):
695
+ result_str, token_count, cost, step_count, execution_steps, system_prompt, attachments = result
696
+ else:
697
+ result_str = result if not isinstance(result, tuple) else result[0]
698
+ token_count = None
699
+ cost = None
700
+ execution_steps = None
701
+ system_prompt = None
702
+ attachments = None
703
+
704
+ # Save to history (unless disabled)
705
+ if should_save_history:
706
+ try:
707
+ from tsugite.agent_runner.history_integration import save_run_to_history
708
+
709
+ agent_info = get_agent_info(agent_file)
710
+ save_run_to_history(
711
+ agent_path=agent_file,
712
+ agent_name=agent_info["name"],
713
+ prompt=prompt,
714
+ result=result_str,
715
+ model=model or agent_info.get("model", "default"),
716
+ token_count=token_count,
717
+ cost=cost,
718
+ execution_steps=execution_steps,
719
+ continue_conversation_id=continue_conversation_id,
720
+ system_prompt=system_prompt,
721
+ attachments=attachments,
722
+ )
723
+ except Exception as e:
724
+ # Don't fail the run if history save fails
725
+ print(f"Warning: Failed to save to history: {e}", file=sys.stderr)
726
+
727
+ # Display result
728
+ if headless or final_only:
729
+ # Headless/final-only: render markdown to stdout
730
+ from rich.markdown import Markdown
731
+
732
+ from tsugite.console import get_stdout_console
733
+
734
+ get_stdout_console(no_color=no_color, force_terminal=True).print(Markdown(result_str))
735
+ else:
736
+ # Show completion banner to stderr
737
+ stderr_console.print()
738
+ stderr_console.rule("[bold green]Agent Execution Complete[/bold green]")
739
+ # Output final result to stdout (for piping) with markdown rendering
740
+ from rich.markdown import Markdown
741
+
742
+ from tsugite.console import get_stdout_console
743
+
744
+ get_stdout_console(no_color=no_color, force_terminal=True).print(Markdown(result_str))
745
+
746
+ except ValueError as e:
747
+ get_error_console(headless, console).print(f"[red]Configuration error: {e}[/red]")
748
+ raise typer.Exit(1)
749
+ except RuntimeError as e:
750
+ get_error_console(headless, console).print(f"[red]Execution error: {e}[/red]")
751
+ raise typer.Exit(1)
752
+ except KeyboardInterrupt:
753
+ get_error_console(headless, console).print("\n[yellow]Agent execution interrupted by user[/yellow]")
754
+ raise typer.Exit(130)
755
+ except Exception as e:
756
+ err_console = get_error_console(headless, console)
757
+ err_console.print(f"[red]Unexpected error: {e}[/red]")
758
+ if not log_json:
759
+ err_console.print("\n[dim]Use --log-json for machine-readable output[/dim]")
760
+ raise typer.Exit(1)
761
+
762
+
763
+ @app.command()
764
+ def render(
765
+ agent_path: Optional[str] = typer.Argument(
766
+ None, help="Path to agent markdown file or builtin agent name (optional when using --continue)"
767
+ ),
768
+ prompt: Optional[str] = typer.Argument(default="", help="Prompt/task for the agent (optional)"),
769
+ root: Optional[str] = typer.Option(None, "--root", help="Working directory"),
770
+ no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colors"),
771
+ verbose: bool = typer.Option(False, "--verbose", help="Show full attachment content (default: truncated)"),
772
+ raw: bool = typer.Option(False, "--raw", help="Show raw Jinja templates in instructions without rendering"),
773
+ attachment: Optional[List[str]] = typer.Option(
774
+ None, "-f", "--attachment", help="Attachment(s) to include (repeatable)"
775
+ ),
776
+ refresh_cache: bool = typer.Option(False, "--refresh-cache", help="Force refresh cached attachment content"),
777
+ auto_context: Optional[bool] = typer.Option(
778
+ None,
779
+ "--auto-context/--no-auto-context",
780
+ help="Enable/disable auto-context attachments (overrides config/agent)",
781
+ ),
782
+ continue_conversation: bool = typer.Option(
783
+ False, "--continue", "-c", help="Show prompt for continuing conversation"
784
+ ),
785
+ conversation_id: Optional[str] = typer.Option(
786
+ None, "--conversation-id", help="Specific conversation ID (use with --continue)"
787
+ ),
788
+ ):
789
+ """Render an agent template without executing it.
790
+
791
+ Examples:
792
+ tsu render agent.md "prompt"
793
+ tsu render +builtin-default "prompt"
794
+ tsu render builtin-default "prompt"
795
+ tsu render --continue "prompt" # Auto-detects agent
796
+ tsu render agent.md "prompt" --continue
797
+ tsu render --continue --conversation-id CONV_ID "prompt"
798
+ """
799
+ # Lazy imports
800
+ from tsugite.agent_preparation import AgentPreparer
801
+
802
+ if no_color:
803
+ console.no_color = True
804
+
805
+ # Handle conversation continuation
806
+ continue_conversation_id = None
807
+ if continue_conversation:
808
+ from tsugite.ui.chat_history import get_latest_conversation
809
+
810
+ if conversation_id:
811
+ continue_conversation_id = conversation_id
812
+ console.print(f"[cyan]Rendering for conversation: {continue_conversation_id}[/cyan]")
813
+ else:
814
+ continue_conversation_id = get_latest_conversation()
815
+ if not continue_conversation_id:
816
+ console.print("[red]No conversations found[/red]")
817
+ raise typer.Exit(1)
818
+ console.print(f"[cyan]Rendering for latest conversation: {continue_conversation_id}[/cyan]")
819
+
820
+ # Auto-detect agent from conversation if not specified
821
+ if continue_conversation_id and not agent_path:
822
+ from tsugite.history import get_conversation_metadata
823
+
824
+ metadata = get_conversation_metadata(continue_conversation_id)
825
+ if not metadata:
826
+ console.print(f"[red]Could not load metadata for conversation: {continue_conversation_id}[/red]")
827
+ raise typer.Exit(1)
828
+
829
+ agent_path = f"+{metadata.agent}"
830
+ console.print(f"[cyan]Auto-detected agent from conversation: {metadata.agent}[/cyan]")
831
+
832
+ # Validate agent_path is provided
833
+ if not agent_path:
834
+ console.print("[red]Error: AGENT_PATH is required (or use --continue to auto-detect)[/red]")
835
+ raise typer.Exit(1)
836
+
837
+ with change_to_root_directory(root, console):
838
+ try:
839
+ # Load and validate agent (handles both builtin and file-based agents)
840
+ agent, agent_file_path, agent_display_name = load_and_validate_agent(agent_path, console)
841
+
842
+ base_dir = Path.cwd()
843
+
844
+ # Inject auto-context if enabled
845
+ agent_attachments = inject_auto_context_if_enabled(
846
+ agent.config.attachments,
847
+ agent.config.auto_context,
848
+ cli_override=auto_context,
849
+ )
850
+
851
+ prompt_updated, resolved_attachments = assemble_prompt_with_attachments(
852
+ prompt=prompt,
853
+ agent_attachments=agent_attachments,
854
+ cli_attachments=attachment,
855
+ base_dir=base_dir,
856
+ refresh_cache=refresh_cache,
857
+ console=console,
858
+ )
859
+
860
+ # Build context
861
+ # Note: Conversation history is no longer loaded here because chat_history
862
+ # template blocks have been removed from modern agents. History is now
863
+ # handled via previous_messages in agent execution, not template rendering.
864
+ context = {}
865
+ if continue_conversation_id:
866
+ # Enable text_mode when continuing (matches run behavior)
867
+ agent.config.text_mode = True
868
+
869
+ # Prepare agent (all rendering + tool building logic)
870
+ preparer = AgentPreparer()
871
+ prepared = preparer.prepare(
872
+ agent=agent,
873
+ prompt=prompt_updated,
874
+ skip_tool_directives=True, # Render doesn't execute tool directives
875
+ context=context,
876
+ attachments=resolved_attachments, # Pass attachments separately
877
+ )
878
+
879
+ # Display what will be sent to LLM
880
+ console.print(
881
+ Panel(
882
+ f"[cyan]Agent:[/cyan] {agent_display_name}\n"
883
+ f"[cyan]Prompt:[/cyan] {prompt}\n"
884
+ f"[cyan]Directory:[/cyan] {Path.cwd()}",
885
+ title="Tsugite Template Renderer",
886
+ border_style="green",
887
+ )
888
+ )
889
+
890
+ # Show message structure
891
+ console.print()
892
+ console.rule(
893
+ "[bold yellow]MESSAGE STRUCTURE[/bold yellow] [dim](sent to LLM as separate content blocks)[/dim]",
894
+ style="yellow",
895
+ )
896
+
897
+ # Message 1: System (role: system)
898
+ console.print()
899
+ console.rule("[bold cyan]Message 1: System Role[/bold cyan]", style="cyan", align="left")
900
+
901
+ # Content Block 1: System Instructions
902
+ console.print()
903
+ console.rule("[dim]Content Block 1: System Instructions[/dim]", style="dim", align="left")
904
+ console.print(prepared.system_message)
905
+
906
+ # Display attachments if they exist (as additional content blocks in system message)
907
+ if prepared.attachments:
908
+ for idx, (name, content) in enumerate(prepared.attachments, start=2):
909
+ console.print()
910
+ console.rule(
911
+ f"[dim]Content Block {idx}: Attachment - {name}[/dim]",
912
+ style="dim",
913
+ align="left",
914
+ )
915
+ console.print(f"[yellow]<Attachment: {name}>[/yellow]")
916
+
917
+ # Truncate unless verbose
918
+ if verbose:
919
+ console.print(content)
920
+ else:
921
+ # Show first 10 lines and last 5 lines
922
+ lines = content.split("\n")
923
+ if len(lines) > 20:
924
+ preview = "\n".join(lines[:10])
925
+ preview += (
926
+ f"\n[dim]... ({len(lines) - 15} lines truncated, use --verbose to see all) ...[/dim]\n"
927
+ )
928
+ preview += "\n".join(lines[-5:])
929
+ console.print(preview)
930
+ else:
931
+ console.print(content)
932
+
933
+ console.print(f"[yellow]</Attachment: {name}>[/yellow]")
934
+
935
+ # Message 2: User (role: user)
936
+ console.print()
937
+ console.rule("[bold cyan]Message 2: User Role[/bold cyan]", style="cyan", align="left")
938
+ console.print()
939
+ console.rule("[dim]Content: User Task/Prompt[/dim]", style="dim", align="left")
940
+ console.print(prepared.user_message)
941
+ console.print()
942
+ console.rule(style="dim")
943
+
944
+ except Exception as e:
945
+ console.print(f"[red]Render error: {e}[/red]")
946
+ raise typer.Exit(1)
947
+
948
+
949
+ @app.command()
950
+ def version():
951
+ """Show version information."""
952
+ from tsugite import __version__
953
+
954
+ console.print(f"Tsugite version {__version__}")
955
+
956
+
957
+ @app.command()
958
+ def chat(
959
+ agent: Optional[str] = typer.Argument(None, help="Agent name or path (optional, uses default if not provided)"),
960
+ model: Optional[str] = typer.Option(None, "--model", help="Override agent model"),
961
+ max_history: int = typer.Option(DEFAULT_MAX_CHAT_HISTORY, "--max-history", help="Maximum turns to keep in context"),
962
+ stream: bool = typer.Option(False, "--stream", help="Stream LLM responses in real-time"),
963
+ no_history: bool = typer.Option(False, "--no-history", help="Disable conversation history persistence"),
964
+ continue_: Optional[str] = typer.Option(
965
+ None, "--continue", "-c", help="Resume conversation by ID, or latest if no ID given"
966
+ ),
967
+ root: Optional[str] = typer.Option(None, "--root", help="Working directory"),
968
+ ):
969
+ """Start an interactive chat session with an agent."""
970
+ from tsugite.agent_composition import parse_agent_references
971
+ from tsugite.ui.textual_chat import run_textual_chat
972
+
973
+ with change_to_root_directory(root, console):
974
+ # Handle conversation resume
975
+ resume_conversation_id = None
976
+ resume_turns = None
977
+
978
+ if continue_ is not None:
979
+ from tsugite.ui.chat_history import get_latest_conversation, load_conversation_history
980
+
981
+ # Determine conversation ID to resume
982
+ if continue_ == "" or continue_.lower() == "latest":
983
+ resume_conversation_id = get_latest_conversation()
984
+ if not resume_conversation_id:
985
+ console.print("[red]No conversations found to resume[/red]")
986
+ raise typer.Exit(1)
987
+ console.print(f"[cyan]Resuming latest conversation: {resume_conversation_id}[/cyan]")
988
+ else:
989
+ resume_conversation_id = continue_
990
+ console.print(f"[cyan]Resuming conversation: {resume_conversation_id}[/cyan]")
991
+
992
+ # Load conversation history
993
+ try:
994
+ resume_turns = load_conversation_history(resume_conversation_id)
995
+ console.print(f"[cyan]Loaded {len(resume_turns)} previous turns[/cyan]")
996
+ except FileNotFoundError:
997
+ console.print(f"[red]Conversation not found: {resume_conversation_id}[/red]")
998
+ raise typer.Exit(1)
999
+ except Exception as e:
1000
+ console.print(f"[red]Failed to load conversation: {e}[/red]")
1001
+ raise typer.Exit(1)
1002
+
1003
+ # Resolve agent path
1004
+ if agent:
1005
+ # Parse agent reference
1006
+ base_dir = Path.cwd()
1007
+ agent_refs = [agent]
1008
+ primary_agent_path, _ = parse_agent_references(agent_refs, None, base_dir)
1009
+ else:
1010
+ # Use package-provided chat assistant by default
1011
+ # Users can override by creating .tsugite/chat_assistant.md or agents/chat_assistant.md
1012
+ base_dir = Path.cwd()
1013
+ agent_refs = ["chat-assistant"]
1014
+ primary_agent_path, _ = parse_agent_references(agent_refs, None, base_dir)
1015
+
1016
+ # Validate agent file exists
1017
+ if not primary_agent_path.exists():
1018
+ console.print(f"[red]Agent file not found: {primary_agent_path}[/red]")
1019
+ raise typer.Exit(1)
1020
+
1021
+ # Run chat with Textual UI
1022
+ run_textual_chat(
1023
+ agent_path=primary_agent_path,
1024
+ model_override=model,
1025
+ max_history=max_history,
1026
+ stream=stream,
1027
+ disable_history=no_history,
1028
+ resume_conversation_id=resume_conversation_id,
1029
+ resume_turns=resume_turns,
1030
+ )
1031
+
1032
+
1033
+ # Register subcommands from separate modules
1034
+ app.add_typer(mcp_app, name="mcp")
1035
+ app.add_typer(agents_app, name="agents")
1036
+ app.add_typer(config_app, name="config")
1037
+ app.add_typer(attachments_app, name="attachments")
1038
+ app.add_typer(cache_app, name="cache")
1039
+ app.add_typer(tools_app, name="tools")
1040
+ app.add_typer(history_app, name="history")
1041
+ app.command("benchmark")(benchmark_command)
1042
+ app.command("init")(init)