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/utils.py ADDED
@@ -0,0 +1,367 @@
1
+ """Common utilities for Tsugite."""
2
+
3
+ import os
4
+ import re
5
+ import shlex
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Tuple
10
+
11
+ import yaml
12
+
13
+
14
+ def parse_yaml_frontmatter(content: str, label: str = "content") -> Tuple[Dict[str, Any], str]:
15
+ """Parse YAML frontmatter from markdown content.
16
+
17
+ Args:
18
+ content: Markdown content with YAML frontmatter
19
+ label: Description of content type for error messages
20
+
21
+ Returns:
22
+ Tuple of (metadata dict, markdown content)
23
+
24
+ Raises:
25
+ ValueError: If frontmatter is missing or invalid
26
+ """
27
+ if not content.startswith("---"):
28
+ raise ValueError(f"{label} must start with YAML frontmatter")
29
+
30
+ parts = content.split("---", 2)
31
+ if len(parts) < 3:
32
+ raise ValueError(f"Invalid YAML frontmatter format in {label.lower()}")
33
+
34
+ try:
35
+ metadata = yaml.safe_load(parts[1]) or {}
36
+ except yaml.YAMLError as e:
37
+ raise ValueError(f"Invalid YAML frontmatter in {label.lower()}: {e}") from e
38
+
39
+ markdown_content = parts[2].strip()
40
+ return metadata, markdown_content
41
+
42
+
43
+ def standardize_error_message(operation: str, target: str, error: Exception) -> str:
44
+ return f"Failed to {operation} {target}: {error}"
45
+
46
+
47
+ def tool_error(tool_name: str, operation: str, details: str) -> RuntimeError:
48
+ return RuntimeError(f"Tool '{tool_name}' failed to {operation}: {details}")
49
+
50
+
51
+ def validation_error(item_type: str, item_name: str, issue: str) -> ValueError:
52
+ return ValueError(f"Invalid {item_type} '{item_name}': {issue}")
53
+
54
+
55
+ def execute_shell_command(command: str, timeout: int = 30, shell: bool = True) -> str:
56
+ """Execute a shell command and return its output.
57
+
58
+ Args:
59
+ command: Shell command to execute
60
+ timeout: Maximum execution time in seconds
61
+ shell: Whether to use shell execution
62
+
63
+ Returns:
64
+ Command output including stdout, stderr, and exit code
65
+
66
+ Raises:
67
+ RuntimeError: If command execution fails or times out
68
+ """
69
+ try:
70
+ if shell:
71
+ result = subprocess.run(
72
+ command,
73
+ shell=True,
74
+ capture_output=True,
75
+ text=True,
76
+ timeout=timeout,
77
+ check=False,
78
+ )
79
+ else:
80
+ cmd_parts = shlex.split(command)
81
+ result = subprocess.run(
82
+ cmd_parts,
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=timeout,
86
+ check=False,
87
+ )
88
+
89
+ output = ""
90
+ if result.stdout:
91
+ output += result.stdout
92
+ if result.stderr:
93
+ if output:
94
+ output += "\n" + result.stderr
95
+ else:
96
+ output = result.stderr
97
+
98
+ if result.returncode != 0:
99
+ output += f"\n[Exit code: {result.returncode}]"
100
+
101
+ return output or "[No output]"
102
+
103
+ except subprocess.TimeoutExpired as exc:
104
+ raise RuntimeError(f"Command timed out after {timeout} seconds") from exc
105
+ except Exception as e:
106
+ raise RuntimeError(f"Command execution failed: {e}") from e
107
+
108
+
109
+ def is_interactive() -> bool:
110
+ """Check if running in an interactive terminal (TTY).
111
+
112
+ Returns:
113
+ True if running in an interactive terminal, False otherwise
114
+ """
115
+ return sys.stdin.isatty()
116
+
117
+
118
+ def has_stdin_data() -> bool:
119
+ """Check if stdin has data available (pipe or redirect).
120
+
121
+ Returns:
122
+ True if stdin has data, False if interactive terminal or no data
123
+ """
124
+ import select
125
+
126
+ if sys.stdin.isatty():
127
+ return False
128
+
129
+ ready, _, _ = select.select([sys.stdin], [], [], 0.0)
130
+ return bool(ready)
131
+
132
+
133
+ def read_stdin() -> str:
134
+ """Read all data from stdin.
135
+
136
+ Returns:
137
+ Content from stdin as string
138
+ """
139
+ return sys.stdin.read()
140
+
141
+
142
+ def should_use_plain_output() -> bool:
143
+ """Detect if plain output mode should be used (no panels/boxes).
144
+
145
+ Plain output is used when:
146
+ - NO_COLOR environment variable is set
147
+ - stdout is not a TTY (output is piped/redirected)
148
+
149
+ Returns:
150
+ True if plain output should be used, False otherwise
151
+ """
152
+ if os.environ.get("NO_COLOR"):
153
+ return True
154
+
155
+ if not sys.stdout.isatty():
156
+ return True
157
+
158
+ return False
159
+
160
+
161
+ def ensure_file_exists(path: Path, context: str = "File") -> Path:
162
+ """Ensure a file exists and return its resolved path.
163
+
164
+ Args:
165
+ path: Path to validate
166
+ context: Context for error message (e.g., "Agent file", "Config file")
167
+
168
+ Returns:
169
+ Resolved absolute path
170
+
171
+ Raises:
172
+ ValueError: If path doesn't exist or is not a file
173
+ """
174
+ if not path.exists():
175
+ raise ValueError(f"{context} not found: {path}")
176
+ if not path.is_file():
177
+ raise ValueError(f"{context} is not a file: {path}")
178
+ return path.resolve()
179
+
180
+
181
+ def ensure_dir_exists(path: Path, context: str = "Directory") -> Path:
182
+ """Ensure a directory exists and return its resolved path.
183
+
184
+ Args:
185
+ path: Path to validate
186
+ context: Context for error message (e.g., "Working directory", "Cache directory")
187
+
188
+ Returns:
189
+ Resolved absolute path
190
+
191
+ Raises:
192
+ ValueError: If path doesn't exist or is not a directory
193
+ """
194
+ if not path.exists():
195
+ raise ValueError(f"{context} not found: {path}")
196
+ if not path.is_dir():
197
+ raise ValueError(f"{context} is not a directory: {path}")
198
+ return path.resolve()
199
+
200
+
201
+ def resolve_attachments(attachment_refs: List[str], refresh_cache: bool = False) -> List[Tuple[str, str]]:
202
+ """Resolve attachment references to their content using handler system.
203
+
204
+ Args:
205
+ attachment_refs: List of attachment aliases
206
+ refresh_cache: If True, bypass cache and re-fetch content
207
+
208
+ Returns:
209
+ List of (alias, content) tuples
210
+
211
+ Raises:
212
+ ValueError: If an attachment cannot be resolved
213
+ """
214
+ from tsugite.attachments import get_attachment, get_handler
215
+ from tsugite.cache import get_cached_content, save_to_cache
216
+
217
+ resolved = []
218
+
219
+ for ref in attachment_refs:
220
+ # Get attachment from registry
221
+ result = get_attachment(ref)
222
+ handler = None
223
+
224
+ # If not in registry, try to find a handler that can handle it directly
225
+ if result is None:
226
+ try:
227
+ handler = get_handler(ref)
228
+ # Handler found, use ref as source
229
+ source = ref
230
+ content = None
231
+ except ValueError:
232
+ # No handler found either
233
+ raise ValueError(f"Attachment not found: '{ref}'")
234
+ else:
235
+ source, content = result
236
+
237
+ # If inline content, use it directly
238
+ if content is not None:
239
+ resolved.append((ref, content))
240
+ continue
241
+
242
+ # For file/URL references, check cache first
243
+ if not refresh_cache:
244
+ cached = get_cached_content(source)
245
+ if cached:
246
+ resolved.append((ref, cached))
247
+ continue
248
+
249
+ # Fetch content via handler
250
+ try:
251
+ if handler is None:
252
+ handler = get_handler(source)
253
+
254
+ # Check if handler supports multiple attachments (like AutoContextHandler)
255
+ if hasattr(handler, "fetch_multiple"):
256
+ # Fetch multiple attachments and add all to resolved list
257
+ multiple_attachments = handler.fetch_multiple(source)
258
+ for name, content in multiple_attachments:
259
+ # Cache each attachment separately
260
+ cache_key = f"{source}:{name}"
261
+ save_to_cache(cache_key, content)
262
+ resolved.append((name, content))
263
+ else:
264
+ # Single attachment - use normal fetch
265
+ fetched_content = handler.fetch(source)
266
+
267
+ # Save to cache
268
+ save_to_cache(source, fetched_content)
269
+
270
+ resolved.append((ref, fetched_content))
271
+ except Exception as e:
272
+ raise ValueError(f"Failed to fetch attachment '{ref}' from {source}: {e}") from e
273
+
274
+ return resolved
275
+
276
+
277
+ def expand_file_references(prompt: str, base_dir: Path) -> Tuple[str, List[Tuple[str, str]]]:
278
+ """Expand @filename references in prompt by reading file contents.
279
+
280
+ Finds patterns like @filename or @"path with spaces.txt", reads their contents,
281
+ and returns them as attachment tuples. The @filename references in the prompt are
282
+ replaced with just the filename (without @).
283
+
284
+ Args:
285
+ prompt: User prompt potentially containing @filename references
286
+ base_dir: Base directory to resolve relative paths from
287
+
288
+ Returns:
289
+ Tuple of (updated_prompt, list_of_file_attachment_tuples)
290
+ where each tuple is (relative_path, content)
291
+
292
+ Raises:
293
+ ValueError: If a referenced file cannot be read
294
+
295
+ Examples:
296
+ >>> expand_file_references("Analyze @test.py", Path("/tmp"))
297
+ ("Analyze test.py", [("test.py", "code content")])
298
+ """
299
+ # Pattern matches @filename or @"quoted filename"
300
+ # Group 1: quoted path, Group 2: unquoted path
301
+ # Unquoted paths must start with valid filename characters (not special symbols like #, $, etc.)
302
+ pattern = r'@(?:"([^"]+)"|([a-zA-Z0-9_./\-][^\s]*))'
303
+
304
+ file_attachments = []
305
+
306
+ def collect_file_ref(match: re.Match) -> str:
307
+ # Extract filename from either quoted or unquoted group
308
+ filename = match.group(1) or match.group(2)
309
+ file_path = Path(filename)
310
+
311
+ # Resolve relative paths from base_dir
312
+ if not file_path.is_absolute():
313
+ file_path = base_dir / file_path
314
+
315
+ # Check if file exists and is readable
316
+ if not file_path.exists():
317
+ raise ValueError(f"File not found: {filename}")
318
+
319
+ if not file_path.is_file():
320
+ raise ValueError(f"Path is not a file: {filename}")
321
+
322
+ try:
323
+ content = file_path.read_text(encoding="utf-8")
324
+ except UnicodeDecodeError as exc:
325
+ raise ValueError(f"File is not a text file or has encoding issues: {filename}") from exc
326
+ except PermissionError as exc:
327
+ raise ValueError(f"Permission denied reading file: {filename}") from exc
328
+ except Exception as e:
329
+ raise ValueError(f"Failed to read file {filename}: {e}") from e
330
+
331
+ # Store as attachment tuple (relative path, content)
332
+ file_attachments.append((filename, content))
333
+
334
+ # Replace @filename with just the filename in the prompt
335
+ return filename
336
+
337
+ # Replace all @filename references and collect contents as tuples
338
+ updated_prompt = re.sub(pattern, collect_file_ref, prompt)
339
+
340
+ return updated_prompt, file_attachments
341
+
342
+
343
+ async def cleanup_pending_tasks() -> None:
344
+ """Clean up any pending asyncio tasks.
345
+
346
+ This is used to properly clean up background tasks (like LiteLLM's logging tasks)
347
+ before the event loop shuts down, preventing RuntimeWarning about pending tasks.
348
+
349
+ Should be called in finally blocks of async functions that use asyncio.run().
350
+ """
351
+ import asyncio
352
+
353
+ # Get all tasks except the current one
354
+ current_task = asyncio.current_task()
355
+ all_tasks = asyncio.all_tasks()
356
+ pending_tasks = [task for task in all_tasks if task is not current_task and not task.done()]
357
+
358
+ if not pending_tasks:
359
+ return
360
+
361
+ # Cancel all pending tasks
362
+ for task in pending_tasks:
363
+ task.cancel()
364
+
365
+ # Wait for all tasks to be cancelled
366
+ # Use return_exceptions=True to suppress CancelledError
367
+ await asyncio.gather(*pending_tasks, return_exceptions=True)
tsugite/xdg.py ADDED
@@ -0,0 +1,104 @@
1
+ """XDG Base Directory utilities for config file management."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def get_xdg_config_path(filename: str, legacy_dir: bool = True) -> Path:
8
+ """Get XDG-compliant config file path.
9
+
10
+ Checks locations in order of precedence:
11
+ 1. ~/.tsugite/{filename} (if legacy_dir is True)
12
+ 2. $XDG_CONFIG_HOME/tsugite/{filename} (if XDG_CONFIG_HOME is set)
13
+ 3. ~/.config/tsugite/{filename} (XDG default)
14
+
15
+ Returns the first existing file, or the preferred location for new files.
16
+
17
+ Args:
18
+ filename: Name of the config file (e.g., "config.json", "mcp.json")
19
+ legacy_dir: Whether to check ~/.tsugite first for backwards compatibility
20
+
21
+ Returns:
22
+ Path to config file
23
+ """
24
+ if legacy_dir:
25
+ home_tsugite_path = Path.home() / ".tsugite" / filename
26
+ if home_tsugite_path.exists():
27
+ return home_tsugite_path
28
+
29
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
30
+ if xdg_config:
31
+ xdg_path = Path(xdg_config) / "tsugite" / filename
32
+ if xdg_path.exists():
33
+ return xdg_path
34
+
35
+ default_path = Path.home() / ".config" / "tsugite" / filename
36
+ if default_path.exists():
37
+ return default_path
38
+
39
+ if xdg_config:
40
+ return Path(xdg_config) / "tsugite" / filename
41
+ return default_path
42
+
43
+
44
+ def get_xdg_write_path(filename: str, legacy_dir: bool = True) -> Path:
45
+ """Get config path for writing operations.
46
+
47
+ Respects existing config location:
48
+ - If ~/.tsugite/{filename} exists and legacy_dir is True, use it
49
+ - Otherwise, use XDG location ($XDG_CONFIG_HOME or ~/.config)
50
+
51
+ Args:
52
+ filename: Name of the config file
53
+ legacy_dir: Whether to check ~/.tsugite first for backwards compatibility
54
+
55
+ Returns:
56
+ Path where config should be written
57
+ """
58
+ if legacy_dir:
59
+ home_tsugite_path = Path.home() / ".tsugite" / filename
60
+ if home_tsugite_path.exists():
61
+ return home_tsugite_path
62
+
63
+ xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
64
+ return Path(xdg_config) / "tsugite" / filename
65
+
66
+
67
+ def get_xdg_cache_path(subdir: str = "") -> Path:
68
+ """Get XDG-compliant cache directory path.
69
+
70
+ Uses XDG Base Directory specification for cache:
71
+ - $XDG_CACHE_HOME/tsugite/{subdir} (if XDG_CACHE_HOME is set)
72
+ - ~/.cache/tsugite/{subdir} (XDG default)
73
+
74
+ Args:
75
+ subdir: Optional subdirectory within tsugite cache (e.g., "attachments")
76
+
77
+ Returns:
78
+ Path to cache directory
79
+ """
80
+ xdg_cache = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))
81
+ cache_path = Path(xdg_cache) / "tsugite"
82
+ if subdir:
83
+ cache_path = cache_path / subdir
84
+ return cache_path
85
+
86
+
87
+ def get_xdg_data_path(subdir: str = "") -> Path:
88
+ """Get XDG-compliant data directory path.
89
+
90
+ Uses XDG Base Directory specification for data:
91
+ - $XDG_DATA_HOME/tsugite/{subdir} (if XDG_DATA_HOME is set)
92
+ - ~/.local/share/tsugite/{subdir} (XDG default)
93
+
94
+ Args:
95
+ subdir: Optional subdirectory within tsugite data (e.g., "history")
96
+
97
+ Returns:
98
+ Path to data directory
99
+ """
100
+ xdg_data = os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))
101
+ data_path = Path(xdg_data) / "tsugite"
102
+ if subdir:
103
+ data_path = data_path / subdir
104
+ return data_path