devpilot-agentic-cli 1.0.0__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.
@@ -0,0 +1,219 @@
1
+ """
2
+ agent/tools/registry.py
3
+ ───────────────────────
4
+ ToolRegistry and PermissionGuard.
5
+
6
+ Improvements:
7
+ - PermissionGuard: session-level "allow all" whitelist so the model
8
+ isn't interrupted on every destructive call after first approval
9
+ - ToolRegistry: accepts RepoContext and passes it to fs tools
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable
16
+
17
+ from agent.tools.base import BaseTool, ToolResult
18
+ from agent.tools.search_code import SearchCodeTool
19
+
20
+ from agent.tools.shell import RunBashTool
21
+ from agent.tools.a2a import A2ATool
22
+ from agent.tools.web_search import WebSearchTool
23
+
24
+ from agent.tools.git_ops import GitStatusTool, GitCommitTool
25
+ from agent.tools.doc_gen import DocGenTool
26
+ from agent.tools.diagram import DiagramTool
27
+ from agent.ui import UI
28
+
29
+ if TYPE_CHECKING:
30
+ from agent.config import Config
31
+ from agent.context import RepoContext
32
+
33
+
34
+ class PermissionGuard:
35
+ """
36
+ Intercepts calls to destructive tools and prompts the user.
37
+
38
+ Three response modes:
39
+ y / Enter → allow this one call
40
+ a → allow all remaining calls this session (whitelist)
41
+ n → deny this call
42
+
43
+ In --no-confirm mode all calls are allowed without prompting.
44
+ """
45
+
46
+ def __init__(self, no_confirm: bool = False) -> None:
47
+ self.no_confirm = no_confirm
48
+ self._allow_all = False # set to True when user types 'a'
49
+ self._whitelisted: set[str] = set() # per-tool whitelist (future extension)
50
+
51
+ def allow_all_for_session(self) -> None:
52
+ """Programmatically grant session-wide permission (used by tests / CI)."""
53
+ self._allow_all = True
54
+
55
+ async def check(self, tool_name: str, tool_input: dict[str, Any]) -> bool:
56
+ if self.no_confirm or self._allow_all:
57
+ return True
58
+
59
+ # Build a human-readable preview
60
+ preview_lines: list[str] = []
61
+ if tool_name == "write_file":
62
+ preview_lines.append(f"Write to: {tool_input.get('path', '?')}")
63
+ elif tool_name == "edit_file":
64
+ preview_lines.append(f"Edit file: {tool_input.get('path', '?')}")
65
+ elif tool_name == "run_bash":
66
+ preview_lines.append(f"Run: {tool_input.get('command', '?')}")
67
+ elif tool_name == "git_commit":
68
+ preview_lines.append(
69
+ f"Git Commit: {tool_input.get('message', '')}\n Files: {', '.join(tool_input.get('paths', []))}"
70
+ )
71
+ else:
72
+ for k, v in tool_input.items():
73
+ preview_lines.append(f" {k}: {v}")
74
+
75
+ choice = await UI.ask_permission(tool_name, preview_lines)
76
+
77
+ if choice in ("y", "yes", ""):
78
+ return True
79
+ if choice in ("a", "all"):
80
+ self._allow_all = True
81
+ UI.print_info("Permission granted for all remaining operations this session.")
82
+ return True
83
+ if choice in ("n", "no"):
84
+ return False
85
+
86
+ return False
87
+
88
+
89
+ @dataclass
90
+ class ToolRegistry:
91
+ """
92
+ Unified store of tool schemas and executors.
93
+ Manages both built-in OOP tools and dynamic MCP/A2A tools.
94
+ """
95
+
96
+ _config: "Config"
97
+ _context: "RepoContext | None" = field(default=None)
98
+ _tools: dict[str, BaseTool] = field(default_factory=dict)
99
+ _mcp_schemas: list[dict[str, Any]] = field(default_factory=list)
100
+ _mcp_executors: dict[str, Callable[[dict[str, Any]], Awaitable[ToolResult]]] = field(
101
+ default_factory=dict
102
+ )
103
+ _guard: PermissionGuard = field(init=False)
104
+
105
+ def __post_init__(self) -> None:
106
+ self._guard = PermissionGuard(no_confirm=self._config.no_confirm)
107
+ self._register_builtins()
108
+
109
+ def _register_builtins(self) -> None:
110
+ """Register all default native tools."""
111
+ # Import here to avoid circular imports at module load time
112
+ from agent.tools.fs import ListFilesTool, ReadFileTool, WriteFileTool, EditFileTool
113
+
114
+ tools: list[BaseTool] = [
115
+ ReadFileTool(self._config, self._context),
116
+ WriteFileTool(self._config, self._context),
117
+ EditFileTool(self._config, self._context),
118
+ ListFilesTool(self._config, self._context),
119
+ RunBashTool(self._config),
120
+ SearchCodeTool(self._config),
121
+
122
+ GitStatusTool(self._config),
123
+ GitCommitTool(self._config),
124
+ DocGenTool(self._config),
125
+ DiagramTool(self._config),
126
+ ]
127
+
128
+ if self._config.web_search_enabled:
129
+ tools.append(WebSearchTool(self._config))
130
+
131
+
132
+ if self._config.a2a_enabled:
133
+ tools.append(A2ATool(self._config))
134
+
135
+ for t in tools:
136
+ self._tools[t.name] = t
137
+
138
+ # ── Tool management ───────────────────────────────────────────────────────
139
+
140
+ def register_tool(self, tool: BaseTool) -> None:
141
+ self._tools[tool.name] = tool
142
+
143
+ # ── Schema access ─────────────────────────────────────────────────────────
144
+
145
+ @property
146
+ def schemas(self) -> list[dict[str, Any]]:
147
+ seen_names = set()
148
+ final_schemas = []
149
+
150
+ # Add native tools first (they take precedence)
151
+ for t in self._tools.values():
152
+ if t.schema.name not in seen_names:
153
+ final_schemas.append({
154
+ "name": t.schema.name,
155
+ "description": t.schema.description,
156
+ "input_schema": t.schema.parameters,
157
+ })
158
+ seen_names.add(t.schema.name)
159
+
160
+ # Add MCP tools if they don't collide
161
+ for s in self._mcp_schemas:
162
+ if s["name"] not in seen_names:
163
+ final_schemas.append({
164
+ "name": s["name"],
165
+ "description": s["description"],
166
+ "input_schema": s["input_schema"],
167
+ })
168
+ seen_names.add(s["name"])
169
+
170
+ return final_schemas
171
+
172
+ def has_tool(self, name: str) -> bool:
173
+ return name in self._tools or name in self._mcp_executors
174
+
175
+ # ── Execution ─────────────────────────────────────────────────────────────
176
+
177
+ async def execute(self, tool_name: str, tool_input: dict[str, Any]) -> ToolResult:
178
+ if not self.has_tool(tool_name):
179
+ available = list(self._tools.keys()) + list(self._mcp_executors.keys())
180
+ return ToolResult(
181
+ f"Error: Unknown tool '{tool_name}'. Available: {', '.join(available)}",
182
+ is_error=True,
183
+ )
184
+
185
+ is_destructive = (
186
+ self._tools[tool_name].is_destructive
187
+ if tool_name in self._tools
188
+ else False
189
+ )
190
+
191
+ if is_destructive:
192
+ allowed = await self._guard.check(tool_name, tool_input)
193
+ if not allowed:
194
+ return ToolResult(f"Operation cancelled by user: {tool_name}", is_error=False)
195
+
196
+ try:
197
+ if tool_name in self._tools:
198
+ return await self._tools[tool_name].execute(**tool_input)
199
+ else:
200
+ return await self._mcp_executors[tool_name](tool_input)
201
+ except Exception as e:
202
+ return ToolResult(f"Unexpected error in {tool_name}: {e}", is_error=True)
203
+
204
+ # ── MCP extension ─────────────────────────────────────────────────────────
205
+
206
+ def register_mcp_tool(
207
+ self,
208
+ schema: dict[str, Any],
209
+ executor: Callable[[dict[str, Any]], Awaitable[ToolResult]],
210
+ ) -> None:
211
+ name: str = schema["name"]
212
+ self._mcp_schemas.append(schema)
213
+ self._mcp_executors[name] = executor
214
+
215
+ def deregister_mcp_tools(self, server_id: str) -> None:
216
+ to_remove = [s["name"] for s in self._mcp_schemas if s.get("_mcp_server_id") == server_id]
217
+ self._mcp_schemas = [s for s in self._mcp_schemas if s.get("_mcp_server_id") != server_id]
218
+ for name in to_remove:
219
+ self._mcp_executors.pop(name, None)
@@ -0,0 +1,120 @@
1
+ """
2
+ agent/tools/search_code.py
3
+ ──────────────────────────
4
+ Code search tool — search_code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from agent.tools.base import BaseTool, ToolResult, ToolSchema
14
+
15
+ if TYPE_CHECKING:
16
+ from agent.config import Config
17
+
18
+
19
+ class SearchCodeTool(BaseTool):
20
+ """Search for patterns across files in the working directory."""
21
+
22
+ def __init__(self, config: "Config") -> None:
23
+ self._config = config
24
+
25
+ @property
26
+ def schema(self) -> ToolSchema:
27
+ return ToolSchema(
28
+ name="search_code",
29
+ description=(
30
+ "Search for a pattern (regex supported) across files in the project. "
31
+ "Returns matching lines with file paths and line numbers. "
32
+ "Use this to find function definitions, usages, imports, etc."
33
+ ),
34
+ parameters={
35
+ "type": "object",
36
+ "properties": {
37
+ "pattern": {
38
+ "type": "string",
39
+ "description": "Regex or literal pattern to search for.",
40
+ },
41
+ "file_pattern": {
42
+ "type": "string",
43
+ "description": "Glob pattern to filter files (e.g. '*.py', '*.ts').",
44
+ },
45
+ "context_lines": {
46
+ "type": "integer",
47
+ "description": "Lines of context around each match (default: 2).",
48
+ "default": 2,
49
+ },
50
+ "max_results": {
51
+ "type": "integer",
52
+ "description": "Maximum number of matches to return (default: 50).",
53
+ "default": 50,
54
+ },
55
+ },
56
+ "required": ["pattern"],
57
+ },
58
+ required=["pattern"],
59
+ sprint="Sprint 1",
60
+ )
61
+
62
+ async def execute( # type: ignore[override]
63
+ self,
64
+ pattern: str,
65
+ file_pattern: str | None = None,
66
+ context_lines: int = 2,
67
+ max_results: int = 50,
68
+ ) -> ToolResult:
69
+ if not pattern:
70
+ return ToolResult("Error: 'pattern' parameter is required.", is_error=True)
71
+
72
+ search_path = Path(self._config.workdir)
73
+ glob = file_pattern or "*.*"
74
+
75
+ try:
76
+ # Use ripgrep if available
77
+ rg_cmd = ["rg", "--no-heading", "--line-number", f"-C{context_lines}"]
78
+ if file_pattern:
79
+ rg_cmd.extend(["-g", file_pattern])
80
+ rg_cmd.extend([pattern, str(search_path)])
81
+
82
+ rg_result = subprocess.run(
83
+ rg_cmd,
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=15,
87
+ encoding="utf-8",
88
+ errors="replace",
89
+ )
90
+ if rg_result.returncode in (0, 1): # 0=matches found, 1=no matches
91
+ output = rg_result.stdout.strip() or f"No matches for '{pattern}'."
92
+ # Optionally cap output
93
+ lines = output.splitlines()
94
+ if len(lines) > max_results * (context_lines * 2 + 1):
95
+ output = "\n".join(lines[:max_results * (context_lines * 2 + 1)]) + "\n... (truncated)"
96
+ return ToolResult(output, is_error=False)
97
+ except (FileNotFoundError, OSError):
98
+ pass # ripgrep not available, fall back
99
+
100
+ # Pure-Python fallback: glob + line scan (simple, no context)
101
+ matches: list[str] = []
102
+ for file_path in sorted(search_path.rglob(glob)):
103
+ if not file_path.is_file():
104
+ continue
105
+ try:
106
+ lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
107
+ for i, line in enumerate(lines, start=1):
108
+ if pattern.lower() in line.lower():
109
+ matches.append(f"{file_path}:{i}: {line.rstrip()}")
110
+ except OSError:
111
+ continue
112
+
113
+ if not matches:
114
+ return ToolResult(f"No matches for '{pattern}' in {search_path}.", is_error=False)
115
+
116
+ output = "\n".join(matches[:max_results])
117
+ if len(matches) > max_results:
118
+ output += f"\n... (truncated {len(matches) - max_results} more matches)"
119
+
120
+ return ToolResult(output, is_error=False)
agent/tools/shell.py ADDED
@@ -0,0 +1,118 @@
1
+ """
2
+ agent/tools/shell.py
3
+ ────────────────────
4
+ Shell tool — run_bash.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ import sys
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from agent.tools.base import BaseTool, ToolResult, ToolSchema
15
+
16
+ if TYPE_CHECKING:
17
+ from agent.config import Config
18
+
19
+
20
+ _IS_WINDOWS = sys.platform == "win32"
21
+ _DEFAULT_SHELL = os.getenv(
22
+ "DEVPILOT_SHELL",
23
+ "powershell.exe" if _IS_WINDOWS else "/bin/bash",
24
+ )
25
+ _SHELL_FLAG = "-Command" if _IS_WINDOWS else "-c"
26
+
27
+ # Commands that are always blocked regardless of --no-confirm
28
+ _BLOCKED_COMMANDS = frozenset([
29
+ "rm -rf /",
30
+ "dd if=/dev/zero",
31
+ ":(){:|:&};:", # Fork bomb
32
+ ])
33
+
34
+
35
+ class RunBashTool(BaseTool):
36
+ """Execute a bash command in the working directory."""
37
+
38
+ def __init__(self, config: "Config") -> None:
39
+ self._config = config
40
+
41
+ @property
42
+ def schema(self) -> ToolSchema:
43
+ return ToolSchema(
44
+ name="run_bash",
45
+ description=(
46
+ "Execute a bash command in the project's working directory. "
47
+ "Use for running tests, builds, package installs, linters, etc. "
48
+ "Always shown to the user before execution."
49
+ ),
50
+ parameters={
51
+ "type": "object",
52
+ "properties": {
53
+ "command": {
54
+ "type": "string",
55
+ "description": "Bash command to execute.",
56
+ },
57
+ "timeout": {
58
+ "type": "integer",
59
+ "description": "Timeout in seconds (default: 60).",
60
+ "default": 60,
61
+ },
62
+ },
63
+ "required": ["command"],
64
+ },
65
+ required=["command"],
66
+ is_destructive=True,
67
+ sprint="Sprint 1",
68
+ )
69
+
70
+ async def execute(self, command: str, timeout: int = 60) -> ToolResult: # type: ignore[override]
71
+ if not command:
72
+ return ToolResult("Error: 'command' parameter is required.", is_error=True)
73
+
74
+ if any(b in command for b in _BLOCKED_COMMANDS):
75
+ return ToolResult(f"Error: Command contains blocked patterns.", is_error=True)
76
+
77
+ try:
78
+ # Use asyncio subprocess to avoid blocking the event loop
79
+ process = await asyncio.create_subprocess_exec(
80
+ _DEFAULT_SHELL,
81
+ _SHELL_FLAG,
82
+ command,
83
+ cwd=self._config.workdir,
84
+ stdout=asyncio.subprocess.PIPE,
85
+ stderr=asyncio.subprocess.PIPE,
86
+ )
87
+
88
+ try:
89
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
90
+ process.communicate(), timeout=timeout
91
+ )
92
+ except asyncio.TimeoutError:
93
+ try:
94
+ process.kill()
95
+ await process.communicate()
96
+ except ProcessLookupError:
97
+ pass
98
+ return ToolResult(
99
+ f"Error: Command timed out after {timeout}s: {command}", is_error=True
100
+ )
101
+
102
+ stdout = stdout_bytes.decode("utf-8", errors="replace")
103
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
104
+
105
+ output_parts: list[str] = []
106
+ if stdout.strip():
107
+ output_parts.append(f"stdout:\n{stdout.rstrip()}")
108
+ if stderr.strip():
109
+ output_parts.append(f"stderr:\n{stderr.rstrip()}")
110
+
111
+ output_parts.append(f"exit code: {process.returncode}")
112
+ output = "\n\n".join(output_parts) if output_parts else "(no output)"
113
+ is_error = process.returncode != 0
114
+
115
+ return ToolResult(output, is_error=is_error)
116
+
117
+ except Exception as e:
118
+ return ToolResult(f"Error executing command: {e}", is_error=True)
@@ -0,0 +1,105 @@
1
+ """
2
+ agent/tools/web_search.py
3
+ ─────────────────────────
4
+ Web search tool using Tavily API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from agent.tools.base import BaseTool, ToolResult, ToolSchema
13
+
14
+ if TYPE_CHECKING:
15
+ from agent.config import Config
16
+
17
+
18
+ class WebSearchTool(BaseTool):
19
+ """Search the web for current information using the Tavily API."""
20
+
21
+ def __init__(self, config: "Config") -> None:
22
+ self._config = config
23
+
24
+ @property
25
+ def schema(self) -> ToolSchema:
26
+ return ToolSchema(
27
+ name="web_search",
28
+ description=(
29
+ "Search the web for current information. Use when you need up-to-date "
30
+ "documentation, library versions, recent news, or anything not in your "
31
+ "training data. Returns a concise summary with source URLs."
32
+ ),
33
+ parameters={
34
+ "type": "object",
35
+ "properties": {
36
+ "query": {
37
+ "type": "string",
38
+ "description": "Search query.",
39
+ },
40
+ "max_results": {
41
+ "type": "integer",
42
+ "description": "Max number of results to return (default: 5).",
43
+ "default": 5,
44
+ },
45
+ "include_raw_content": {
46
+ "type": "boolean",
47
+ "description": "If true, include raw page content for deeper analysis.",
48
+ "default": False,
49
+ },
50
+ },
51
+ "required": ["query"],
52
+ },
53
+ required=["query"],
54
+ sprint="Sprint 2",
55
+ )
56
+
57
+ async def execute( # type: ignore[override]
58
+ self,
59
+ query: str,
60
+ max_results: int = 5,
61
+ include_raw_content: bool = False,
62
+ ) -> ToolResult:
63
+ if not query:
64
+ return ToolResult("Error: 'query' parameter is required.", is_error=True)
65
+
66
+ api_key = os.getenv("TAVILY_API_KEY")
67
+ if not api_key:
68
+ return ToolResult(
69
+ "Error: TAVILY_API_KEY environment variable is not set. "
70
+ "Get a free key at https://tavily.com and add it to your .env.",
71
+ is_error=True,
72
+ )
73
+
74
+ try:
75
+ from tavily import TavilyClient # type: ignore[import]
76
+ client = TavilyClient(api_key=api_key)
77
+
78
+ response = client.search(
79
+ query=query,
80
+ max_results=max_results,
81
+ include_raw_content=include_raw_content,
82
+ )
83
+
84
+ results = response.get("results", [])
85
+ if not results:
86
+ return ToolResult(f"No web results found for: {query}", is_error=False)
87
+
88
+ lines: list[str] = [f"Web search results for: {query}\n"]
89
+ for i, r in enumerate(results, 1):
90
+ lines.append(f"[{i}] {r.get('title', 'Untitled')}")
91
+ lines.append(f" URL: {r.get('url', '')}")
92
+ lines.append(f" {r.get('content', '').strip()[:400]}")
93
+ if include_raw_content and r.get("raw_content"):
94
+ lines.append(f"\n Full content:\n{r['raw_content'][:2000]}")
95
+ lines.append("")
96
+
97
+ return ToolResult("\n".join(lines), is_error=False)
98
+
99
+ except ImportError:
100
+ return ToolResult(
101
+ "Error: tavily-python is not installed. Run: pip install tavily-python",
102
+ is_error=True,
103
+ )
104
+ except Exception as e:
105
+ return ToolResult(f"Web search error: {e}", is_error=True)
agent/tui/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """
2
+ DevPilot Textual UI Package.
3
+ """