pdo-agent 2.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.
pdo/serve.py ADDED
@@ -0,0 +1,124 @@
1
+ """Serve mode: expose the PDO agent over stdio JSON-RPC (MCP-compatible).
2
+
3
+ ``pdo --serve`` turns PDO into an **MCP server**: any MCP client (Claude
4
+ Desktop, Claude Code, another PDO…) can connect over stdio and call the
5
+ ``run_task`` tool, which runs a prompt through the full PDO agent — tools,
6
+ sub-agents, codebase search and all — and returns the final answer.
7
+
8
+ Protocol: newline-delimited JSON-RPC 2.0 with the MCP handshake
9
+ (``initialize`` → ``tools/list`` → ``tools/call``), i.e. the mirror image of
10
+ :mod:`pdo.mcp`, which is PDO acting as a *client*.
11
+
12
+ Because stdin/stdout carry the protocol, serve mode never prints to stdout and
13
+ auto-denies interactive confirmations (dangerous commands are simply refused).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ from typing import Any, TextIO
20
+
21
+ from . import __version__
22
+ from .mcp import PROTOCOL_VERSION
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _RUN_TASK_SCHEMA = {
27
+ "type": "object",
28
+ "properties": {
29
+ "task": {
30
+ "type": "string",
31
+ "description": "The complete, self-contained task or question for the agent.",
32
+ }
33
+ },
34
+ "required": ["task"],
35
+ }
36
+
37
+
38
+ class PDOServer:
39
+ """A minimal MCP server wrapping one PDO agent."""
40
+
41
+ def __init__(self, agent: Any) -> None:
42
+ self._agent = agent
43
+
44
+ # --- request handling ---------------------------------------------------- #
45
+ def handle(self, message: dict[str, Any]) -> dict[str, Any] | None:
46
+ """Handle one JSON-RPC message; returns the response (None for notifications)."""
47
+ method = message.get("method", "")
48
+ msg_id = message.get("id")
49
+
50
+ if msg_id is None: # notification (e.g. notifications/initialized)
51
+ return None
52
+ if method == "initialize":
53
+ return self._result(
54
+ msg_id,
55
+ {
56
+ "protocolVersion": PROTOCOL_VERSION,
57
+ "capabilities": {"tools": {}},
58
+ "serverInfo": {"name": "pdo", "version": __version__},
59
+ },
60
+ )
61
+ if method == "tools/list":
62
+ return self._result(
63
+ msg_id,
64
+ {
65
+ "tools": [
66
+ {
67
+ "name": "run_task",
68
+ "description": (
69
+ "Run a task with the PDO agent: it reasons, uses its "
70
+ "tools (files, shell, web, git, codebase search, "
71
+ "sub-agents, connected MCP servers) and returns the "
72
+ "final answer."
73
+ ),
74
+ "inputSchema": _RUN_TASK_SCHEMA,
75
+ }
76
+ ]
77
+ },
78
+ )
79
+ if method == "tools/call":
80
+ return self._call_tool(msg_id, message.get("params") or {})
81
+ return self._error(msg_id, -32601, f"method not found: {method}")
82
+
83
+ def _call_tool(self, msg_id: Any, params: dict[str, Any]) -> dict[str, Any]:
84
+ name = params.get("name")
85
+ if name != "run_task":
86
+ return self._error(msg_id, -32602, f"unknown tool: {name}")
87
+ task = (params.get("arguments") or {}).get("task", "").strip()
88
+ if not task:
89
+ return self._error(msg_id, -32602, "missing required argument: task")
90
+ try:
91
+ answer = self._agent.run_turn(task)
92
+ is_error = False
93
+ except Exception as exc: # noqa: BLE001 — report, keep serving
94
+ logger.exception("run_task failed")
95
+ answer, is_error = f"Agent error: {exc}", True
96
+ return self._result(
97
+ msg_id,
98
+ {"content": [{"type": "text", "text": answer}], "isError": is_error},
99
+ )
100
+
101
+ # --- plumbing -------------------------------------------------------------- #
102
+ @staticmethod
103
+ def _result(msg_id: Any, result: dict[str, Any]) -> dict[str, Any]:
104
+ return {"jsonrpc": "2.0", "id": msg_id, "result": result}
105
+
106
+ @staticmethod
107
+ def _error(msg_id: Any, code: int, message: str) -> dict[str, Any]:
108
+ return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
109
+
110
+ def serve_forever(self, stdin: TextIO, stdout: TextIO) -> None:
111
+ """Blocking loop: one JSON-RPC message per line until EOF."""
112
+ for line in stdin:
113
+ line = line.strip()
114
+ if not line:
115
+ continue
116
+ try:
117
+ message = json.loads(line)
118
+ except json.JSONDecodeError:
119
+ logger.warning("Ignoring non-JSON input line")
120
+ continue
121
+ response = self.handle(message)
122
+ if response is not None:
123
+ stdout.write(json.dumps(response) + "\n")
124
+ stdout.flush()
pdo/skills.py ADDED
@@ -0,0 +1,59 @@
1
+ """User-defined skills: reusable prompt templates invoked as slash commands.
2
+
3
+ Each ``.md`` file in the skills directory becomes a slash command named after the
4
+ file (``review.md`` → ``/review``). The file body is a prompt template; an
5
+ optional first line ``description: ...`` or ``# Title`` sets the menu description.
6
+ Use ``{{args}}`` in the template to interpolate whatever the user types after the
7
+ command (otherwise their text is appended).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class Skill:
20
+ name: str # slash command name without the leading "/"
21
+ description: str
22
+ template: str
23
+
24
+ def render(self, args: str) -> str:
25
+ """Produce the prompt to send, substituting the user's arguments."""
26
+ if "{{args}}" in self.template:
27
+ return self.template.replace("{{args}}", args)
28
+ return self.template if not args else f"{self.template}\n\n{args}"
29
+
30
+
31
+ def load_skills(skills_dir: Path) -> dict[str, Skill]:
32
+ """Load every ``*.md`` skill file from ``skills_dir`` (name → Skill)."""
33
+ skills: dict[str, Skill] = {}
34
+ if not skills_dir.exists():
35
+ return skills
36
+ for path in sorted(skills_dir.glob("*.md")):
37
+ try:
38
+ text = path.read_text(encoding="utf-8")
39
+ except OSError:
40
+ logger.warning("Could not read skill %s", path)
41
+ continue
42
+ name = path.stem.lower().lstrip("/")
43
+ description, template = _parse_skill(text, name)
44
+ skills[name] = Skill(name=name, description=description, template=template)
45
+ return skills
46
+
47
+
48
+ def _parse_skill(text: str, name: str) -> tuple[str, str]:
49
+ lines = text.splitlines()
50
+ description = f"Custom skill: {name}"
51
+ if lines:
52
+ first = lines[0].strip()
53
+ if first.lower().startswith("description:"):
54
+ description = first.split(":", 1)[1].strip() or description
55
+ return description, "\n".join(lines[1:]).strip()
56
+ if first.startswith("# "):
57
+ description = first[2:].strip() or description
58
+ return description, "\n".join(lines[1:]).strip()
59
+ return description, text.strip()
pdo/theme.py ADDED
@@ -0,0 +1,47 @@
1
+ """Color themes for the terminal UI.
2
+
3
+ A theme is just an accent color used across the splash, input box, prompt, tool
4
+ bullets and footer. The accent is exposed both as a ``rich`` color name and as a
5
+ ``prompt_toolkit`` ANSI name, since the two libraries name colors differently.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ # name -> (rich color, prompt_toolkit ansi color)
10
+ THEMES: dict[str, tuple[str, str]] = {
11
+ "cyan": ("cyan", "ansicyan"),
12
+ "green": ("green", "ansigreen"),
13
+ "magenta": ("magenta", "ansimagenta"),
14
+ "amber": ("yellow", "ansiyellow"),
15
+ "blue": ("blue", "ansiblue"),
16
+ "mono": ("white", "ansiwhite"),
17
+ }
18
+
19
+ _DEFAULT = "cyan"
20
+ _current = _DEFAULT
21
+
22
+
23
+ def set_theme(name: str) -> bool:
24
+ """Switch the active theme. Returns False if the name is unknown."""
25
+ global _current
26
+ if name in THEMES:
27
+ _current = name
28
+ return True
29
+ return False
30
+
31
+
32
+ def current_theme() -> str:
33
+ return _current
34
+
35
+
36
+ def theme_names() -> list[str]:
37
+ return list(THEMES)
38
+
39
+
40
+ def accent() -> str:
41
+ """The accent color as a rich color name."""
42
+ return THEMES.get(_current, THEMES[_DEFAULT])[0]
43
+
44
+
45
+ def accent_ansi() -> str:
46
+ """The accent color as a prompt_toolkit ANSI color name."""
47
+ return THEMES.get(_current, THEMES[_DEFAULT])[1]
pdo/tools/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Built-in tools.
2
+
3
+ Tool modules register themselves with the shared registry when imported. The
4
+ registry imports them lazily (see :func:`pdo.tools.registry.get_registry`), so
5
+ importing this package has no side effects beyond making the modules available.
6
+ """
pdo/tools/base.py ADDED
@@ -0,0 +1,89 @@
1
+ """The ``Tool`` base class and shared confirmation helper.
2
+
3
+ Every tool subclasses :class:`Tool`, declares a JSON parameter schema, and is
4
+ executed by the agent through the registry. The agent never imports a concrete
5
+ tool, which is what keeps new tools a pure add-on.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from abc import ABC, abstractmethod
11
+ from typing import Any
12
+
13
+ from rich.console import Console
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _console = Console()
18
+
19
+
20
+ # Process-wide confirmation override. Non-interactive modes (e.g. `pdo --serve`,
21
+ # where stdin/stdout carry JSON-RPC) install a handler here so confirmation can
22
+ # never block on, or write to, the protocol streams.
23
+ _confirm_override: Any = None
24
+
25
+
26
+ def set_confirm_override(handler) -> None:
27
+ """Replace interactive confirmation globally (None restores the default)."""
28
+ global _confirm_override
29
+ _confirm_override = handler
30
+
31
+
32
+ def default_confirm(prompt: str) -> bool:
33
+ """Ask the user to type ``y`` to approve a sensitive action.
34
+
35
+ Returns ``False`` on EOF/Ctrl-C so that "no answer" always means "do not
36
+ proceed". Tools accept this as an injectable dependency so tests can supply
37
+ a deterministic callback instead of blocking on real input.
38
+ """
39
+ if _confirm_override is not None:
40
+ return bool(_confirm_override(prompt))
41
+ _console.print(f"[yellow]{prompt}[/yellow]")
42
+ try:
43
+ answer = _console.input("Type 'y' to confirm: ")
44
+ except (EOFError, KeyboardInterrupt):
45
+ return False
46
+ return answer.strip().lower() == "y"
47
+
48
+
49
+ def truncate(text: str, limit: int = 4000) -> str:
50
+ """Cap tool output so a single result can't overwhelm the model context."""
51
+ if len(text) <= limit:
52
+ return text
53
+ return text[:limit] + f"\n… [truncated {len(text) - limit} more characters]"
54
+
55
+
56
+ class ToolError(RuntimeError):
57
+ """Raised by a tool to signal a recoverable failure to the agent."""
58
+
59
+
60
+ class Tool(ABC):
61
+ """Base class for all tools.
62
+
63
+ Subclasses set the class attributes ``name``, ``description`` and
64
+ ``parameters`` (a JSON Schema object) and implement :meth:`run`.
65
+ """
66
+
67
+ name: str = ""
68
+ description: str = ""
69
+ parameters: dict[str, Any] = {"type": "object", "properties": {}}
70
+
71
+ def to_openai_schema(self) -> dict[str, Any]:
72
+ """Return the function/tool schema in the shape the model expects."""
73
+ return {
74
+ "type": "function",
75
+ "function": {
76
+ "name": self.name,
77
+ "description": self.description,
78
+ "parameters": self.parameters,
79
+ },
80
+ }
81
+
82
+ @abstractmethod
83
+ def run(self, **kwargs: Any) -> str:
84
+ """Execute the tool and return a string result for the model.
85
+
86
+ Implementations should return a human/model-readable string even on
87
+ failure (e.g. ``"Error: ..."``); raising is acceptable too — the
88
+ executor wraps every call — but returning is preferred for clarity.
89
+ """
pdo/tools/code.py ADDED
@@ -0,0 +1,48 @@
1
+ """Code execution tool.
2
+
3
+ Runs Python in a *separate process* with a timeout. This isolates crashes and
4
+ bounds runtime, but it is NOT a security sandbox — the code runs with the same
5
+ permissions as PDO. Use it for calculations and quick data tasks.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import sys
11
+ from typing import Any
12
+
13
+ from .base import Tool, truncate
14
+ from .registry import register_tool
15
+
16
+
17
+ @register_tool
18
+ class PythonExecTool(Tool):
19
+ name = "python_exec"
20
+ description = (
21
+ "Execute a snippet of Python code in a separate process and return its "
22
+ "stdout/stderr. Good for calculations, parsing, and quick data tasks. "
23
+ "Print results you want to see."
24
+ )
25
+ parameters = {
26
+ "type": "object",
27
+ "properties": {
28
+ "code": {"type": "string", "description": "Python source to run."},
29
+ "timeout": {
30
+ "type": "integer",
31
+ "description": "Maximum seconds to run (default 30).",
32
+ },
33
+ },
34
+ "required": ["code"],
35
+ }
36
+
37
+ def run(self, code: str, timeout: int = 30, **_: Any) -> str:
38
+ try:
39
+ completed = subprocess.run(
40
+ [sys.executable, "-c", code],
41
+ capture_output=True,
42
+ text=True,
43
+ timeout=timeout,
44
+ )
45
+ except subprocess.TimeoutExpired:
46
+ return f"Error: code timed out after {timeout}s."
47
+ output = ((completed.stdout or "") + (completed.stderr or "")).strip()
48
+ return f"[exit {completed.returncode}]\n{truncate(output or '(no output)')}"
pdo/tools/data.py ADDED
@@ -0,0 +1,57 @@
1
+ """Database tools: query a SQLite file."""
2
+ from __future__ import annotations
3
+
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .base import Tool, truncate
9
+ from .registry import register_tool
10
+
11
+ _MAX_ROWS = 200
12
+
13
+
14
+ @register_tool
15
+ class SqliteQueryTool(Tool):
16
+ name = "sqlite_query"
17
+ description = (
18
+ "Run a SQL statement against a SQLite database file. SELECTs return rows; "
19
+ "other statements are committed and report the affected row count."
20
+ )
21
+ parameters = {
22
+ "type": "object",
23
+ "properties": {
24
+ "db_path": {"type": "string", "description": "Path to the .sqlite/.db file."},
25
+ "query": {"type": "string", "description": "The SQL statement to run."},
26
+ "params": {
27
+ "type": "array",
28
+ "items": {"type": "string"},
29
+ "description": "Optional positional parameters for the query.",
30
+ },
31
+ },
32
+ "required": ["db_path", "query"],
33
+ }
34
+
35
+ def run(
36
+ self, db_path: str, query: str, params: list[Any] | None = None, **_: Any
37
+ ) -> str:
38
+ path = Path(db_path).expanduser()
39
+ if not path.exists():
40
+ return f"Error: database not found: {path}"
41
+ try:
42
+ connection = sqlite3.connect(str(path))
43
+ connection.row_factory = sqlite3.Row
44
+ cursor = connection.execute(query, tuple(params or []))
45
+ if cursor.description: # a result-producing query (SELECT/PRAGMA)
46
+ columns = [d[0] for d in cursor.description]
47
+ rows = cursor.fetchmany(_MAX_ROWS)
48
+ lines = [" | ".join(columns)]
49
+ lines += [" | ".join(str(row[c]) for c in columns) for row in rows]
50
+ connection.close()
51
+ return truncate("\n".join(lines)) if rows else "(no rows)"
52
+ connection.commit()
53
+ affected = cursor.rowcount
54
+ connection.close()
55
+ return f"OK ({affected} row(s) affected)."
56
+ except sqlite3.Error as exc:
57
+ return f"SQLite error: {exc}"
pdo/tools/edit.py ADDED
@@ -0,0 +1,55 @@
1
+ """Targeted file edit tool: replace an exact snippet (safer than full rewrites)."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ from .base import Tool, default_confirm
8
+ from .filesystem import _resolve, _within_cwd
9
+ from .registry import register_tool
10
+
11
+
12
+ @register_tool
13
+ class EditFileTool(Tool):
14
+ name = "edit_file"
15
+ description = (
16
+ "Replace an exact text snippet in a file with new text — safer than "
17
+ "rewriting the whole file. The old text must appear exactly once; add "
18
+ "surrounding context to make it unique."
19
+ )
20
+ parameters = {
21
+ "type": "object",
22
+ "properties": {
23
+ "path": {"type": "string", "description": "File to edit."},
24
+ "old_string": {
25
+ "type": "string",
26
+ "description": "Exact text to replace (must be unique in the file).",
27
+ },
28
+ "new_string": {"type": "string", "description": "Replacement text."},
29
+ },
30
+ "required": ["path", "old_string", "new_string"],
31
+ }
32
+
33
+ def __init__(self, confirm: Callable[[str], bool] = default_confirm) -> None:
34
+ self._confirm = confirm
35
+
36
+ def run(self, path: str, old_string: str, new_string: str, **_: Any) -> str:
37
+ target = _resolve(path)
38
+ if not target.exists():
39
+ return f"Error: file not found: {target}"
40
+ if not _within_cwd(target) and not self._confirm(
41
+ f"Edit file OUTSIDE the working directory {target}?"
42
+ ):
43
+ return "Cancelled: editing outside the working directory was not confirmed."
44
+
45
+ content = target.read_text(encoding="utf-8")
46
+ occurrences = content.count(old_string)
47
+ if occurrences == 0:
48
+ return "Error: old_string was not found in the file."
49
+ if occurrences > 1:
50
+ return (
51
+ f"Error: old_string is not unique ({occurrences} matches). "
52
+ "Add more surrounding context so it matches exactly once."
53
+ )
54
+ target.write_text(content.replace(old_string, new_string), encoding="utf-8")
55
+ return f"Edited {target} (1 replacement)."
@@ -0,0 +1,175 @@
1
+ """Filesystem tools: read, write, append, list and create directories.
2
+
3
+ Writes are sandboxed to the current working directory by default. Writing
4
+ outside it, or overwriting an existing file, requires explicit confirmation —
5
+ the confirmation callback is injectable so it can be mocked in tests.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .base import Tool, default_confirm
15
+ from .registry import register_tool
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Guard against accidentally pasting a huge file into the model context.
20
+ MAX_READ_CHARS = 200_000
21
+
22
+
23
+ def _resolve(path: str) -> Path:
24
+ """Expand ``~`` and resolve to an absolute path."""
25
+ return Path(path).expanduser().resolve()
26
+
27
+
28
+ def _within_cwd(path: Path) -> bool:
29
+ """Return True if ``path`` is inside the current working directory."""
30
+ try:
31
+ path.relative_to(Path.cwd().resolve())
32
+ return True
33
+ except ValueError:
34
+ return False
35
+
36
+
37
+ @register_tool
38
+ class ReadFileTool(Tool):
39
+ name = "read_file"
40
+ description = "Read and return the UTF-8 text contents of a file."
41
+ parameters = {
42
+ "type": "object",
43
+ "properties": {
44
+ "path": {"type": "string", "description": "Path to the file to read."}
45
+ },
46
+ "required": ["path"],
47
+ }
48
+
49
+ def run(self, path: str, **_: Any) -> str:
50
+ target = _resolve(path)
51
+ if not target.exists():
52
+ return f"Error: file not found: {target}"
53
+ if not target.is_file():
54
+ return f"Error: not a file: {target}"
55
+ data = target.read_text(encoding="utf-8", errors="replace")
56
+ if len(data) > MAX_READ_CHARS:
57
+ data = data[:MAX_READ_CHARS] + "\n... [truncated]"
58
+ return data
59
+
60
+
61
+ @register_tool
62
+ class WriteFileTool(Tool):
63
+ name = "write_file"
64
+ description = (
65
+ "Write text to a file (creating parent directories). Asks for "
66
+ "confirmation before overwriting a file or writing outside the working "
67
+ "directory."
68
+ )
69
+ parameters = {
70
+ "type": "object",
71
+ "properties": {
72
+ "path": {"type": "string", "description": "Destination file path."},
73
+ "content": {"type": "string", "description": "Text to write."},
74
+ },
75
+ "required": ["path", "content"],
76
+ }
77
+
78
+ def __init__(self, confirm: Callable[[str], bool] = default_confirm) -> None:
79
+ self._confirm = confirm
80
+
81
+ def run(self, path: str, content: str, **_: Any) -> str:
82
+ target = _resolve(path)
83
+ if not _within_cwd(target) and not self._confirm(
84
+ f"Write OUTSIDE the working directory to {target}?"
85
+ ):
86
+ return "Cancelled: writing outside the working directory was not confirmed."
87
+ if target.exists() and not self._confirm(f"Overwrite existing file {target}?"):
88
+ return "Cancelled: overwrite was not confirmed."
89
+ target.parent.mkdir(parents=True, exist_ok=True)
90
+ target.write_text(content, encoding="utf-8")
91
+ return f"Wrote {len(content)} characters to {target}"
92
+
93
+
94
+ @register_tool
95
+ class AppendFileTool(Tool):
96
+ name = "append_file"
97
+ description = (
98
+ "Append text to a file (creating it if needed). Asks for confirmation "
99
+ "before writing outside the working directory."
100
+ )
101
+ parameters = {
102
+ "type": "object",
103
+ "properties": {
104
+ "path": {"type": "string", "description": "Target file path."},
105
+ "content": {"type": "string", "description": "Text to append."},
106
+ },
107
+ "required": ["path", "content"],
108
+ }
109
+
110
+ def __init__(self, confirm: Callable[[str], bool] = default_confirm) -> None:
111
+ self._confirm = confirm
112
+
113
+ def run(self, path: str, content: str, **_: Any) -> str:
114
+ target = _resolve(path)
115
+ if not _within_cwd(target) and not self._confirm(
116
+ f"Append OUTSIDE the working directory to {target}?"
117
+ ):
118
+ return "Cancelled: writing outside the working directory was not confirmed."
119
+ target.parent.mkdir(parents=True, exist_ok=True)
120
+ with target.open("a", encoding="utf-8") as handle:
121
+ handle.write(content)
122
+ return f"Appended {len(content)} characters to {target}"
123
+
124
+
125
+ @register_tool
126
+ class ListDirTool(Tool):
127
+ name = "list_directory"
128
+ description = "List the entries of a directory (directories are marked)."
129
+ parameters = {
130
+ "type": "object",
131
+ "properties": {
132
+ "path": {
133
+ "type": "string",
134
+ "description": "Directory to list. Defaults to the current directory.",
135
+ }
136
+ },
137
+ }
138
+
139
+ def run(self, path: str = ".", **_: Any) -> str:
140
+ target = _resolve(path)
141
+ if not target.exists():
142
+ return f"Error: directory not found: {target}"
143
+ if not target.is_dir():
144
+ return f"Error: not a directory: {target}"
145
+ entries = sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
146
+ lines = [f"{'[dir] ' if entry.is_dir() else ' '}{entry.name}" for entry in entries]
147
+ return "\n".join(lines) if lines else "(empty directory)"
148
+
149
+
150
+ @register_tool
151
+ class CreateDirTool(Tool):
152
+ name = "create_directory"
153
+ description = (
154
+ "Create a directory (including parents). Asks for confirmation before "
155
+ "creating it outside the working directory."
156
+ )
157
+ parameters = {
158
+ "type": "object",
159
+ "properties": {
160
+ "path": {"type": "string", "description": "Directory path to create."}
161
+ },
162
+ "required": ["path"],
163
+ }
164
+
165
+ def __init__(self, confirm: Callable[[str], bool] = default_confirm) -> None:
166
+ self._confirm = confirm
167
+
168
+ def run(self, path: str, **_: Any) -> str:
169
+ target = _resolve(path)
170
+ if not _within_cwd(target) and not self._confirm(
171
+ f"Create directory OUTSIDE the working directory at {target}?"
172
+ ):
173
+ return "Cancelled: creating outside the working directory was not confirmed."
174
+ target.mkdir(parents=True, exist_ok=True)
175
+ return f"Created directory {target}"