diff --git a/navi/core/registry.py b/navi/core/registry.py index afe45d7..233cf58 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -8,6 +8,7 @@ from navi.profiles.base import AgentProfile from navi.tools import ( CodeExecTool, + DeleteToolTool, FilesystemTool, HttpRequestTool, ImageViewTool, @@ -107,13 +108,14 @@ tools = ToolRegistry() reload_tool = ReloadToolsTool(registry=tools) write_tool = WriteToolTool(registry=tools) + delete_tool = DeleteToolTool(registry=tools) list_tool = ListToolsTool(registry=tools) manual_tool = ToolManualTool(registry=tools) memory_tool = MemoryTool(memory_store) if memory_store else None builtins = [WebSearchTool(), FilesystemTool(), HttpRequestTool(), WebViewTool(), CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), ShareFileTool(), TestToolTool(), - TodoTool(), ScratchpadTool(), reload_tool, write_tool, list_tool, manual_tool] + TodoTool(), ScratchpadTool(), reload_tool, write_tool, delete_tool, list_tool, manual_tool] if memory_tool: builtins.append(memory_tool) for builtin in builtins: diff --git a/navi/profiles/developer.py b/navi/profiles/developer.py index 7b25012..1ac6d05 100644 --- a/navi/profiles/developer.py +++ b/navi/profiles/developer.py @@ -133,7 +133,7 @@ "web_search", "web_view", "http_request", "filesystem", "code_exec", "terminal", "image_view", "memory", - "reload_tools", "list_tools", "tool_manual", + "reload_tools", "delete_tool", "list_tools", "tool_manual", "test_tool", "spawn_agent", "share_file", diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index 05b761c..64101a2 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -1,5 +1,6 @@ from .base import Tool, ToolResult from .code_exec import CodeExecTool +from .delete_tool import DeleteToolTool from .filesystem import FilesystemTool from .http_request import HttpRequestTool from .image_view import ImageViewTool @@ -17,6 +18,7 @@ __all__ = [ "Tool", "ToolResult", + "DeleteToolTool", "WebSearchTool", "FilesystemTool", "HttpRequestTool", diff --git a/navi/tools/delete_tool.py b/navi/tools/delete_tool.py new file mode 100644 index 0000000..19a241f --- /dev/null +++ b/navi/tools/delete_tool.py @@ -0,0 +1,158 @@ +"""Built-in tool that removes a user tool by moving it to tools/.trash/.""" + +import json +from pathlib import Path + +from navi.config import settings + +from .base import Tool, ToolResult + +_TRASH_DIR_NAME = ".trash" + + +def _trash_dir(tools_dir: Path) -> Path: + d = tools_dir / _TRASH_DIR_NAME + d.mkdir(exist_ok=True) + return d + + +def _read_enabled(tools_dir: Path) -> list[str]: + f = tools_dir / "enabled.json" + try: + return json.loads(f.read_text()) if f.exists() else [] + except Exception: + return [] + + +def _write_enabled(tools_dir: Path, names: list[str]) -> None: + (tools_dir / "enabled.json").write_text(json.dumps(names, indent=2)) + + +class DeleteToolTool(Tool): + name = "delete_tool" + description = ( + "Remove, restore, or list trashed user tools. " + "remove — moves tools/.py to tools/.trash/ and unregisters it. " + "restore — moves it back and re-registers it. " + "list — shows what is currently in the trash." + ) + parameters = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["remove", "restore", "list"], + "description": "remove: trash the tool. restore: recover from trash. list: show trash contents.", + }, + "name": { + "type": "string", + "description": "Tool name (filename without .py). Required for remove and restore.", + }, + }, + "required": ["action"], + } + + def __init__(self, registry=None) -> None: + self._registry = registry + + async def execute(self, params: dict) -> ToolResult: + action = params.get("action", "") + if action == "remove": + return await self._remove(params) + if action == "restore": + return await self._restore(params) + if action == "list": + return self._list() + return ToolResult(success=False, output=f"Unknown action '{action}'.", error="invalid action") + + async def _remove(self, params: dict) -> ToolResult: + name = (params.get("name") or "").strip().removesuffix(".py") + if not name: + return ToolResult(success=False, output="name is required for remove.", error="missing name") + + tools_dir = Path(settings.tools_dir) + src = tools_dir / f"{name}.py" + if not src.exists(): + return ToolResult(success=False, output=f"tools/{name}.py not found.", error="not found") + + dest = _trash_dir(tools_dir) / f"{name}.py" + if dest.exists(): + # avoid silent overwrite — rename with suffix + i = 1 + while dest.exists(): + dest = _trash_dir(tools_dir) / f"{name}.{i}.py" + i += 1 + + src.rename(dest) + + # Remove from enabled.json + enabled = _read_enabled(tools_dir) + if name in enabled: + enabled.remove(name) + _write_enabled(tools_dir, enabled) + + # Reload registry to unregister + notes = [] + if self._registry is not None: + self._registry.reload_user_tools(settings.tools_dir) + notes.append("unregistered from registry") + else: + notes.append("registry unavailable — restart to fully unregister") + + return ToolResult( + success=True, + output=f"Moved tools/{name}.py → tools/.trash/{dest.name}. " + f"Removed from enabled.json. {', '.join(notes).capitalize()}.", + ) + + async def _restore(self, params: dict) -> ToolResult: + name = (params.get("name") or "").strip().removesuffix(".py") + if not name: + return ToolResult(success=False, output="name is required for restore.", error="missing name") + + tools_dir = Path(settings.tools_dir) + trash = _trash_dir(tools_dir) + src = trash / f"{name}.py" + if not src.exists(): + trashed = [p.name for p in trash.glob("*.py")] + hint = f" Trash contains: {', '.join(trashed)}" if trashed else " Trash is empty." + return ToolResult(success=False, output=f"tools/.trash/{name}.py not found.{hint}", error="not found") + + dest = tools_dir / f"{name}.py" + if dest.exists(): + return ToolResult( + success=False, + output=f"tools/{name}.py already exists. Remove or rename it first.", + error="conflict", + ) + + src.rename(dest) + + # Add back to enabled.json + enabled = _read_enabled(tools_dir) + if name not in enabled: + enabled.append(name) + _write_enabled(tools_dir, enabled) + + # Reload registry + notes = [] + if self._registry is not None: + self._registry.reload_user_tools(settings.tools_dir) + notes.append("re-registered in registry") + else: + notes.append("registry unavailable — restart to register") + + return ToolResult( + success=True, + output=f"Restored tools/.trash/{name}.py → tools/{name}.py. " + f"Added to enabled.json. {', '.join(notes).capitalize()}.", + ) + + def _list(self) -> ToolResult: + tools_dir = Path(settings.tools_dir) + trash = _trash_dir(tools_dir) + files = sorted(trash.glob("*.py")) + if not files: + return ToolResult(success=True, output="Trash is empty.") + lines = [f.name for f in files] + return ToolResult(success=True, output=f"{len(lines)} file(s) in trash:\n" + "\n".join(lines))