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/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
|