opencomputer 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 (51) hide show
  1. opencomputer/__init__.py +3 -0
  2. opencomputer/agent/__init__.py +1 -0
  3. opencomputer/agent/compaction.py +245 -0
  4. opencomputer/agent/config.py +108 -0
  5. opencomputer/agent/config_store.py +210 -0
  6. opencomputer/agent/injection.py +60 -0
  7. opencomputer/agent/loop.py +326 -0
  8. opencomputer/agent/memory.py +132 -0
  9. opencomputer/agent/prompt_builder.py +66 -0
  10. opencomputer/agent/prompts/base.j2 +23 -0
  11. opencomputer/agent/state.py +251 -0
  12. opencomputer/agent/step.py +31 -0
  13. opencomputer/cli.py +483 -0
  14. opencomputer/doctor.py +216 -0
  15. opencomputer/gateway/__init__.py +1 -0
  16. opencomputer/gateway/dispatch.py +89 -0
  17. opencomputer/gateway/protocol.py +84 -0
  18. opencomputer/gateway/server.py +77 -0
  19. opencomputer/gateway/wire_server.py +256 -0
  20. opencomputer/hooks/__init__.py +1 -0
  21. opencomputer/hooks/engine.py +79 -0
  22. opencomputer/hooks/runner.py +42 -0
  23. opencomputer/mcp/__init__.py +1 -0
  24. opencomputer/mcp/client.py +208 -0
  25. opencomputer/plugins/__init__.py +1 -0
  26. opencomputer/plugins/discovery.py +107 -0
  27. opencomputer/plugins/loader.py +155 -0
  28. opencomputer/plugins/registry.py +56 -0
  29. opencomputer/setup_wizard.py +235 -0
  30. opencomputer/skills/debug-python-import-error/SKILL.md +58 -0
  31. opencomputer/tools/__init__.py +1 -0
  32. opencomputer/tools/bash.py +78 -0
  33. opencomputer/tools/delegate.py +98 -0
  34. opencomputer/tools/glob.py +70 -0
  35. opencomputer/tools/grep.py +117 -0
  36. opencomputer/tools/read.py +81 -0
  37. opencomputer/tools/registry.py +69 -0
  38. opencomputer/tools/skill_manage.py +265 -0
  39. opencomputer/tools/write.py +58 -0
  40. opencomputer-0.1.0.dist-info/METADATA +190 -0
  41. opencomputer-0.1.0.dist-info/RECORD +51 -0
  42. opencomputer-0.1.0.dist-info/WHEEL +4 -0
  43. opencomputer-0.1.0.dist-info/entry_points.txt +3 -0
  44. plugin_sdk/__init__.py +66 -0
  45. plugin_sdk/channel_contract.py +74 -0
  46. plugin_sdk/core.py +129 -0
  47. plugin_sdk/hooks.py +80 -0
  48. plugin_sdk/injection.py +60 -0
  49. plugin_sdk/provider_contract.py +95 -0
  50. plugin_sdk/runtime_context.py +39 -0
  51. plugin_sdk/tool_contract.py +67 -0
