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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Agent validation and information utilities."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
from tsugite.md_agents import parse_agent_file, validate_agent_execution
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_agent_file(agent_path: Path) -> tuple[bool, str]:
|
|
10
|
+
"""Validate that an agent file can be executed.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
agent_path: Path to agent markdown file (or builtin agent path like <builtin-default>)
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Tuple of (is_valid, error_message)
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
# Parse agent with inheritance resolution
|
|
20
|
+
agent = parse_agent_file(agent_path)
|
|
21
|
+
|
|
22
|
+
# Use centralized validation
|
|
23
|
+
return validate_agent_execution(agent)
|
|
24
|
+
|
|
25
|
+
except Exception as e:
|
|
26
|
+
return False, f"Agent file validation failed: {e}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_agent_info(agent_path: Path) -> Dict[str, Any]:
|
|
30
|
+
"""Get information about an agent without executing it.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
agent_path: Path to agent markdown file (or builtin agent path like <builtin-default>)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Dictionary with agent information
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
# Parse agent with inheritance resolution
|
|
40
|
+
agent = parse_agent_file(agent_path)
|
|
41
|
+
agent_config = agent.config
|
|
42
|
+
|
|
43
|
+
model_display = agent_config.model
|
|
44
|
+
if not model_display:
|
|
45
|
+
from tsugite.config import load_config
|
|
46
|
+
|
|
47
|
+
config = load_config()
|
|
48
|
+
if config.default_model:
|
|
49
|
+
model_display = f"{config.default_model} (default)"
|
|
50
|
+
else:
|
|
51
|
+
model_display = "not set"
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
"name": agent_config.name,
|
|
55
|
+
"description": getattr(agent_config, "description", "No description"),
|
|
56
|
+
"model": model_display,
|
|
57
|
+
"max_turns": agent_config.max_turns,
|
|
58
|
+
"tools": agent_config.tools,
|
|
59
|
+
"prefetch_count": (len(agent_config.prefetch) if agent_config.prefetch else 0),
|
|
60
|
+
"attachments": agent_config.attachments,
|
|
61
|
+
"auto_context": getattr(agent_config, "auto_context", None),
|
|
62
|
+
"permissions_profile": getattr(agent_config, "permissions_profile", None),
|
|
63
|
+
"valid": validate_agent_file(agent_path)[0],
|
|
64
|
+
"instructions": getattr(agent_config, "instructions", ""),
|
|
65
|
+
}
|
|
66
|
+
except Exception as e:
|
|
67
|
+
return {
|
|
68
|
+
"error": str(e),
|
|
69
|
+
"valid": False,
|
|
70
|
+
}
|
tsugite/agent_utils.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Utility functions for agent management."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _parse_agent_from_path(path: Path):
|
|
8
|
+
"""Parse an agent from a file path.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
path: Path to agent file
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
Parsed Agent object
|
|
15
|
+
"""
|
|
16
|
+
from tsugite.md_agents import parse_agent
|
|
17
|
+
|
|
18
|
+
# All agents are now file-based (including built-ins)
|
|
19
|
+
content = path.read_text(encoding="utf-8")
|
|
20
|
+
return parse_agent(content, path)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_valid_agent_file(path: Path) -> bool:
|
|
24
|
+
"""Check if a file is a valid agent file.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
path: Path to check
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
True if file is a valid agent with a name field
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
agent = _parse_agent_from_path(path)
|
|
34
|
+
return bool(agent.config.name)
|
|
35
|
+
except Exception:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_inheritance_chain(agent_path: Path) -> List[Tuple[str, Path]]:
|
|
40
|
+
"""Build the inheritance chain for an agent.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
agent_path: Path to the agent file
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
List of (agent_name, agent_path) tuples in inheritance order (parent to child)
|
|
47
|
+
"""
|
|
48
|
+
from tsugite.agent_inheritance import _get_default_base_agent_name, find_agent_file
|
|
49
|
+
|
|
50
|
+
chain = []
|
|
51
|
+
visited = set()
|
|
52
|
+
|
|
53
|
+
current_path = agent_path.resolve()
|
|
54
|
+
visited.add(current_path)
|
|
55
|
+
|
|
56
|
+
current_agent = _parse_agent_from_path(current_path)
|
|
57
|
+
|
|
58
|
+
if current_agent.config.extends and current_agent.config.extends != "none":
|
|
59
|
+
extends_chain = _get_parent_chain(current_agent.config.extends, current_path, visited.copy())
|
|
60
|
+
chain.extend(extends_chain)
|
|
61
|
+
elif current_agent.config.extends != "none":
|
|
62
|
+
# Auto-inherit from default base agent
|
|
63
|
+
# Priority: 1) Local .tsugite/default.md, 2) Config's default_base_agent
|
|
64
|
+
user_default_path = current_path.parent / ".tsugite" / "default.md"
|
|
65
|
+
default_path = None
|
|
66
|
+
default_name = None
|
|
67
|
+
|
|
68
|
+
if user_default_path.exists() and user_default_path.resolve() != current_path:
|
|
69
|
+
# Use local default.md if it exists
|
|
70
|
+
default_path = user_default_path
|
|
71
|
+
default_name = "default"
|
|
72
|
+
else:
|
|
73
|
+
# Fall back to config's default_base_agent
|
|
74
|
+
default_base_name = _get_default_base_agent_name()
|
|
75
|
+
if default_base_name:
|
|
76
|
+
default_path = find_agent_file(default_base_name, current_path.parent)
|
|
77
|
+
default_name = default_base_name
|
|
78
|
+
|
|
79
|
+
if default_path and default_path.resolve() != current_path:
|
|
80
|
+
chain.append((default_name, default_path))
|
|
81
|
+
visited.add(default_path.resolve())
|
|
82
|
+
|
|
83
|
+
default_agent = _parse_agent_from_path(default_path)
|
|
84
|
+
if default_agent.config.extends and default_agent.config.extends != "none":
|
|
85
|
+
parent_chain = _get_parent_chain(default_agent.config.extends, default_path, visited.copy())
|
|
86
|
+
# Insert at beginning (parents come before children)
|
|
87
|
+
chain = parent_chain + chain
|
|
88
|
+
|
|
89
|
+
chain.append((current_agent.config.name, current_path))
|
|
90
|
+
|
|
91
|
+
return chain
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _get_parent_chain(extends_ref: str, current_path: Path, visited: set) -> List[Tuple[str, Path]]:
|
|
95
|
+
"""Recursively get parent chain.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
extends_ref: Reference to parent agent
|
|
99
|
+
current_path: Current agent path
|
|
100
|
+
visited: Set of already visited paths (for cycle detection)
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of (agent_name, agent_path) tuples
|
|
104
|
+
"""
|
|
105
|
+
from tsugite.agent_inheritance import find_agent_file
|
|
106
|
+
|
|
107
|
+
chain = []
|
|
108
|
+
|
|
109
|
+
parent_path = find_agent_file(extends_ref, current_path.parent)
|
|
110
|
+
if not parent_path:
|
|
111
|
+
return chain
|
|
112
|
+
|
|
113
|
+
parent_resolved = parent_path.resolve()
|
|
114
|
+
if parent_resolved in visited:
|
|
115
|
+
return chain
|
|
116
|
+
|
|
117
|
+
visited.add(parent_resolved)
|
|
118
|
+
|
|
119
|
+
parent_agent = _parse_agent_from_path(parent_path)
|
|
120
|
+
|
|
121
|
+
if parent_agent.config.extends and parent_agent.config.extends != "none":
|
|
122
|
+
grandparent_chain = _get_parent_chain(parent_agent.config.extends, parent_path, visited.copy())
|
|
123
|
+
chain.extend(grandparent_chain)
|
|
124
|
+
|
|
125
|
+
chain.append((parent_agent.config.name, parent_path))
|
|
126
|
+
|
|
127
|
+
return chain
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def list_local_agents(base_path: Path = None) -> dict[str, List[Path]]:
|
|
131
|
+
"""List agents in local directories.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
base_path: Base directory to search from (defaults to cwd)
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dictionary mapping location names to list of agent paths
|
|
138
|
+
"""
|
|
139
|
+
from .agent_inheritance import get_builtin_agents_path
|
|
140
|
+
|
|
141
|
+
if base_path is None:
|
|
142
|
+
base_path = Path.cwd()
|
|
143
|
+
|
|
144
|
+
results = {}
|
|
145
|
+
|
|
146
|
+
# Add built-in agents first
|
|
147
|
+
builtin_path = get_builtin_agents_path()
|
|
148
|
+
if builtin_path.exists() and builtin_path.is_dir():
|
|
149
|
+
builtin_agents = sorted(builtin_path.glob("*.md"))
|
|
150
|
+
if builtin_agents:
|
|
151
|
+
results["Built-in"] = builtin_agents
|
|
152
|
+
|
|
153
|
+
locations = [
|
|
154
|
+
("Current directory", base_path),
|
|
155
|
+
(".tsugite/", base_path / ".tsugite"),
|
|
156
|
+
("agents/", base_path / "agents"),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
for location_name, location_path in locations:
|
|
160
|
+
if location_path.exists() and location_path.is_dir():
|
|
161
|
+
all_md_files = sorted(location_path.glob("*.md"))
|
|
162
|
+
agent_files = [f for f in all_md_files if _is_valid_agent_file(f)]
|
|
163
|
+
|
|
164
|
+
if agent_files:
|
|
165
|
+
results[location_name] = agent_files
|
|
166
|
+
|
|
167
|
+
return results
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Attachment handler system for different content sources."""
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from tsugite.attachments.auto_context import AutoContextHandler
|
|
6
|
+
from tsugite.attachments.base import AttachmentHandler
|
|
7
|
+
from tsugite.attachments.file import FileHandler
|
|
8
|
+
from tsugite.attachments.inline import InlineHandler
|
|
9
|
+
from tsugite.attachments.storage import (
|
|
10
|
+
add_attachment,
|
|
11
|
+
get_attachment,
|
|
12
|
+
get_attachments_path,
|
|
13
|
+
list_attachments,
|
|
14
|
+
remove_attachment,
|
|
15
|
+
search_attachments,
|
|
16
|
+
)
|
|
17
|
+
from tsugite.attachments.url import GenericURLHandler
|
|
18
|
+
from tsugite.attachments.youtube import YouTubeHandler
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
# Handlers
|
|
22
|
+
"AttachmentHandler",
|
|
23
|
+
"InlineHandler",
|
|
24
|
+
"FileHandler",
|
|
25
|
+
"YouTubeHandler",
|
|
26
|
+
"GenericURLHandler",
|
|
27
|
+
"AutoContextHandler",
|
|
28
|
+
"HANDLERS",
|
|
29
|
+
"get_handler",
|
|
30
|
+
# Storage functions
|
|
31
|
+
"add_attachment",
|
|
32
|
+
"get_attachment",
|
|
33
|
+
"get_attachments_path",
|
|
34
|
+
"list_attachments",
|
|
35
|
+
"remove_attachment",
|
|
36
|
+
"search_attachments",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Handler registry - order matters! More specific handlers first
|
|
40
|
+
HANDLERS: List[AttachmentHandler] = [
|
|
41
|
+
InlineHandler(),
|
|
42
|
+
AutoContextHandler(), # Before FileHandler (might match file paths)
|
|
43
|
+
YouTubeHandler(), # Before GenericURLHandler
|
|
44
|
+
FileHandler(),
|
|
45
|
+
GenericURLHandler(), # Catch-all for URLs
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_handler(source: str) -> AttachmentHandler:
|
|
50
|
+
"""Get appropriate handler for a source.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
source: Source string
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Handler that can process this source
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ValueError: If no handler found
|
|
60
|
+
"""
|
|
61
|
+
for handler in HANDLERS:
|
|
62
|
+
if handler.can_handle(source):
|
|
63
|
+
return handler
|
|
64
|
+
|
|
65
|
+
raise ValueError(f"No handler found for source: {source}")
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Auto-context handler for discovering project context files."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from tsugite.attachments.base import AttachmentHandler
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AutoContextHandler(AttachmentHandler):
|
|
11
|
+
"""Handler for auto-discovering context files.
|
|
12
|
+
|
|
13
|
+
Searches from current directory up to git root for specific context files
|
|
14
|
+
like CONTEXT.md, AGENTS.md, CLAUDE.md and concatenates them.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, context_files: Optional[List[str]] = None):
|
|
18
|
+
"""Initialize handler.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
context_files: List of filenames to search for.
|
|
22
|
+
If None, will use config at fetch time.
|
|
23
|
+
"""
|
|
24
|
+
self.context_files = context_files
|
|
25
|
+
|
|
26
|
+
def can_handle(self, source: str) -> bool:
|
|
27
|
+
"""Check if source is auto-context marker.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
source: Source string
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
True if source is "auto-context" or starts with "auto:"
|
|
34
|
+
"""
|
|
35
|
+
return source in ("auto-context", "auto") or source.startswith("auto:")
|
|
36
|
+
|
|
37
|
+
def fetch(self, source: str) -> str:
|
|
38
|
+
"""Not supported. Use fetch_multiple() instead.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
source: Source string
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
NotImplementedError: This method is not supported
|
|
45
|
+
"""
|
|
46
|
+
raise NotImplementedError("AutoContextHandler.fetch() is not supported. Use fetch_multiple() instead.")
|
|
47
|
+
|
|
48
|
+
def fetch_multiple(self, source: str) -> List[tuple[str, str]]:
|
|
49
|
+
"""Discover context files and return as separate attachments.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
source: Auto-context marker
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
List of (attachment_name, content) tuples, one per discovered file
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If discovery fails
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
found_files = self._discover_context_files()
|
|
62
|
+
|
|
63
|
+
result = []
|
|
64
|
+
for file_path, relative_name in found_files:
|
|
65
|
+
try:
|
|
66
|
+
content = file_path.read_text(encoding="utf-8")
|
|
67
|
+
# Use the relative name as the attachment name
|
|
68
|
+
result.append((relative_name, content))
|
|
69
|
+
except Exception as e:
|
|
70
|
+
# Add error as attachment content for visibility
|
|
71
|
+
error_content = f"# Error reading {relative_name}\n\n{str(e)}"
|
|
72
|
+
result.append((relative_name, error_content))
|
|
73
|
+
|
|
74
|
+
return result
|
|
75
|
+
except Exception as e:
|
|
76
|
+
raise ValueError(f"Failed to fetch auto-context: {e}")
|
|
77
|
+
|
|
78
|
+
def _discover_context_files(self) -> List[tuple[Path, str]]:
|
|
79
|
+
"""Discover all context files from project and global locations.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List of (file_path, display_name) tuples for found files
|
|
83
|
+
"""
|
|
84
|
+
# Load context files from config if not set at init
|
|
85
|
+
context_files = self.context_files
|
|
86
|
+
include_global = True
|
|
87
|
+
if context_files is None:
|
|
88
|
+
from tsugite.config import load_config
|
|
89
|
+
|
|
90
|
+
config = load_config()
|
|
91
|
+
context_files = config.auto_context_files
|
|
92
|
+
include_global = config.auto_context_include_global
|
|
93
|
+
|
|
94
|
+
cwd = Path.cwd()
|
|
95
|
+
git_root = self._find_git_root(cwd)
|
|
96
|
+
|
|
97
|
+
# Search from cwd up to git root (or just cwd if not in git repo)
|
|
98
|
+
search_dirs = self._get_search_directories(cwd, git_root)
|
|
99
|
+
|
|
100
|
+
# Find all project context files
|
|
101
|
+
found_files = self._discover_files(search_dirs, context_files)
|
|
102
|
+
|
|
103
|
+
# Add global context file if enabled
|
|
104
|
+
if include_global:
|
|
105
|
+
global_file = self._get_global_context_file()
|
|
106
|
+
if global_file:
|
|
107
|
+
found_files.append(global_file)
|
|
108
|
+
|
|
109
|
+
return found_files
|
|
110
|
+
|
|
111
|
+
def _find_git_root(self, start_dir: Path) -> Optional[Path]:
|
|
112
|
+
"""Find git repository root.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
start_dir: Directory to start searching from
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Path to git root, or None if not in a git repository
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
["git", "rev-parse", "--show-toplevel"], cwd=start_dir, capture_output=True, text=True, check=False
|
|
123
|
+
)
|
|
124
|
+
if result.returncode == 0:
|
|
125
|
+
return Path(result.stdout.strip())
|
|
126
|
+
return None
|
|
127
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def _get_search_directories(self, cwd: Path, git_root: Optional[Path]) -> List[Path]:
|
|
131
|
+
"""Get list of directories to search for context files.
|
|
132
|
+
|
|
133
|
+
Walks from cwd up to git root (inclusive).
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
cwd: Current working directory
|
|
137
|
+
git_root: Git repository root, or None
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
List of directories to search, from most specific to most general
|
|
141
|
+
"""
|
|
142
|
+
dirs = []
|
|
143
|
+
current = cwd
|
|
144
|
+
|
|
145
|
+
while True:
|
|
146
|
+
dirs.append(current)
|
|
147
|
+
|
|
148
|
+
# Stop at git root if we have one
|
|
149
|
+
if git_root and current == git_root:
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
# Stop at filesystem root
|
|
153
|
+
parent = current.parent
|
|
154
|
+
if parent == current:
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
current = parent
|
|
158
|
+
|
|
159
|
+
return dirs
|
|
160
|
+
|
|
161
|
+
def _discover_files(self, search_dirs: List[Path], context_files: List[str]) -> List[tuple[Path, str]]:
|
|
162
|
+
"""Discover context files in search directories.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
search_dirs: Directories to search
|
|
166
|
+
context_files: List of filenames to search for
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
List of (file_path, relative_name) tuples for found files
|
|
170
|
+
"""
|
|
171
|
+
found = []
|
|
172
|
+
seen_names = set()
|
|
173
|
+
|
|
174
|
+
# Search in order (most specific directory first)
|
|
175
|
+
for directory in search_dirs:
|
|
176
|
+
for filename in context_files:
|
|
177
|
+
# Skip if we've already found a file with this name
|
|
178
|
+
if filename in seen_names:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
file_path = directory / filename
|
|
182
|
+
if file_path.exists() and file_path.is_file():
|
|
183
|
+
found.append((file_path, filename))
|
|
184
|
+
seen_names.add(filename)
|
|
185
|
+
|
|
186
|
+
return found
|
|
187
|
+
|
|
188
|
+
def _get_global_context_file(self) -> Optional[tuple[Path, str]]:
|
|
189
|
+
"""Get global context file from user's config directory.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Tuple of (file_path, "Global Context") if file exists, None otherwise
|
|
193
|
+
"""
|
|
194
|
+
from tsugite.xdg import get_xdg_config_path
|
|
195
|
+
|
|
196
|
+
global_context_path = get_xdg_config_path("CONTEXT.md")
|
|
197
|
+
if global_context_path.exists() and global_context_path.is_file():
|
|
198
|
+
return (global_context_path, "Global Context (~/.config/tsugite/CONTEXT.md)")
|
|
199
|
+
return None
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Base class for attachment handlers."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AttachmentHandler(ABC):
|
|
7
|
+
"""Base class for attachment handlers."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def can_handle(self, source: str) -> bool:
|
|
11
|
+
"""Check if this handler can process the source.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
source: Source string (URL, file path, etc.)
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
True if this handler can process the source
|
|
18
|
+
"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def fetch(self, source: str) -> str:
|
|
23
|
+
"""Fetch and return content for this source.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
source: Source string to fetch
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Content as string
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: If fetch fails
|
|
33
|
+
"""
|
|
34
|
+
pass
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""File handler for local file attachments."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from tsugite.attachments.base import AttachmentHandler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileHandler(AttachmentHandler):
|
|
9
|
+
"""Handler for local file references."""
|
|
10
|
+
|
|
11
|
+
def can_handle(self, source: str) -> bool:
|
|
12
|
+
"""Check if source is a file path.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
source: Source string
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
True if source looks like a file path and exists
|
|
19
|
+
"""
|
|
20
|
+
# Don't handle URLs or inline markers
|
|
21
|
+
if source.lower() in ("inline", "text"):
|
|
22
|
+
return False
|
|
23
|
+
if source.startswith("http://") or source.startswith("https://"):
|
|
24
|
+
return False
|
|
25
|
+
if source.startswith("youtube:"):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
# Check if it's a valid file path
|
|
29
|
+
try:
|
|
30
|
+
path = Path(source).expanduser()
|
|
31
|
+
return path.exists() and path.is_file()
|
|
32
|
+
except (OSError, RuntimeError):
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
def fetch(self, source: str) -> str:
|
|
36
|
+
"""Read file content.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
source: File path
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
File content as string
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: If file cannot be read
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
path = Path(source).expanduser()
|
|
49
|
+
return path.read_text(encoding="utf-8")
|
|
50
|
+
except Exception as e:
|
|
51
|
+
raise ValueError(f"Failed to read file '{source}': {e}")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Inline text handler for attachments."""
|
|
2
|
+
|
|
3
|
+
from tsugite.attachments.base import AttachmentHandler
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InlineHandler(AttachmentHandler):
|
|
7
|
+
"""Handler for inline text content."""
|
|
8
|
+
|
|
9
|
+
def can_handle(self, source: str) -> bool:
|
|
10
|
+
"""Check if source is inline text.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
source: Source string
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
True if source is "inline" or "text"
|
|
17
|
+
"""
|
|
18
|
+
return source.lower() in ("inline", "text")
|
|
19
|
+
|
|
20
|
+
def fetch(self, source: str) -> str:
|
|
21
|
+
"""Inline content is stored in attachments JSON, not fetched.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
source: Source string (ignored)
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Empty string (inline content comes from attachments.json)
|
|
28
|
+
"""
|
|
29
|
+
# Inline content is handled specially in resolve_attachments
|
|
30
|
+
# This method should not be called for inline content
|
|
31
|
+
return ""
|