opencommand 0.1.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.
Files changed (37) hide show
  1. opencommand/__init__.py +4 -0
  2. opencommand/cli.py +375 -0
  3. opencommand/core/agent.py +91 -0
  4. opencommand/core/config.py +65 -0
  5. opencommand/core/errors.py +23 -0
  6. opencommand/core/logging.py +65 -0
  7. opencommand/core/supervisor.py +81 -0
  8. opencommand/core/tools.py +136 -0
  9. opencommand/engine/__init__.py +66 -0
  10. opencommand/engine/runner.py +275 -0
  11. opencommand/modules/knowledge.py +85 -0
  12. opencommand/modules/memory/MEMORY.md +14 -0
  13. opencommand/modules/skills/bash.md +14 -0
  14. opencommand/modules/skills/powershell.md +15 -0
  15. opencommand/modules/skills/python.md +20 -0
  16. opencommand/modules/skills/system.md +38 -0
  17. opencommand/modules/system/automatic.py +151 -0
  18. opencommand/modules/system/cron.py +193 -0
  19. opencommand/modules/system/notify.py +85 -0
  20. opencommand/modules/system/platform.py +197 -0
  21. opencommand/modules/templates.py +227 -0
  22. opencommand/modules/tools/desktop.py +127 -0
  23. opencommand/modules/tools/files.py +82 -0
  24. opencommand/modules/tools/instructions.py +163 -0
  25. opencommand/modules/tools/memory.py +78 -0
  26. opencommand/modules/tools/playwright.py +61 -0
  27. opencommand/modules/tools/research.py +46 -0
  28. opencommand/modules/tools/system.py +163 -0
  29. opencommand/modules/tools/terminal.py +53 -0
  30. opencommand/providers/provider.py +157 -0
  31. opencommand/swarm/agents/commander.py +530 -0
  32. opencommand/swarm/agents/worker.py +26 -0
  33. opencommand/tui/dashboard.py +41 -0
  34. opencommand-0.1.0.dist-info/METADATA +76 -0
  35. opencommand-0.1.0.dist-info/RECORD +37 -0
  36. opencommand-0.1.0.dist-info/WHEEL +4 -0
  37. opencommand-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,163 @@