@@ -0,0 +1,235 @@
1
+ """
2
+ opencomputer setup — interactive first-run wizard.
3
+
4
+ Walks a new user through: pick provider → enter API key → optionally add
5
+ channel tokens → write config.yaml → test the provider connection.
6
+
7
+ Design notes:
8
+ - Never stores the API key in config.yaml — we ask the user to export it
9
+ as an env var (the provider reads $ANTHROPIC_API_KEY / $OPENAI_API_KEY
10
+ natively). Saves the ENV VAR NAME so we can remind the user later.
11
+ - Safe to re-run — each step detects existing config and asks "overwrite?"
12
+ - Provider test is short (just a <10-token ping) to confirm auth works.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import os
19
+ from dataclasses import replace
20
+ from pathlib import Path
21
+
22
+ from rich.console import Console
23
+ from rich.prompt import Confirm, Prompt
24
+
25
+ from opencomputer.agent.config import (
26
+ Config,
27
+ MCPServerConfig,
28
+ ModelConfig,
29
+ default_config,
30
+ )
31
+ from opencomputer.agent.config_store import (
32
+ config_file_path,
33
+ load_config,
34
+ save_config,
35
+ )
36
+
37
+ console = Console()
38
+
39
+
40
+ # Known providers the wizard supports out of the box. Adding a new provider
41
+ # plugin? It'll still work — the user can edit config.yaml by hand.
42
+ _SUPPORTED_PROVIDERS = {
43
+ "anthropic": {
44
+ "label": "Anthropic (Claude)",
45
+ "env_key": "ANTHROPIC_API_KEY",
46
+ "default_model": "claude-opus-4-7",
47
+ "signup_url": "https://console.anthropic.com/settings/keys",
48
+ },
49
+ "openai": {
50
+ "label": "OpenAI (GPT)",
51
+ "env_key": "OPENAI_API_KEY",
52
+ "default_model": "gpt-5.4",
53
+ "signup_url": "https://platform.openai.com/api-keys",
54
+ },
55
+ }
56
+
57
+
58
+ def _print_banner() -> None:
59
+ console.print("\n[bold cyan]╭─────────────────────────────────────╮[/bold cyan]")
60
+ console.print("[bold cyan]│ OpenComputer — Setup Wizard │[/bold cyan]")
61
+ console.print("[bold cyan]╰─────────────────────────────────────╯[/bold cyan]")
62
+ console.print()
63
+
64
+
65
+ def _pick_provider() -> tuple[str, dict]:
66
+ console.print("[bold]Step 1 — pick an LLM provider[/bold]")
67
+ for i, (pid, meta) in enumerate(_SUPPORTED_PROVIDERS.items(), 1):
68
+ console.print(f" [cyan]{i}[/cyan]. {meta['label']} — [dim]{pid}[/dim]")
69
+ while True:
70
+ choice = Prompt.ask(
71
+ "Choose", default="1", choices=[str(i) for i in range(1, len(_SUPPORTED_PROVIDERS) + 1)]
72
+ )
73
+ try:
74
+ idx = int(choice) - 1
75
+ except ValueError:
76
+ continue
77
+ pid = list(_SUPPORTED_PROVIDERS.keys())[idx]
78
+ return pid, _SUPPORTED_PROVIDERS[pid]
79
+
80
+
81
+ def _prompt_model(default_model: str) -> str:
82
+ console.print(f"\n[bold]Step 2 — which model?[/bold] [dim](default: {default_model})[/dim]")
83
+ return Prompt.ask("Model", default=default_model)
84
+
85
+
86
+ def _prompt_api_key(env_key: str, signup_url: str) -> None:
87
+ console.print("\n[bold]Step 3 — API key[/bold]")
88
+ console.print(f"[dim]Get one at {signup_url} if you don't have it yet.[/dim]")
89
+
90
+ current = os.environ.get(env_key, "")
91
+ if current:
92
+ console.print(
93
+ f"[green]✓[/green] {env_key} is already set in your environment "
94
+ f"(ends in …{current[-4:]})."
95
+ )
96
+ return
97
+
98
+ console.print(
99
+ f"[yellow]![/yellow] {env_key} is NOT set. "
100
+ f"Before running, export it in your shell:"
101
+ )
102
+ console.print(f" [bold]export {env_key}=your-key-here[/bold]")
103
+ console.print(
104
+ "[dim]Tip: add it to ~/.zshrc or ~/.bashrc to persist across sessions.[/dim]"
105
+ )
106
+
107
+
108
+ def _optional_channel(cfg: Config) -> None:
109
+ console.print("\n[bold]Step 4 — messaging channel (optional)[/bold]")
110
+ console.print("[dim]Skip if you only want to use the CLI for now.[/dim]")
111
+
112
+ want_telegram = Confirm.ask("Set up Telegram?", default=False)
113
+ if want_telegram:
114
+ console.print(
115
+ "1. Open Telegram → message @BotFather → /newbot\n"
116
+ "2. Name the bot, get the token.\n"
117
+ "3. Export the token:"
118
+ )
119
+ console.print(" [bold]export TELEGRAM_BOT_TOKEN=123:ABC...[/bold]")
120
+ console.print(
121
+ "[dim]Then run `opencomputer gateway` — the Telegram plugin "
122
+ "picks up the token automatically.[/dim]"
123
+ )
124
+
125
+
126
+ def _optional_mcp(cfg: Config) -> Config:
127
+ console.print("\n[bold]Step 5 — MCP servers (optional)[/bold]")
128
+ console.print(
129
+ "[dim]MCP servers expose external tools to the agent "
130
+ "(stock prices, databases, browsers, etc.)[/dim]"
131
+ )
132
+ want_mcp = Confirm.ask("Add an MCP server?", default=False)
133
+ if not want_mcp:
134
+ return cfg
135
+
136
+ servers: list[MCPServerConfig] = list(cfg.mcp.servers)
137
+ while True:
138
+ name = Prompt.ask("Server name (kebab-case, e.g. 'investor-agent')")
139
+ if not name.strip():
140
+ break
141
+ command = Prompt.ask("Command to launch it (e.g. 'python3')")
142
+ args_str = Prompt.ask(
143
+ "Args (space-separated, e.g. '-m investor_agent.server')", default=""
144
+ )
145
+ args = tuple(args_str.split()) if args_str else ()
146
+ servers.append(
147
+ MCPServerConfig(name=name, command=command, args=args, enabled=True)
148
+ )
149
+ console.print(f"[green]✓[/green] added {name}")
150
+ if not Confirm.ask("Add another?", default=False):
151
+ break
152
+
153
+ return replace(cfg, mcp=replace(cfg.mcp, servers=tuple(servers)))
154
+
155
+
156
+ async def _test_provider(provider_id: str, env_key: str) -> bool:
157
+ """Fire one tiny request to confirm auth works. Returns True on success."""
158
+ if not os.environ.get(env_key):
159
+ console.print(
160
+ f"[yellow]skipped[/yellow] — {env_key} not set, can't test auth yet"
161
+ )
162
+ return False
163
+
164
+ from opencomputer.agent.config import default_config
165
+ from opencomputer.plugins.registry import registry as plugin_registry
166
+ from plugin_sdk.core import Message
167
+
168
+ # Discover + activate providers
169
+ repo_root = Path(__file__).resolve().parent.parent
170
+ ext_dir = repo_root / "extensions"
171
+ if ext_dir.exists():
172
+ plugin_registry.load_all([ext_dir])
173
+
174
+ provider_cls = plugin_registry.providers.get(provider_id)
175
+ if provider_cls is None:
176
+ console.print(f"[red]✗[/red] provider plugin for '{provider_id}' not found")
177
+ return False
178
+
179
+ try:
180
+ provider = provider_cls() if isinstance(provider_cls, type) else provider_cls
181
+ resp = await provider.complete(
182
+ model=default_config().model.model if provider_id == "anthropic" else "gpt-5.4",
183
+ messages=[Message(role="user", content="reply with exactly: OK")],
184
+ max_tokens=8,
185
+ )
186
+ console.print(
187
+ f"[green]✓[/green] provider responded — "
188
+ f"{resp.usage.input_tokens} in / {resp.usage.output_tokens} out tokens"
189
+ )
190
+ return True
191
+ except Exception as e: # noqa: BLE001
192
+ console.print(f"[red]✗[/red] provider test failed: {type(e).__name__}: {e}")
193
+ return False
194
+
195
+
196
+ def run_setup() -> None:
197
+ """Interactive setup wizard entry point."""
198
+ _print_banner()
199
+
200
+ existing = config_file_path().exists()
201
+ if existing:
202
+ console.print(
203
+ f"[yellow]![/yellow] Existing config found at [dim]{config_file_path()}[/dim]"
204
+ )
205
+ if not Confirm.ask("Overwrite?", default=False):
206
+ console.print("[dim]Aborted.[/dim]")
207
+ return
208
+
209
+ cfg = load_config() if existing else default_config()
210
+ provider_id, meta = _pick_provider()
211
+ model = _prompt_model(meta["default_model"])
212
+ new_model_cfg = ModelConfig(
213
+ provider=provider_id,
214
+ model=model,
215
+ api_key_env=meta["env_key"],
216
+ )
217
+ cfg = replace(cfg, model=new_model_cfg)
218
+
219
+ _prompt_api_key(meta["env_key"], meta["signup_url"])
220
+ _optional_channel(cfg)
221
+ cfg = _optional_mcp(cfg)
222
+
223
+ save_config(cfg)
224
+ console.print(f"\n[green]✓[/green] wrote config → [dim]{config_file_path()}[/dim]")
225
+
226
+ console.print("\n[bold]Step 6 — test the provider connection[/bold]")
227
+ if Confirm.ask("Send a tiny test request now?", default=True):
228
+ asyncio.run(_test_provider(provider_id, meta["env_key"]))
229
+
230
+ console.print(
231
+ "\n[bold green]Setup complete.[/bold green] Run [bold]opencomputer[/bold] to chat."
232
+ )
233
+
234
+
235
+ __all__ = ["run_setup"]
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: Debug Python import error
3
+ description: Use when the user hits a ModuleNotFoundError, ImportError, ImportError when running a Python script, circular import, "no module named X", or asks about fixing a broken Python import.
4
+ version: 0.1.0
5
+ ---
6
+
7
+ # Debugging Python Import Errors
8
+
9
+ When the user hits an import error, follow this systematic checklist.
10
+
11
+ ## 1. Identify the exact error
12
+
13
+ Look at the traceback — the key things to extract:
14
+ - Exact module name that failed (`ModuleNotFoundError: No module named 'foo'`)
15
+ - Which file triggered the import
16
+ - Any partial match (sometimes `X.Y` fails where `X` works — points to a missing submodule)
17
+
18
+ ## 2. The 4 most common causes (check in order)
19
+
20
+ ### A. Missing dependency
21
+ Most common. Package simply isn't installed.
22
+ - Check: `pip list | grep <module>` or `python -c "import <module>"`
23
+ - Fix: `pip install <module>` (or `pip install -e .` if editable in-project)
24
+
25
+ ### B. Virtual environment not activated
26
+ The script is running against system Python, not the venv.
27
+ - Check: `which python` and `echo $VIRTUAL_ENV`
28
+ - Fix: `source .venv/bin/activate` (or equivalent)
29
+
30
+ ### C. Wrong working directory / sys.path
31
+ Package lives somewhere not on `sys.path`.
32
+ - Check: `python -c "import sys; print(sys.path)"`
33
+ - Check: is the project installed with `pip install -e .`? That adds src to path.
34
+ - Fix: install in editable mode or use `PYTHONPATH=...`.
35
+
36
+ ### D. Circular import
37
+ `A` imports `B` which imports `A` back.
38
+ - Signal: error says "partially initialized module" or "cannot import name X from Y"
39
+ - Fix: restructure — extract the shared piece into a third module that both import.
40
+
41
+ ## 3. Plugin/extension specific (OpenComputer)
42
+
43
+ If the error is in an OpenComputer plugin:
44
+ - Plugin entry module names collide — check `opencomputer/plugins/loader.py` — we clear common names between plugin loads.
45
+ - `from X import Y` at top of plugin.py — use the try/except ImportError dual pattern or `importlib.util.spec_from_file_location`.
46
+
47
+ ## 4. Verify the fix
48
+
49
+ After making a change:
50
+ ```bash
51
+ python -c "import <module>; print('OK')"
52
+ # or run the failing script again
53
+ ```
54
+
55
+ ## 5. Save the root cause
56
+
57
+ If this was a non-obvious fix (circular import fix, sys.path trick), save it
58
+ to a skill so you don't debug it again next time.
@@ -0,0 +1 @@
1
+ """Built-in tools — Read, Write, Bash, Grep, Glob, skill_manage, delegate."""
@@ -0,0 +1,78 @@
1
+ """Bash tool — run a shell command with a timeout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from plugin_sdk.core import ToolCall, ToolResult
8
+ from plugin_sdk.tool_contract import BaseTool, ToolSchema
9
+
10
+
11
+ class BashTool(BaseTool):
12
+ parallel_safe = False # side effects
13
+
14
+ @property
15
+ def schema(self) -> ToolSchema:
16
+ return ToolSchema(
17
+ name="Bash",
18
+ description="Execute a bash command and return stdout+stderr. "
19
+ "Commands run in /bin/bash with a configurable timeout. "
20
+ "Use for scripted tasks, git, package management, file ops.",
21
+ parameters={
22
+ "type": "object",
23
+ "properties": {
24
+ "command": {
25
+ "type": "string",
26
+ "description": "The bash command to execute.",
27
+ },
28
+ "timeout_s": {
29
+ "type": "integer",
30
+ "description": "Max execution time in seconds (default 60, max 600).",
31
+ "minimum": 1,
32
+ "maximum": 600,
33
+ },
34
+ },
35
+ "required": ["command"],
36
+ },
37
+ )
38
+
39
+ async def execute(self, call: ToolCall) -> ToolResult:
40
+ args = call.arguments
41
+ cmd = args.get("command", "")
42
+ timeout = min(int(args.get("timeout_s", 60)), 600)
43
+ if not cmd.strip():
44
+ return ToolResult(
45
+ tool_call_id=call.id, content="Error: empty command", is_error=True
46
+ )
47
+ try:
48
+ proc = await asyncio.create_subprocess_shell(
49
+ cmd,
50
+ stdout=asyncio.subprocess.PIPE,
51
+ stderr=asyncio.subprocess.PIPE,
52
+ )
53
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
54
+ exit_code = proc.returncode or 0
55
+ except TimeoutError:
56
+ return ToolResult(
57
+ tool_call_id=call.id,
58
+ content=f"Error: command timed out after {timeout}s",
59
+ is_error=True,
60
+ )
61
+ except Exception as e:
62
+ return ToolResult(
63
+ tool_call_id=call.id,
64
+ content=f"Error: {type(e).__name__}: {e}",
65
+ is_error=True,
66
+ )
67
+
68
+ out = stdout.decode("utf-8", errors="replace") if stdout else ""
69
+ err = stderr.decode("utf-8", errors="replace") if stderr else ""
70
+ combined = (
71
+ f"$ {cmd}\n"
72
+ f"exit={exit_code}\n"
73
+ f"--- stdout ---\n{out}"
74
+ + (f"\n--- stderr ---\n{err}" if err else "")
75
+ )
76
+ return ToolResult(
77
+ tool_call_id=call.id, content=combined, is_error=exit_code != 0
78
+ )
@@ -0,0 +1,98 @@
1
+ """
2
+ delegate — spawn a fresh subagent in an isolated context.
3
+
4
+ Used when the main agent wants to offload a big exploration task without
5
+ polluting its own context. The subagent gets a fresh system prompt +
6
+ whatever briefing the main agent writes, runs its own while-loop, and
7
+ returns a single text summary.
8
+
9
+ Phase 1.5 stub: uses a simple approach where the subagent shares the
10
+ provider + tool registry, but keeps its own conversation messages.
11
+ Later phases can add context isolation, tool restrictions, etc.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from plugin_sdk.core import ToolCall, ToolResult
17
+ from plugin_sdk.runtime_context import DEFAULT_RUNTIME_CONTEXT, RuntimeContext
18
+ from plugin_sdk.tool_contract import BaseTool, ToolSchema
19
+
20
+
21
+ class DelegateTool(BaseTool):
22
+ parallel_safe = True # each delegate gets its own loop instance
23
+
24
+ # Lazy-import a factory the CLI can inject; until then raise a clear error
25
+ _factory = None
26
+ #: Class-level "current runtime" set by the parent loop before dispatching
27
+ #: tool calls. Ensures subagent loops inherit plan_mode / yolo_mode, etc.
28
+ _current_runtime: RuntimeContext = DEFAULT_RUNTIME_CONTEXT
29
+
30
+ @classmethod
31
+ def set_factory(cls, factory) -> None:
32
+ """Inject a callable that returns a fresh AgentLoop. Called once at CLI startup."""
33
+ # staticmethod wrap prevents Python from binding `self` when we later do
34
+ # `self._factory()` on an instance — lambdas and plain functions would
35
+ # otherwise get `self` auto-injected.
36
+ cls._factory = staticmethod(factory)
37
+
38
+ @classmethod
39
+ def set_runtime(cls, runtime: RuntimeContext) -> None:
40
+ """Set the runtime context to propagate into subagents. Called by AgentLoop."""
41
+ cls._current_runtime = runtime
42
+
43
+ @property
44
+ def schema(self) -> ToolSchema:
45
+ return ToolSchema(
46
+ name="delegate",
47
+ description=(
48
+ "Spawn a fresh subagent with isolated context to handle a specific task. "
49
+ "Use this when you need to do heavy exploration (reading many files, searching "
50
+ "code) and only want a summary back instead of polluting the main conversation. "
51
+ "The subagent runs until it produces a final answer, then returns its output."
52
+ ),
53
+ parameters={
54
+ "type": "object",
55
+ "properties": {
56
+ "task": {
57
+ "type": "string",
58
+ "description": (
59
+ "Describe the task for the subagent completely. The subagent has "
60
+ "no memory of the main conversation — include all context it needs."
61
+ ),
62
+ },
63
+ },
64
+ "required": ["task"],
65
+ },
66
+ )
67
+
68
+ async def execute(self, call: ToolCall) -> ToolResult:
69
+ task = call.arguments.get("task", "").strip()
70
+ if not task:
71
+ return ToolResult(
72
+ tool_call_id=call.id,
73
+ content="Error: task description required",
74
+ is_error=True,
75
+ )
76
+ if self._factory is None:
77
+ return ToolResult(
78
+ tool_call_id=call.id,
79
+ content=(
80
+ "Error: delegate is not initialized. "
81
+ "CLI bootstrapping must call DelegateTool.set_factory(...)."
82
+ ),
83
+ is_error=True,
84
+ )
85
+ subagent_loop = self._factory()
86
+ # Propagate the parent's runtime context — plan mode, yolo mode, etc.
87
+ # must apply to subagents too, otherwise delegating becomes an escape hatch.
88
+ result = await subagent_loop.run_conversation(
89
+ user_message=task,
90
+ runtime=self._current_runtime,
91
+ )
92
+ return ToolResult(
93
+ tool_call_id=call.id,
94
+ content=result.final_message.content,
95
+ )
96
+
97
+
98
+ __all__ = ["DelegateTool"]
@@ -0,0 +1,70 @@
1
+ """Glob tool — find files by pattern, sorted by mtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from plugin_sdk.core import ToolCall, ToolResult
8
+ from plugin_sdk.tool_contract import BaseTool, ToolSchema
9
+
10
+
11
+ class GlobTool(BaseTool):
12
+ parallel_safe = True
13
+
14
+ @property
15
+ def schema(self) -> ToolSchema:
16
+ return ToolSchema(
17
+ name="Glob",
18
+ description=(
19
+ "Find files matching a glob pattern. Returns paths sorted by modification "
20
+ "time (newest first). Supports recursive patterns like '**/*.py'."
21
+ ),
22
+ parameters={
23
+ "type": "object",
24
+ "properties": {
25
+ "pattern": {
26
+ "type": "string",
27
+ "description": "Glob pattern, e.g. '**/*.py' or 'src/**/*.ts'.",
28
+ },
29
+ "path": {
30
+ "type": "string",
31
+ "description": "Root to search from. Defaults to cwd.",
32
+ },
33
+ "max_results": {
34
+ "type": "integer",
35
+ "description": "Cap the result count. Default 500.",
36
+ },
37
+ },
38
+ "required": ["pattern"],
39
+ },
40
+ )
41
+
42
+ async def execute(self, call: ToolCall) -> ToolResult:
43
+ args = call.arguments
44
+ pattern = args.get("pattern", "")
45
+ path = args.get("path", ".")
46
+ max_results = int(args.get("max_results", 500))
47
+
48
+ if not pattern:
49
+ return ToolResult(
50
+ tool_call_id=call.id, content="Error: pattern required", is_error=True
51
+ )
52
+ root = Path(path)
53
+ if not root.exists():
54
+ return ToolResult(
55
+ tool_call_id=call.id,
56
+ content=f"Error: path does not exist: {root}",
57
+ is_error=True,
58
+ )
59
+
60
+ matches = list(root.glob(pattern))
61
+ matches = [p for p in matches if p.is_file()]
62
+ matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
63
+ matches = matches[:max_results]
64
+
65
+ if not matches:
66
+ return ToolResult(tool_call_id=call.id, content="(no matches)")
67
+ return ToolResult(
68
+ tool_call_id=call.id,
69
+ content="\n".join(str(p) for p in matches),
70
+ )
@@ -0,0 +1,117 @@
1
+ """Grep tool — search file contents with ripgrep if available, else Python fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ from plugin_sdk.core import ToolCall, ToolResult
11
+ from plugin_sdk.tool_contract import BaseTool, ToolSchema
12
+
13
+
14
+ class GrepTool(BaseTool):
15
+ parallel_safe = True
16
+
17
+ @property
18
+ def schema(self) -> ToolSchema:
19
+ return ToolSchema(
20
+ name="Grep",
21
+ description=(
22
+ "Search for a regex pattern in files. Uses ripgrep if available, "
23
+ "falls back to pure Python. Returns matching lines with file:line prefix."
24
+ ),
25
+ parameters={
26
+ "type": "object",
27
+ "properties": {
28
+ "pattern": {
29
+ "type": "string",
30
+ "description": "Regex pattern to search for.",
31
+ },
32
+ "path": {
33
+ "type": "string",
34
+ "description": "Directory or file to search in. Defaults to cwd.",
35
+ },
36
+ "glob": {
37
+ "type": "string",
38
+ "description": "Optional glob filter (e.g. '*.py').",
39
+ },
40
+ "case_insensitive": {
41
+ "type": "boolean",
42
+ "description": "Case-insensitive match (-i). Default false.",
43
+ },
44
+ "max_results": {
45
+ "type": "integer",
46
+ "description": "Cap the number of matches. Default 200.",
47
+ },
48
+ },
49
+ "required": ["pattern"],
50
+ },
51
+ )
52
+
53
+ async def execute(self, call: ToolCall) -> ToolResult:
54
+ args = call.arguments
55
+ pattern = args.get("pattern", "")
56
+ path = args.get("path", ".")
57
+ glob = args.get("glob", "")
58
+ case_i = bool(args.get("case_insensitive", False))
59
+ max_results = int(args.get("max_results", 200))
60
+
61
+ if not pattern:
62
+ return ToolResult(tool_call_id=call.id, content="Error: pattern required", is_error=True)
63
+
64
+ rg = shutil.which("rg")
65
+ if rg:
66
+ cmd = [rg, "--no-heading", "--line-number", "--color=never"]
67
+ if case_i:
68
+ cmd.append("-i")
69
+ if glob:
70
+ cmd += ["--glob", glob]
71
+ cmd += ["--max-count", str(max_results)]
72
+ cmd += [pattern, path]
73
+ proc = await asyncio.create_subprocess_exec(
74
+ *cmd,
75
+ stdout=asyncio.subprocess.PIPE,
76
+ stderr=asyncio.subprocess.PIPE,
77
+ )
78
+ out, err = await proc.communicate()
79
+ txt = (out or b"").decode("utf-8", errors="replace")
80
+ return ToolResult(
81
+ tool_call_id=call.id,
82
+ content=txt or "(no matches)",
83
+ )
84
+
85
+ # pure-python fallback
86
+ try:
87
+ regex = re.compile(pattern, re.IGNORECASE if case_i else 0)
88
+ except re.error as e:
89
+ return ToolResult(
90
+ tool_call_id=call.id, content=f"Error: invalid regex: {e}", is_error=True
91
+ )
92
+
93
+ target = Path(path)
94
+ files: list[Path]
95
+ if target.is_file():
96
+ files = [target]
97
+ else:
98
+ files = list(target.rglob(glob or "*"))
99
+ files = [f for f in files if f.is_file()]
100
+
101
+ hits: list[str] = []
102
+ for f in files:
103
+ if len(hits) >= max_results:
104
+ break
105
+ try:
106
+ for i, line in enumerate(f.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
107
+ if regex.search(line):
108
+ hits.append(f"{f}:{i}:{line}")
109
+ if len(hits) >= max_results:
110
+ break
111
+ except Exception:
112
+ continue
113
+
114
+ return ToolResult(
115
+ tool_call_id=call.id,
116
+ content="\n".join(hits) or "(no matches)",
117
+ )