sudosu 0.1.5__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.
- sudosu/__init__.py +3 -0
- sudosu/cli.py +561 -0
- sudosu/commands/__init__.py +15 -0
- sudosu/commands/agent.py +318 -0
- sudosu/commands/config.py +96 -0
- sudosu/commands/init.py +73 -0
- sudosu/commands/integrations.py +563 -0
- sudosu/commands/memory.py +170 -0
- sudosu/commands/onboarding.py +319 -0
- sudosu/commands/tasks.py +635 -0
- sudosu/core/__init__.py +238 -0
- sudosu/core/agent_loader.py +263 -0
- sudosu/core/connection.py +196 -0
- sudosu/core/default_agent.py +541 -0
- sudosu/core/prompt_refiner.py +0 -0
- sudosu/core/safety.py +75 -0
- sudosu/core/session.py +205 -0
- sudosu/tools/__init__.py +373 -0
- sudosu/ui/__init__.py +451 -0
- sudosu-0.1.5.dist-info/METADATA +172 -0
- sudosu-0.1.5.dist-info/RECORD +25 -0
- sudosu-0.1.5.dist-info/WHEEL +5 -0
- sudosu-0.1.5.dist-info/entry_points.txt +2 -0
- sudosu-0.1.5.dist-info/licenses/LICENSE +21 -0
- sudosu-0.1.5.dist-info/top_level.txt +1 -0
sudosu/core/session.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Session management for conversation continuity with SHARED thread model.
|
|
2
|
+
|
|
3
|
+
This module tracks conversation sessions to enable memory continuity
|
|
4
|
+
with the backend. All agents in a session share the SAME thread_id,
|
|
5
|
+
enabling seamless handoffs where sub-agents see the full conversation
|
|
6
|
+
context.
|
|
7
|
+
|
|
8
|
+
Key concepts:
|
|
9
|
+
- session_id: Unique ID for this CLI instance
|
|
10
|
+
- thread_id: SAME as session_id (shared across all agents)
|
|
11
|
+
- active_agent: The agent currently handling the conversation
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ConversationSession:
|
|
22
|
+
"""Tracks a SHARED conversation session across all agents."""
|
|
23
|
+
|
|
24
|
+
session_id: str
|
|
25
|
+
thread_id: str
|
|
26
|
+
created_at: float = field(default_factory=time.time)
|
|
27
|
+
last_activity: float = field(default_factory=time.time)
|
|
28
|
+
message_count: int = 0
|
|
29
|
+
active_agent: str = "sudosu"
|
|
30
|
+
is_routed: bool = False
|
|
31
|
+
|
|
32
|
+
def touch(self):
|
|
33
|
+
"""Update last activity and increment message count."""
|
|
34
|
+
self.last_activity = time.time()
|
|
35
|
+
self.message_count += 1
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def duration_seconds(self) -> float:
|
|
39
|
+
"""Get the duration of this conversation in seconds."""
|
|
40
|
+
return self.last_activity - self.created_at
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_active(self) -> bool:
|
|
44
|
+
"""Check if conversation has been active recently (within 30 minutes)."""
|
|
45
|
+
return (time.time() - self.last_activity) < 1800
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SessionManager:
|
|
49
|
+
"""Manages conversation sessions with SHARED thread model.
|
|
50
|
+
|
|
51
|
+
Key concepts:
|
|
52
|
+
- session_id: Unique ID for this CLI instance
|
|
53
|
+
- thread_id: SAME as session_id (shared across all agents)
|
|
54
|
+
- active_agent: The agent currently handling the conversation
|
|
55
|
+
|
|
56
|
+
All agents share the same conversation thread, enabling seamless
|
|
57
|
+
handoffs where sub-agents see the full context of what was discussed
|
|
58
|
+
with the orchestrator.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self):
|
|
62
|
+
"""Initialize session manager with unique session ID."""
|
|
63
|
+
# Unique ID for this CLI session
|
|
64
|
+
self.session_id = str(uuid.uuid4())
|
|
65
|
+
# SHARED THREAD: Single thread for the entire session
|
|
66
|
+
self.thread_id = self.session_id
|
|
67
|
+
# Track which agent is currently active
|
|
68
|
+
self.active_agent: str = "sudosu" # Default to orchestrator
|
|
69
|
+
# Track if we're in a routed conversation
|
|
70
|
+
self.is_routed: bool = False
|
|
71
|
+
# Message count for the shared conversation
|
|
72
|
+
self.message_count: int = 0
|
|
73
|
+
# Session creation time
|
|
74
|
+
self.created_at: float = time.time()
|
|
75
|
+
# Last activity time
|
|
76
|
+
self.last_activity: float = time.time()
|
|
77
|
+
|
|
78
|
+
def get_thread_id(self) -> str:
|
|
79
|
+
"""Get the shared thread ID for this session.
|
|
80
|
+
|
|
81
|
+
All agents use the same thread_id to share conversation history.
|
|
82
|
+
"""
|
|
83
|
+
return self.thread_id
|
|
84
|
+
|
|
85
|
+
def set_active_agent(self, agent_name: str, via_routing: bool = False):
|
|
86
|
+
"""Set the currently active agent.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
agent_name: Name of the agent now handling the conversation
|
|
90
|
+
via_routing: True if this was set via routing from sudosu
|
|
91
|
+
"""
|
|
92
|
+
self.active_agent = agent_name
|
|
93
|
+
self.is_routed = via_routing
|
|
94
|
+
|
|
95
|
+
def get_active_agent(self) -> str:
|
|
96
|
+
"""Get the currently active agent."""
|
|
97
|
+
return self.active_agent or "sudosu"
|
|
98
|
+
|
|
99
|
+
def reset_to_orchestrator(self):
|
|
100
|
+
"""Reset back to the default sudosu orchestrator."""
|
|
101
|
+
self.active_agent = "sudosu"
|
|
102
|
+
self.is_routed = False
|
|
103
|
+
|
|
104
|
+
def increment_message_count(self):
|
|
105
|
+
"""Increment the shared message count and update activity."""
|
|
106
|
+
self.message_count += 1
|
|
107
|
+
self.last_activity = time.time()
|
|
108
|
+
|
|
109
|
+
def clear_session(self) -> str:
|
|
110
|
+
"""Clear the session by generating a new thread_id.
|
|
111
|
+
|
|
112
|
+
This starts a fresh conversation for all agents while keeping
|
|
113
|
+
the same session_id.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
New thread_id for the fresh conversation
|
|
117
|
+
"""
|
|
118
|
+
# Create new thread with unique suffix to start fresh
|
|
119
|
+
self.thread_id = f"{self.session_id}:{uuid.uuid4().hex[:8]}"
|
|
120
|
+
self.message_count = 0
|
|
121
|
+
self.active_agent = "sudosu"
|
|
122
|
+
self.is_routed = False
|
|
123
|
+
self.last_activity = time.time()
|
|
124
|
+
return self.thread_id
|
|
125
|
+
|
|
126
|
+
def get_stats(self) -> dict:
|
|
127
|
+
"""Get statistics about current session.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Dict with session statistics
|
|
131
|
+
"""
|
|
132
|
+
duration = time.time() - self.created_at
|
|
133
|
+
return {
|
|
134
|
+
"session_id": self.session_id,
|
|
135
|
+
"thread_id": self.thread_id,
|
|
136
|
+
"active_agent": self.active_agent,
|
|
137
|
+
"is_routed": self.is_routed,
|
|
138
|
+
"message_count": self.message_count,
|
|
139
|
+
"duration_seconds": duration,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Legacy compatibility methods
|
|
143
|
+
def get_or_create_conversation(self, agent_name: str) -> "SessionManager":
|
|
144
|
+
"""Legacy method - returns self since we use shared thread.
|
|
145
|
+
|
|
146
|
+
This maintains compatibility with existing code that expects
|
|
147
|
+
a conversation object.
|
|
148
|
+
"""
|
|
149
|
+
# Update active agent tracking
|
|
150
|
+
self.set_active_agent(agent_name)
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def clear_conversation(self, agent_name: str) -> Optional[str]:
|
|
154
|
+
"""Clear the shared conversation.
|
|
155
|
+
|
|
156
|
+
Since all agents share the same thread, this clears everything.
|
|
157
|
+
The agent_name is kept for backward compatibility.
|
|
158
|
+
"""
|
|
159
|
+
return self.clear_session()
|
|
160
|
+
|
|
161
|
+
def clear_all_conversations(self) -> int:
|
|
162
|
+
"""Clear the shared conversation.
|
|
163
|
+
|
|
164
|
+
Returns 1 since there's only one shared conversation.
|
|
165
|
+
"""
|
|
166
|
+
self.clear_session()
|
|
167
|
+
return 1
|
|
168
|
+
|
|
169
|
+
def get_active_conversation(self) -> Optional["SessionManager"]:
|
|
170
|
+
"""Get the current session (self) for compatibility."""
|
|
171
|
+
return self if self.message_count > 0 else None
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def conversations(self) -> dict:
|
|
175
|
+
"""Legacy property - returns dict with self if active."""
|
|
176
|
+
if self.message_count > 0:
|
|
177
|
+
return {self.active_agent: self}
|
|
178
|
+
return {}
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def agent_name(self) -> str:
|
|
182
|
+
"""Legacy property for compatibility."""
|
|
183
|
+
return self.active_agent
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Global session manager instance
|
|
187
|
+
_session_manager: Optional[SessionManager] = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_session_manager() -> SessionManager:
|
|
191
|
+
"""Get or create the global session manager.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
The global SessionManager instance
|
|
195
|
+
"""
|
|
196
|
+
global _session_manager
|
|
197
|
+
if _session_manager is None:
|
|
198
|
+
_session_manager = SessionManager()
|
|
199
|
+
return _session_manager
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def reset_session_manager():
|
|
203
|
+
"""Reset the global session manager (useful for testing)."""
|
|
204
|
+
global _session_manager
|
|
205
|
+
_session_manager = None
|
sudosu/tools/__init__.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Local tool execution for Sudosu client."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Special routing result marker
|
|
11
|
+
ROUTING_MARKER = "_sudosu_routing"
|
|
12
|
+
|
|
13
|
+
# Special consultation routing marker
|
|
14
|
+
CONSULTATION_ROUTING_MARKER = "_sudosu_consultation_route"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def execute_tool(tool_name: str, args: dict, cwd: str) -> dict:
|
|
18
|
+
"""
|
|
19
|
+
Execute a tool locally and return the result.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
tool_name: Name of the tool to execute
|
|
23
|
+
args: Tool arguments
|
|
24
|
+
cwd: Current working directory
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Tool execution result
|
|
28
|
+
"""
|
|
29
|
+
executors = {
|
|
30
|
+
"write_file": tool_write_file,
|
|
31
|
+
"read_file": tool_read_file,
|
|
32
|
+
"list_directory": tool_list_directory,
|
|
33
|
+
"run_command": tool_run_command,
|
|
34
|
+
"search_files": tool_search_files,
|
|
35
|
+
"route_to_agent": tool_route_to_agent,
|
|
36
|
+
"consult_orchestrator": tool_consult_orchestrator,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
executor = executors.get(tool_name)
|
|
40
|
+
if not executor:
|
|
41
|
+
return {"error": f"Unknown tool: {tool_name}"}
|
|
42
|
+
|
|
43
|
+
return await executor(args, cwd)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def tool_consult_orchestrator(args: dict, cwd: str) -> dict: # noqa: ARG001
|
|
47
|
+
"""
|
|
48
|
+
Handle consultation with the orchestrator.
|
|
49
|
+
|
|
50
|
+
Note: This is a stub - actual consultation is handled by the backend.
|
|
51
|
+
The backend intercepts this tool call and evaluates the consultation
|
|
52
|
+
before returning a decision.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
args: {"situation": str, "user_request": str}
|
|
56
|
+
cwd: Current working directory (unused)
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Consultation result (handled by backend)
|
|
60
|
+
"""
|
|
61
|
+
# This should not be reached - backend handles this tool
|
|
62
|
+
return {
|
|
63
|
+
"output": "Consultation handled by backend",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def tool_route_to_agent(args: dict, cwd: str) -> dict: # noqa: ARG001
|
|
68
|
+
"""
|
|
69
|
+
Handle routing to another agent.
|
|
70
|
+
|
|
71
|
+
This returns a special marker that the CLI intercepts to perform
|
|
72
|
+
the actual agent handoff.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
args: {"agent_name": str, "message": str}
|
|
76
|
+
cwd: Current working directory (unused for routing)
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Special routing result with marker
|
|
80
|
+
"""
|
|
81
|
+
agent_name = args.get("agent_name")
|
|
82
|
+
message = args.get("message", "")
|
|
83
|
+
|
|
84
|
+
if not agent_name:
|
|
85
|
+
return {"error": "Missing 'agent_name' argument"}
|
|
86
|
+
|
|
87
|
+
# Return special routing marker that CLI will intercept
|
|
88
|
+
# The output message must clearly indicate completion to prevent
|
|
89
|
+
# the LLM from calling route_to_agent again in a loop
|
|
90
|
+
return {
|
|
91
|
+
ROUTING_MARKER: True,
|
|
92
|
+
"agent_name": agent_name,
|
|
93
|
+
"message": message,
|
|
94
|
+
"output": f"SUCCESS: Request has been routed to @{agent_name}. The handoff is complete. Do not call route_to_agent again. Simply confirm the routing to the user and stop.",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _validate_path(path: str, cwd: str) -> tuple[bool, str, Path]:
|
|
99
|
+
"""
|
|
100
|
+
Validate that a path is within the allowed directory.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Tuple of (is_valid, error_message, resolved_path)
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
cwd_path = Path(cwd).resolve()
|
|
107
|
+
full_path = (cwd_path / path).resolve()
|
|
108
|
+
|
|
109
|
+
# Check if path is within cwd
|
|
110
|
+
full_path.relative_to(cwd_path)
|
|
111
|
+
|
|
112
|
+
return True, "", full_path
|
|
113
|
+
except ValueError:
|
|
114
|
+
return False, "Path must be within current directory", Path()
|
|
115
|
+
except Exception as e:
|
|
116
|
+
return False, str(e), Path()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def tool_write_file(args: dict, cwd: str) -> dict:
|
|
120
|
+
"""
|
|
121
|
+
Write content to a file.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
args: {"file_path": str or "path": str, "content": str}
|
|
125
|
+
cwd: Current working directory
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Result dict with success status
|
|
129
|
+
"""
|
|
130
|
+
# Support both file_path (backend) and path (legacy) naming
|
|
131
|
+
path = args.get("file_path") or args.get("path")
|
|
132
|
+
content = args.get("content")
|
|
133
|
+
|
|
134
|
+
if not path:
|
|
135
|
+
return {"error": "Missing 'path' argument"}
|
|
136
|
+
if content is None:
|
|
137
|
+
return {"error": "Missing 'content' argument"}
|
|
138
|
+
|
|
139
|
+
# Validate path
|
|
140
|
+
is_valid, error, full_path = _validate_path(path, cwd)
|
|
141
|
+
if not is_valid:
|
|
142
|
+
return {"error": error}
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
# Create parent directories if needed
|
|
146
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
|
|
148
|
+
# Write file
|
|
149
|
+
with open(full_path, "w", encoding="utf-8") as f:
|
|
150
|
+
f.write(content)
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
"success": True,
|
|
154
|
+
"path": str(full_path),
|
|
155
|
+
"message": f"File written successfully: {path}",
|
|
156
|
+
"output": f"File written successfully: {path}",
|
|
157
|
+
}
|
|
158
|
+
except PermissionError:
|
|
159
|
+
return {"error": f"Permission denied: {path}", "output": f"Error: Permission denied: {path}"}
|
|
160
|
+
except Exception as e:
|
|
161
|
+
return {"error": f"Failed to write file: {e}", "output": f"Error: Failed to write file: {e}"}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def tool_read_file(args: dict, cwd: str) -> dict:
|
|
165
|
+
"""
|
|
166
|
+
Read content from a file.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
args: {"file_path": str or "path": str}
|
|
170
|
+
cwd: Current working directory
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Result dict with file content
|
|
174
|
+
"""
|
|
175
|
+
# Support both file_path (backend) and path (legacy) naming
|
|
176
|
+
path = args.get("file_path") or args.get("path")
|
|
177
|
+
|
|
178
|
+
if not path:
|
|
179
|
+
return {"error": "Missing 'path' argument"}
|
|
180
|
+
|
|
181
|
+
# Validate path
|
|
182
|
+
is_valid, error, full_path = _validate_path(path, cwd)
|
|
183
|
+
if not is_valid:
|
|
184
|
+
return {"error": error}
|
|
185
|
+
|
|
186
|
+
if not full_path.exists():
|
|
187
|
+
return {"error": f"File not found: {path}"}
|
|
188
|
+
|
|
189
|
+
if not full_path.is_file():
|
|
190
|
+
return {"error": f"Not a file: {path}"}
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
with open(full_path, "r", encoding="utf-8") as f:
|
|
194
|
+
content = f.read()
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"success": True,
|
|
198
|
+
"content": content,
|
|
199
|
+
"path": str(full_path),
|
|
200
|
+
"output": content,
|
|
201
|
+
}
|
|
202
|
+
except PermissionError:
|
|
203
|
+
return {"error": f"Permission denied: {path}", "output": f"Error: Permission denied: {path}"}
|
|
204
|
+
except UnicodeDecodeError:
|
|
205
|
+
return {"error": f"Cannot read file (binary?): {path}", "output": f"Error: Cannot read file (binary?): {path}"}
|
|
206
|
+
except Exception as e:
|
|
207
|
+
return {"error": f"Failed to read file: {e}", "output": f"Error: Failed to read file: {e}"}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def tool_list_directory(args: dict, cwd: str) -> dict:
|
|
211
|
+
"""
|
|
212
|
+
List directory contents.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
args: {"directory_path": str or "path": str} (optional, defaults to ".")
|
|
216
|
+
cwd: Current working directory
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Result dict with directory listing
|
|
220
|
+
"""
|
|
221
|
+
# Support both directory_path (backend) and path (legacy) naming
|
|
222
|
+
path = args.get("directory_path") or args.get("path", ".")
|
|
223
|
+
|
|
224
|
+
# Validate path
|
|
225
|
+
is_valid, error, full_path = _validate_path(path, cwd)
|
|
226
|
+
if not is_valid:
|
|
227
|
+
return {"error": error}
|
|
228
|
+
|
|
229
|
+
if not full_path.exists():
|
|
230
|
+
return {"error": f"Directory not found: {path}"}
|
|
231
|
+
|
|
232
|
+
if not full_path.is_dir():
|
|
233
|
+
return {"error": f"Not a directory: {path}"}
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
items = []
|
|
237
|
+
for item in sorted(full_path.iterdir()):
|
|
238
|
+
items.append({
|
|
239
|
+
"name": item.name,
|
|
240
|
+
"type": "dir" if item.is_dir() else "file",
|
|
241
|
+
"size": item.stat().st_size if item.is_file() else None,
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
# Format output as a listing
|
|
245
|
+
output_lines = []
|
|
246
|
+
for item in items:
|
|
247
|
+
prefix = "[DIR]" if item["type"] == "dir" else "[FILE]"
|
|
248
|
+
output_lines.append(f"{prefix} {item['name']}")
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"success": True,
|
|
252
|
+
"path": str(full_path),
|
|
253
|
+
"items": items,
|
|
254
|
+
"output": "\n".join(output_lines) if output_lines else "Empty directory",
|
|
255
|
+
}
|
|
256
|
+
except PermissionError:
|
|
257
|
+
return {"error": f"Permission denied: {path}", "output": f"Error: Permission denied: {path}"}
|
|
258
|
+
except Exception as e:
|
|
259
|
+
return {"error": f"Failed to list directory: {e}", "output": f"Error: Failed to list directory: {e}"}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def tool_run_command(args: dict, cwd: str) -> dict:
|
|
263
|
+
"""
|
|
264
|
+
Run a shell command (with restrictions).
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
args: {"command": str}
|
|
268
|
+
cwd: Current working directory
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Result dict with command output
|
|
272
|
+
"""
|
|
273
|
+
command = args.get("command")
|
|
274
|
+
|
|
275
|
+
if not command:
|
|
276
|
+
return {"error": "Missing 'command' argument"}
|
|
277
|
+
|
|
278
|
+
# Restricted commands for safety
|
|
279
|
+
blocked_patterns = [
|
|
280
|
+
"rm -rf /",
|
|
281
|
+
"sudo",
|
|
282
|
+
"> /dev/",
|
|
283
|
+
"mkfs",
|
|
284
|
+
"dd if=",
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
for pattern in blocked_patterns:
|
|
288
|
+
if pattern in command:
|
|
289
|
+
return {
|
|
290
|
+
"error": f"Command blocked for safety: contains '{pattern}'",
|
|
291
|
+
"output": f"Error: Command blocked for safety: contains '{pattern}'",
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
result = subprocess.run(
|
|
296
|
+
command,
|
|
297
|
+
shell=True,
|
|
298
|
+
capture_output=True,
|
|
299
|
+
text=True,
|
|
300
|
+
timeout=60,
|
|
301
|
+
cwd=cwd,
|
|
302
|
+
check=False,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Combine stdout and stderr for output
|
|
306
|
+
output = result.stdout
|
|
307
|
+
if result.stderr:
|
|
308
|
+
output += f"\n[stderr]: {result.stderr}"
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
"success": result.returncode == 0,
|
|
312
|
+
"stdout": result.stdout,
|
|
313
|
+
"stderr": result.stderr,
|
|
314
|
+
"returncode": result.returncode,
|
|
315
|
+
"output": output.strip() if output else "(no output)",
|
|
316
|
+
}
|
|
317
|
+
except subprocess.TimeoutExpired:
|
|
318
|
+
return {"error": "Command timed out (60s limit)", "output": "Error: Command timed out (60s limit)"}
|
|
319
|
+
except Exception as e:
|
|
320
|
+
return {"error": f"Failed to run command: {e}", "output": f"Error: Failed to run command: {e}"}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def tool_search_files(args: dict, cwd: str) -> dict:
|
|
324
|
+
"""
|
|
325
|
+
Search for files matching a pattern.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
args: {"pattern": str, "directory": str (optional)}
|
|
329
|
+
cwd: Current working directory
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Result dict with matching files
|
|
333
|
+
"""
|
|
334
|
+
pattern = args.get("pattern")
|
|
335
|
+
directory = args.get("directory", ".")
|
|
336
|
+
|
|
337
|
+
if not pattern:
|
|
338
|
+
return {"error": "Missing 'pattern' argument"}
|
|
339
|
+
|
|
340
|
+
# Validate path
|
|
341
|
+
is_valid, error, full_path = _validate_path(directory, cwd)
|
|
342
|
+
if not is_valid:
|
|
343
|
+
return {"error": error}
|
|
344
|
+
|
|
345
|
+
if not full_path.exists():
|
|
346
|
+
return {"error": f"Directory not found: {directory}"}
|
|
347
|
+
|
|
348
|
+
if not full_path.is_dir():
|
|
349
|
+
return {"error": f"Not a directory: {directory}"}
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
matches = []
|
|
353
|
+
# Use glob for ** patterns, fnmatch for simple patterns
|
|
354
|
+
if "**" in pattern:
|
|
355
|
+
for match in full_path.glob(pattern):
|
|
356
|
+
matches.append(str(match.relative_to(full_path)))
|
|
357
|
+
else:
|
|
358
|
+
for root, dirs, files in os.walk(full_path):
|
|
359
|
+
for filename in files:
|
|
360
|
+
if fnmatch.fnmatch(filename, pattern):
|
|
361
|
+
rel_path = os.path.relpath(os.path.join(root, filename), full_path)
|
|
362
|
+
matches.append(rel_path)
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
"success": True,
|
|
366
|
+
"matches": matches,
|
|
367
|
+
"count": len(matches),
|
|
368
|
+
"output": "\n".join(matches) if matches else "No matches found",
|
|
369
|
+
}
|
|
370
|
+
except PermissionError:
|
|
371
|
+
return {"error": f"Permission denied: {directory}"}
|
|
372
|
+
except OSError as e:
|
|
373
|
+
return {"error": f"Failed to search: {e}"}
|