1
+ """Instructions tool: the swarm's self-improvement surface.
2
+
3
+ Lets the Commander (and workers) create, update, and list the project's
4
+ *instruction* artifacts that shape future runs:
5
+
6
+ - skills -> ``modules/skills/*.md`` (how to do a kind of task)
7
+ - tasks -> ``modules/tasks/*.md`` (reusable task/playbook templates)
8
+ - memory -> ``<workspace>/.opencommand/memory/*.md`` (shared long-term notes)
9
+ - docs -> ``<workspace>/.opencommand/docs/*.md`` (learned reference docs)
10
+
11
+ The Commander calls this at the end of a run to distill what worked (new skills,
12
+ corrected instructions, gotchas) so the next run starts smarter. Workers read
13
+ these via the knowledge base that is injected into their system prompt.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from ...core.tools import BaseTool
22
+ from ..templates import validate_markdown
23
+
24
+ # Built-in skills ship with the package (read-only defaults). The swarm writes
25
+ # NEW/updated skills under the workspace so an installed (read-only) package is
26
+ # never mutated; tasks/docs/memory already live under the workspace.
27
+ _PKG_ROOT = Path(__file__).resolve().parents[2]
28
+
29
+
30
+ def _now() -> str:
31
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
32
+
33
+
34
+ def builtin_skills_dir() -> Path:
35
+ return _PKG_ROOT / "modules" / "skills"
36
+
37
+
38
+ def skills_dir(workspace: Path) -> Path:
39
+ d = workspace / ".opencommand" / "skills"
40
+ d.mkdir(parents=True, exist_ok=True)
41
+ return d
42
+
43
+
44
+ def tasks_dir(workspace: Path) -> Path:
45
+ d = workspace / ".opencommand" / "tasks"
46
+ d.mkdir(parents=True, exist_ok=True)
47
+ return d
48
+
49
+
50
+ def docs_dir(workspace: Path) -> Path:
51
+ d = workspace / ".opencommand" / "docs"
52
+ d.mkdir(parents=True, exist_ok=True)
53
+ return d
54
+
55
+
56
+ def _memory_dir(workspace: Path) -> Path:
57
+ from .memory import memory_dir
58
+
59
+ return memory_dir(workspace)
60
+
61
+
62
+ class InstructionsTool(BaseTool):
63
+ name = "instructions"
64
+ description = (
65
+ "Self-improvement surface: create/update/list the swarm's instruction "
66
+ "artifacts (skills, tasks, docs, memory) so future runs start smarter. "
67
+ "Use after a goal to distill what worked."
68
+ )
69
+ parameters = {
70
+ "type": "object",
71
+ "properties": {
72
+ "action": {"type": "string",
73
+ "enum": ["save", "update", "list", "read"]},
74
+ "kind": {"type": "string",
75
+ "enum": ["skill", "task", "doc", "memory"],
76
+ "description": "Which instruction store to target."},
77
+ "name": {"type": "string",
78
+ "description": "File name without extension (for save/update/read)."},
79
+ "body": {"type": "string",
80
+ "description": "Markdown content (for save/update)."},
81
+ "tags": {"type": "array", "items": {"type": "string"},
82
+ "description": "Tags for memory/docs (optional)."},
83
+ "query": {"type": "string",
84
+ "description": "Name or topic to read/list (optional)."},
85
+ },
86
+ "required": ["action", "kind"],
87
+ }
88
+
89
+ def __init__(self, workspace: Path) -> None:
90
+ self.workspace = workspace
91
+
92
+ def _root(self, kind: str) -> Path:
93
+ if kind == "skill":
94
+ return skills_dir(self.workspace)
95
+ if kind == "task":
96
+ return tasks_dir(self.workspace)
97
+ if kind == "doc":
98
+ return docs_dir(self.workspace)
99
+ if kind == "memory":
100
+ return _memory_dir(self.workspace)
101
+ raise ValueError(f"unknown kind: {kind}")
102
+
103
+ def run(self, action: str, kind: str, name: str | None = None,
104
+ body: str | None = None, tags: list[str] | None = None,
105
+ query: str | None = None) -> str:
106
+ root = self._root(kind)
107
+ if action == "list":
108
+ items = sorted(p.name for p in root.glob("*.md"))
109
+ return "\n".join(items) if items else f"(no {kind} files)"
110
+ if action == "read":
111
+ if not name and not query:
112
+ return "ERROR: read requires name or query"
113
+ target = root / f"{(name or query)}.md"
114
+ if not target.exists():
115
+ # fuzzy match by substring
116
+ matches = [p for p in root.glob("*.md")
117
+ if (name or query or "") in p.stem]
118
+ if not matches:
119
+ return f"ERROR: no {kind} file named {name or query}"
120
+ target = matches[0]
121
+ return target.read_text(encoding="utf-8")
122
+ if action in ("save", "update"):
123
+ if not name:
124
+ return "ERROR: save/update requires name"
125
+ if body is None:
126
+ return "ERROR: save/update requires body"
127
+ path = root / f"{name}.md"
128
+ existed = path.exists()
129
+ content = self._render(kind, name, body, tags)
130
+ missing = validate_markdown(kind, content)
131
+ if missing:
132
+ return (f"ERROR: {kind} '{name}' is missing required elements: "
133
+ f"{', '.join(missing)}. Use the template shape.")
134
+ path.write_text(content, encoding="utf-8")
135
+ verb = "Updated" if existed else "Created"
136
+ return f"{verb} {kind}: {path.name}"
137
+ return f"ERROR: unknown action {action}"
138
+
139
+ @staticmethod
140
+ def _render(kind: str, name: str, body: str,
141
+ tags: list[str] | None) -> str:
142
+ """Normalize body into the required template shape for ``kind``.
143
+
144
+ Emits the full YAML front-matter with every required field so the
145
+ artifact passes template validation and is machine-readable.
146
+ """
147
+ from ..templates import template_for
148
+
149
+ tpl = template_for(kind)
150
+ tag_csv = ",".join(tags or [])
151
+ now = _now()
152
+ # Map required front-matter fields to values we can derive.
153
+ fm_values = {"name": name, "topic": name, "category": name,
154
+ "goal": name, "updated": now, "created": now,
155
+ "tags": f"[{tag_csv}]", "phase": name, "index": "0",
156
+ "iteration": "0", "status": "draft", "phases": "0"}
157
+ lines = ["---"]
158
+ for fld in tpl.required_fields:
159
+ lines.append(f"{fld}: {fm_values.get(fld, '')}")
160
+ front = "\n".join(lines) + "\n---\n\n"
161
+ if body.startswith("#"):
162
+ return front + body + "\n"
163
+ return f"{front}# {name}\n\n{body}\n"
@@ -0,0 +1,78 @@
1
+ """Memory tool: Obsidian-style tagged markdown memory stored under modules/memory.
2
+
3
+ Supports saving notes (with #tags), listing, and searching across memory files.
4
+ Workspace-specific memory lives in <workspace>/.opencommand/memory/.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from ...core.tools import BaseTool
12
+
13
+
14
+ def memory_dir(workspace: Path) -> Path:
15
+ d = workspace / ".opencommand" / "memory"
16
+ d.mkdir(parents=True, exist_ok=True)
17
+ return d
18
+
19
+
20
+ def save_note(workspace: Path, topic: str, body: str, tags: list[str] | None = None) -> str:
21
+ d = memory_dir(workspace)
22
+ tags = tags or []
23
+ tag_line = " " + " ".join(f"#{t}" for t in tags) if tags else ""
24
+ path = d / f"{topic.replace(' ', '_').lower()}.md"
25
+ path.write_text(f"# {topic}{tag_line}\n\n{body}\n", encoding="utf-8")
26
+ return f"Saved memory: {path.name}"
27
+
28
+
29
+ def search_memory(workspace: Path, query: str) -> str:
30
+ d = memory_dir(workspace)
31
+ if not d.exists():
32
+ return "(no memory yet)"
33
+ q = query.lower()
34
+ hits: list[str] = []
35
+ for f in d.glob("*.md"):
36
+ text = f.read_text(encoding="utf-8")
37
+ if q in text.lower():
38
+ hits.append(f"- {f.name}: {text[:400].replace(chr(10), ' ')}")
39
+ return "\n".join(hits) if hits else "(no matches)"
40
+
41
+
42
+ def list_memory(workspace: Path) -> str:
43
+ d = memory_dir(workspace)
44
+ if not d.exists():
45
+ return "(no memory yet)"
46
+ files = sorted(p.name for p in d.glob("*.md"))
47
+ return "\n".join(files) if files else "(empty)"
48
+
49
+
50
+ class MemoryTool(BaseTool):
51
+ name = "memory"
52
+ description = "Manage OpenCommand memory: save a tagged note, list notes, or search notes."
53
+ parameters = {
54
+ "type": "object",
55
+ "properties": {
56
+ "action": {"type": "string", "enum": ["save", "list", "search"]},
57
+ "topic": {"type": "string", "description": "Note topic (for save)."},
58
+ "body": {"type": "string", "description": "Note body (for save)."},
59
+ "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags (for save)."},
60
+ "query": {"type": "string", "description": "Search query (for search)."},
61
+ },
62
+ "required": ["action"],
63
+ }
64
+
65
+ def __init__(self, workspace: Path) -> None:
66
+ self.workspace = workspace
67
+
68
+ def run(self, action: str, topic: str | None = None, body: str | None = None,
69
+ tags: list[str] | None = None, query: str | None = None) -> str:
70
+ if action == "save":
71
+ if not topic or body is None:
72
+ return "ERROR: save requires topic and body"
73
+ return save_note(self.workspace, topic, body, tags)
74
+ if action == "list":
75
+ return list_memory(self.workspace)
76
+ if action == "search":
77
+ return search_memory(self.workspace, query or "")
78
+ return f"ERROR: unknown action {action}"
@@ -0,0 +1,61 @@
1
+ """Playwright browser automation tool (web testing / interaction).
2
+
3
+ Distinct from research: drives a real browser (click, type, navigate, extract
4
+ DOM) for interactive web testing. Requires playwright + browsers installed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ...core.tools import BaseTool
10
+
11
+
12
+ class PlaywrightTool(BaseTool):
13
+ name = "playwright"
14
+ description = "Automate a browser: navigate, click, type, read page text/DOM."
15
+ parameters = {
16
+ "type": "object",
17
+ "properties": {
18
+ "action": {"type": "string", "enum": ["goto", "click", "type", "text", "screenshot"]},
19
+ "url": {"type": "string", "description": "URL for goto."},
20
+ "selector": {"type": "string", "description": "CSS selector for click/type."},
21
+ "value": {"type": "string", "description": "Text to type."},
22
+ },
23
+ "required": ["action"],
24
+ }
25
+
26
+ def __init__(self, headless: bool = True) -> None:
27
+ self.headless = headless
28
+ self._page = None
29
+
30
+ def _ensure(self):
31
+ if self._page is None:
32
+ from playwright.sync_api import sync_playwright
33
+ self._pw = sync_playwright().start()
34
+ self._browser = self._pw.chromium.launch(headless=self.headless)
35
+ self._page = self._browser.new_page()
36
+ return self._page
37
+
38
+ def run(self, action: str, url: str | None = None,
39
+ selector: str | None = None, value: str | None = None) -> str:
40
+ page = self._ensure()
41
+ if action == "goto" and url:
42
+ page.goto(url)
43
+ return f"Navigated to {url}; title={page.title()}"
44
+ if action == "click" and selector:
45
+ page.click(selector)
46
+ return f"Clicked {selector}"
47
+ if action == "type" and selector and value is not None:
48
+ page.fill(selector, value)
49
+ return f"Typed into {selector}"
50
+ if action == "text":
51
+ return page.inner_text("body")[:4000]
52
+ if action == "screenshot":
53
+ path = "/tmp/opencommand_shot.png"
54
+ page.screenshot(path=path)
55
+ return f"Screenshot saved: {path}"
56
+ return "ERROR: invalid action/args"
57
+
58
+ def close(self) -> None:
59
+ if getattr(self, "_browser", None):
60
+ self._browser.close()
61
+ self._pw.stop()
@@ -0,0 +1,46 @@
1
+ """Research tool: multi-batch web research via HTTP + lightweight scraping.
2
+
3
+ Not a browser-automation tool (see playwright.py). Fetches pages and pulls out
4
+ readable text for research/planning agents.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ import requests
12
+
13
+ from ...core.tools import BaseTool
14
+
15
+ _TAG_RE = re.compile(r"<[^>]+>")
16
+ _WS_RE = re.compile(r"\s+")
17
+ _HEAD_RE = re.compile(r"<(script|style|head)[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL)
18
+
19
+
20
+ def fetch_text(url: str, timeout: int = 30) -> str:
21
+ resp = requests.get(url, timeout=timeout, headers={"User-Agent": "OpenCommand/0.1"})
22
+ resp.raise_for_status()
23
+ text = _WS_RE.sub(" ", _TAG_RE.sub(" ", _HEAD_RE.sub("", resp.text))).strip()
24
+ return text[:8000]
25
+
26
+
27
+ class ResearchTool(BaseTool):
28
+ name = "research"
29
+ description = "Fetch one or more URLs and return cleaned readable text for research."
30
+ parameters = {
31
+ "type": "object",
32
+ "properties": {
33
+ "urls": {"type": "array", "items": {"type": "string"}, "description": "URLs to fetch."},
34
+ "query": {"type": "string", "description": "Optional note describing the research goal."},
35
+ },
36
+ "required": ["urls"],
37
+ }
38
+
39
+ def run(self, urls: list[str], query: str | None = None) -> str:
40
+ out: list[str] = []
41
+ for u in urls:
42
+ try:
43
+ out.append(f"## {u}\n{fetch_text(u)}")
44
+ except Exception as e: # noqa: BLE001
45
+ out.append(f"## {u}\nERROR: {e}")
46
+ return "\n\n".join(out)
@@ -0,0 +1,163 @@
1
+ """System introspection & security tool: processes, file watching, YARA scans.
2
+
3
+ Situational-awareness tool for the swarm: list running processes, scan files or
4
+ directories with YARA rules, and watch a path for changes via watchdog. YARA is
5
+ used through the yara CLI when present on PATH (the Python binding needs a C
6
+ toolchain to build on Windows, so we shell out instead). If yara is unavailable
7
+ the tool reports it clearly rather than crashing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ from ...core.tools import BaseTool
18
+
19
+
20
+ def _yara_available() -> bool:
21
+ return shutil.which("yara") is not None
22
+
23
+
24
+ def list_processes() -> list[dict[str, str]]:
25
+ """Return a snapshot of running processes (name, pid, maybe cmdline)."""
26
+ out: list[dict[str, str]] = []
27
+ try:
28
+ proc = subprocess.run(["ps", "-eo", "pid,comm,pcpu,pmem"],
29
+ capture_output=True, text=True, timeout=20)
30
+ for line in proc.stdout.strip().splitlines()[1:]:
31
+ parts = line.split(None, 3)
32
+ if len(parts) >= 2:
33
+ out.append({
34
+ "pid": parts[0], "name": parts[1],
35
+ "cpu": parts[2] if len(parts) > 2 else "",
36
+ "mem": parts[3] if len(parts) > 3 else "",
37
+ })
38
+ except (FileNotFoundError, subprocess.SubprocessError):
39
+ # Windows fallback (wmic is deprecated; use tasklist).
40
+ proc = subprocess.run(["tasklist", "/fo", "csv", "/nh"],
41
+ capture_output=True, text=True, timeout=30)
42
+ for line in proc.stdout.strip().splitlines():
43
+ parts = [p.strip('"') for p in line.split('","')]
44
+ if len(parts) >= 2:
45
+ out.append({"pid": parts[1], "name": parts[0]})
46
+ return out
47
+
48
+
49
+ def scan_file(path: str, rules: str) -> str:
50
+ """Scan a single file with YARA rules (rules = path to .yar or rule text)."""
51
+ if not _yara_available():
52
+ return "ERROR: yara CLI not found on PATH (install YARA to enable scanning)"
53
+ target = Path(path)
54
+ if not target.exists():
55
+ return f"ERROR: path does not exist: {path}"
56
+ # If rules looks like a file, pass it; otherwise write a temp rule file.
57
+ rule_arg = rules
58
+ tmp = None
59
+ if not Path(rules).exists():
60
+ tmp = Path(target.parent) / ".opencommand_yara_tmp.yar"
61
+ tmp.write_text(rules, encoding="utf-8")
62
+ rule_arg = str(tmp)
63
+ try:
64
+ proc = subprocess.run(["yara", rule_arg, str(target)],
65
+ capture_output=True, text=True, timeout=60)
66
+ result = proc.stdout.strip() or "(no matches)"
67
+ if proc.stderr.strip():
68
+ result += f"\n[stderr] {proc.stderr.strip()}"
69
+ return result
70
+ finally:
71
+ if tmp and tmp.exists():
72
+ tmp.unlink(missing_ok=True)
73
+
74
+
75
+ def scan_directory(path: str, rules: str, recursive: bool = True) -> str:
76
+ """Recursively scan a directory with YARA rules."""
77
+ if not _yara_available():
78
+ return "ERROR: yara CLI not found on PATH (install YARA to enable scanning)"
79
+ target = Path(path)
80
+ if not target.is_dir():
81
+ return f"ERROR: not a directory: {path}"
82
+ cmd = ["yara", "-r", rules, str(target)] if recursive else ["yara", rules, str(target)]
83
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
84
+ return proc.stdout.strip() or "(no matches)"
85
+
86
+
87
+ def watch_path(path: str, seconds: int = 10) -> str:
88
+ """Watch a directory for filesystem changes for ``seconds`` and report them."""
89
+ try:
90
+ from watchdog.events import FileSystemEventHandler
91
+ from watchdog.observers import Observer
92
+ except Exception as e: # pragma: no cover
93
+ return f"ERROR: watchdog unavailable: {e}"
94
+ target = Path(path)
95
+ if not target.is_dir():
96
+ return f"ERROR: not a directory: {path}"
97
+ events: list[str] = []
98
+
99
+ class _Handler(FileSystemEventHandler):
100
+ def on_any_event(self, event):
101
+ events.append(f"{event.event_type}: {event.src_path}")
102
+
103
+ observer = Observer()
104
+ observer.schedule(_Handler(), str(target), recursive=True)
105
+ observer.start()
106
+ try:
107
+ import time
108
+ time.sleep(seconds)
109
+ finally:
110
+ observer.stop()
111
+ observer.join(timeout=5)
112
+ return "\n".join(events) if events else "(no changes observed)"
113
+
114
+
115
+ class SystemTool(BaseTool):
116
+ name = "system"
117
+ description = (
118
+ "System introspection & security: list running processes, scan files or "
119
+ "directories with YARA rules, and watch a path for filesystem changes. "
120
+ "Use for situational awareness and safety checks."
121
+ )
122
+ parameters = {
123
+ "type": "object",
124
+ "properties": {
125
+ "action": {
126
+ "type": "string",
127
+ "enum": ["list_processes", "scan_file", "scan_directory", "watch"],
128
+ "description": "Which system operation to perform.",
129
+ },
130
+ "path": {"type": "string", "description": "File or directory path (for scan/watch)."},
131
+ "rules": {
132
+ "type": "string",
133
+ "description": "Path to a .yar rule file, or inline YARA rule text (for scans).",
134
+ },
135
+ "recursive": {
136
+ "type": "boolean",
137
+ "description": "Recurse into subdirectories for scan_directory (default true).",
138
+ },
139
+ "seconds": {
140
+ "type": "integer",
141
+ "description": "How long to watch a path in seconds (default 10).",
142
+ },
143
+ },
144
+ "required": ["action"],
145
+ }
146
+
147
+ def run(self, action: str, path: str | None = None, rules: str | None = None,
148
+ recursive: bool = True, seconds: int = 10) -> str:
149
+ if action == "list_processes":
150
+ return json.dumps(list_processes(), indent=2)
151
+ if action == "scan_file":
152
+ if not path or not rules:
153
+ return "ERROR: scan_file requires 'path' and 'rules'"
154
+ return scan_file(path, rules)
155
+ if action == "scan_directory":
156
+ if not path or not rules:
157
+ return "ERROR: scan_directory requires 'path' and 'rules'"
158
+ return scan_directory(path, rules, recursive=recursive)
159
+ if action == "watch":
160
+ if not path:
161
+ return "ERROR: watch requires 'path'"
162
+ return watch_path(path, seconds=seconds)
163
+ return f"ERROR: unknown action '{action}'"
@@ -0,0 +1,53 @@
1
+ """Cross-platform terminal command execution tool.
2
+
3
+ Agents call Terminal.run to execute shell commands and get the output back.
4
+ Uses subprocess (not os.popen, which is soft-deprecated in 3.14) and detects
5
+ the platform shell automatically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ import sys
12
+ from dataclasses import dataclass
13
+
14
+ from ...core.tools import BaseTool
15
+
16
+
17
+ @dataclass
18
+ class TerminalResult:
19
+ returncode: int
20
+ stdout: str
21
+ stderr: str
22
+
23
+
24
+ def _shell() -> list[str]:
25
+ return ["cmd.exe", "/c"] if sys.platform.startswith("win") else ["/bin/sh", "-c"]
26
+
27
+
28
+ def run_command(command: str, cwd: str | None = None, timeout: int = 120) -> TerminalResult:
29
+ """Run ``command`` in the platform shell; return stdout/stderr/rc."""
30
+ proc = subprocess.run(_shell() + [command], cwd=cwd, capture_output=True,
31
+ text=True, timeout=timeout)
32
+ return TerminalResult(proc.returncode, proc.stdout, proc.stderr)
33
+
34
+
35
+ class TerminalTool(BaseTool):
36
+ name = "terminal"
37
+ description = "Run a shell command on the host and return its stdout/stderr/exit code."
38
+ parameters = {
39
+ "type": "object",
40
+ "properties": {
41
+ "command": {"type": "string", "description": "The shell command to run."},
42
+ "cwd": {"type": "string", "description": "Working directory (optional)."},
43
+ "timeout": {"type": "integer", "description": "Timeout in seconds (default 120)."},
44
+ },
45
+ "required": ["command"],
46
+ }
47
+
48
+ def run(self, command: str, cwd: str | None = None, timeout: int = 600) -> str:
49
+ try:
50
+ r = run_command(command, cwd=cwd, timeout=timeout)
51
+ except subprocess.TimeoutExpired:
52
+ return "ERROR: command timed out"
53
+ return f"[exit={r.returncode}]\nSTDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}"