forgex-cli 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.
- forgex/__init__.py +3 -0
- forgex/__main__.py +6 -0
- forgex/agents/__init__.py +36 -0
- forgex/agents/architect.py +233 -0
- forgex/agents/base.py +385 -0
- forgex/agents/builder.py +269 -0
- forgex/agents/ensemble.py +334 -0
- forgex/agents/expert_coder.py +283 -0
- forgex/agents/supervisor.py +303 -0
- forgex/cli/__init__.py +1 -0
- forgex/cli/app.py +232 -0
- forgex/cli/build_views.py +94 -0
- forgex/cli/commands/__init__.py +1 -0
- forgex/cli/commands/build.py +63 -0
- forgex/cli/commands/checkpoint.py +41 -0
- forgex/cli/commands/config.py +225 -0
- forgex/cli/commands/context.py +94 -0
- forgex/cli/commands/diff.py +32 -0
- forgex/cli/commands/do_cmd.py +50 -0
- forgex/cli/commands/doctor.py +175 -0
- forgex/cli/commands/fix.py +46 -0
- forgex/cli/commands/init.py +41 -0
- forgex/cli/commands/logs.py +102 -0
- forgex/cli/commands/models.py +144 -0
- forgex/cli/commands/plan.py +103 -0
- forgex/cli/commands/review.py +76 -0
- forgex/cli/commands/rollback.py +65 -0
- forgex/cli/commands/run.py +75 -0
- forgex/cli/commands/status.py +141 -0
- forgex/cli/commands/supervise.py +61 -0
- forgex/cli/commands/task.py +136 -0
- forgex/cli/commands/tools.py +116 -0
- forgex/cli/commands/verify.py +65 -0
- forgex/cli/context_memory.py +62 -0
- forgex/cli/intent.py +209 -0
- forgex/cli/progress.py +101 -0
- forgex/cli/repl.py +377 -0
- forgex/cli/runtime.py +233 -0
- forgex/cli/session.py +95 -0
- forgex/cli/ui.py +113 -0
- forgex/config/__init__.py +53 -0
- forgex/config/loader.py +189 -0
- forgex/config/schema.py +145 -0
- forgex/context/__init__.py +6 -0
- forgex/context/chunker.py +102 -0
- forgex/context/dependency_graph.py +110 -0
- forgex/context/indexer.py +161 -0
- forgex/context/manager.py +174 -0
- forgex/context/retriever.py +99 -0
- forgex/models/__init__.py +25 -0
- forgex/models/base.py +112 -0
- forgex/models/capabilities.py +21 -0
- forgex/models/factory.py +66 -0
- forgex/models/groq.py +13 -0
- forgex/models/local.py +10 -0
- forgex/models/openai_compatible.py +233 -0
- forgex/models/registry.py +81 -0
- forgex/models/router.py +332 -0
- forgex/orchestration/__init__.py +36 -0
- forgex/orchestration/checkpoints.py +117 -0
- forgex/orchestration/engine.py +573 -0
- forgex/orchestration/events.py +101 -0
- forgex/orchestration/lockfile.py +82 -0
- forgex/orchestration/retry.py +65 -0
- forgex/orchestration/state.py +89 -0
- forgex/orchestration/stores.py +90 -0
- forgex/orchestration/workflow.py +103 -0
- forgex/project.py +209 -0
- forgex/prompts/__init__.py +27 -0
- forgex/prompts/architect.md +18 -0
- forgex/prompts/builder.md +20 -0
- forgex/prompts/expert_coder.md +25 -0
- forgex/prompts/reviewer.md +11 -0
- forgex/prompts/supervisor.md +26 -0
- forgex/research/__init__.py +15 -0
- forgex/research/citations.py +16 -0
- forgex/research/engine.py +83 -0
- forgex/research/sources.py +154 -0
- forgex/sandbox/__init__.py +37 -0
- forgex/sandbox/executor.py +418 -0
- forgex/sandbox/permissions.py +67 -0
- forgex/sandbox/policies.py +172 -0
- forgex/schemas/__init__.py +101 -0
- forgex/schemas/agent.py +109 -0
- forgex/schemas/checkpoint.py +19 -0
- forgex/schemas/error.py +125 -0
- forgex/schemas/event.py +76 -0
- forgex/schemas/plan.py +84 -0
- forgex/schemas/project.py +92 -0
- forgex/schemas/research.py +25 -0
- forgex/schemas/result.py +68 -0
- forgex/schemas/task.py +118 -0
- forgex/storage/__init__.py +25 -0
- forgex/storage/database.py +40 -0
- forgex/storage/migrations.py +130 -0
- forgex/storage/repositories.py +492 -0
- forgex/tools/__init__.py +73 -0
- forgex/tools/base.py +212 -0
- forgex/tools/dependencies.py +137 -0
- forgex/tools/filesystem.py +322 -0
- forgex/tools/git.py +239 -0
- forgex/tools/project.py +39 -0
- forgex/tools/search.py +205 -0
- forgex/tools/terminal.py +81 -0
- forgex/tools/testing.py +100 -0
- forgex/tools/verification.py +174 -0
- forgex/utils/__init__.py +15 -0
- forgex/utils/logging.py +28 -0
- forgex/utils/paths.py +48 -0
- forgex/utils/redact.py +44 -0
- forgex_cli-0.1.0.dist-info/METADATA +189 -0
- forgex_cli-0.1.0.dist-info/RECORD +115 -0
- forgex_cli-0.1.0.dist-info/WHEEL +4 -0
- forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
- forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
forgex/tools/base.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Tool framework (spec sections 10, 18, 32).
|
|
2
|
+
|
|
3
|
+
Tools are the ONLY way agents touch the system. Every call passes
|
|
4
|
+
through ToolExecutor, which enforces the per-agent permission matrix.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field, model_validator
|
|
19
|
+
|
|
20
|
+
from forgex.config.schema import ForgexConfig
|
|
21
|
+
from forgex.orchestration.events import EventRecorder
|
|
22
|
+
from forgex.sandbox.executor import CommandExecutor
|
|
23
|
+
from forgex.sandbox.permissions import Permission, PermissionSet
|
|
24
|
+
from forgex.schemas.agent import AgentRole
|
|
25
|
+
from forgex.schemas.error import ForgexError, PermissionDeniedError
|
|
26
|
+
from forgex.schemas.event import EventType
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("forgex.tools")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ToolCall(BaseModel):
|
|
32
|
+
id: str = ""
|
|
33
|
+
name: str
|
|
34
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="before")
|
|
37
|
+
@classmethod
|
|
38
|
+
def _parse_arguments(cls, data: Any) -> Any:
|
|
39
|
+
if isinstance(data, dict):
|
|
40
|
+
args = data.get("arguments")
|
|
41
|
+
if isinstance(args, str):
|
|
42
|
+
try:
|
|
43
|
+
data["arguments"] = json.loads(args) if args.strip() else {}
|
|
44
|
+
except Exception:
|
|
45
|
+
data["arguments"] = {}
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ToolResult(BaseModel):
|
|
50
|
+
ok: bool = True
|
|
51
|
+
output: str = ""
|
|
52
|
+
error: str | None = None
|
|
53
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
54
|
+
denied: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ToolContext:
|
|
59
|
+
workspace: Path
|
|
60
|
+
config: ForgexConfig
|
|
61
|
+
executor: CommandExecutor
|
|
62
|
+
events: EventRecorder | None = None
|
|
63
|
+
approval: Callable[[str], bool] | None = None
|
|
64
|
+
run_id: str | None = None
|
|
65
|
+
agent: AgentRole | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Tool(ABC):
|
|
69
|
+
name: str
|
|
70
|
+
description: str
|
|
71
|
+
required_permissions: set[Permission]
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def parameters_schema(self) -> dict[str, Any]:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
async def execute(self, call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
ToolHandler = Callable[[ToolCall, ToolContext], Awaitable[ToolResult]]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SimpleTool(Tool):
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
name: str,
|
|
89
|
+
handler: ToolHandler,
|
|
90
|
+
permissions: set[Permission],
|
|
91
|
+
description: str,
|
|
92
|
+
parameters: dict[str, Any],
|
|
93
|
+
) -> None:
|
|
94
|
+
self.name = name
|
|
95
|
+
self._handler = handler
|
|
96
|
+
self.required_permissions = permissions
|
|
97
|
+
self.description = description
|
|
98
|
+
self._parameters = parameters
|
|
99
|
+
|
|
100
|
+
def parameters_schema(self) -> dict[str, Any]:
|
|
101
|
+
return self._parameters
|
|
102
|
+
|
|
103
|
+
async def execute(self, call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
104
|
+
return await self._handler(call, ctx)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ToolRegistry:
|
|
108
|
+
def __init__(self) -> None:
|
|
109
|
+
self._tools: dict[str, Tool] = {}
|
|
110
|
+
|
|
111
|
+
def register(self, tool: Tool) -> None:
|
|
112
|
+
self._tools[tool.name] = tool
|
|
113
|
+
|
|
114
|
+
def get(self, name: str) -> Tool | None:
|
|
115
|
+
return self._tools.get(name)
|
|
116
|
+
|
|
117
|
+
def tools(self) -> list[Tool]:
|
|
118
|
+
return list(self._tools.values())
|
|
119
|
+
|
|
120
|
+
def schemas_for(self, permissions: PermissionSet) -> list[dict[str, Any]]:
|
|
121
|
+
schemas: list[dict[str, Any]] = []
|
|
122
|
+
for tool in self._tools.values():
|
|
123
|
+
if permissions.permits(*tool.required_permissions):
|
|
124
|
+
schemas.append(
|
|
125
|
+
{
|
|
126
|
+
"type": "function",
|
|
127
|
+
"function": {
|
|
128
|
+
"name": tool.name,
|
|
129
|
+
"description": tool.description,
|
|
130
|
+
"parameters": tool.parameters_schema(),
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
return schemas
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ToolExecutor:
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
registry: ToolRegistry,
|
|
141
|
+
permissions: PermissionSet,
|
|
142
|
+
context: ToolContext,
|
|
143
|
+
) -> None:
|
|
144
|
+
self._registry = registry
|
|
145
|
+
self._permissions = permissions
|
|
146
|
+
self._context = context
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def context(self) -> ToolContext:
|
|
150
|
+
return self._context
|
|
151
|
+
|
|
152
|
+
def schemas(self) -> list[dict[str, Any]]:
|
|
153
|
+
return self._registry.schemas_for(self._permissions)
|
|
154
|
+
|
|
155
|
+
async def execute(self, call: ToolCall) -> ToolResult:
|
|
156
|
+
tool = self._registry.get(call.name)
|
|
157
|
+
if tool is None:
|
|
158
|
+
return ToolResult(ok=False, error=f"Unknown tool: '{call.name}'")
|
|
159
|
+
|
|
160
|
+
if not self._permissions.permits(*tool.required_permissions):
|
|
161
|
+
reason = self._permissions.deny_reason(*tool.required_permissions) or "denied"
|
|
162
|
+
msg = f"Permission denied for tool '{call.name}': {reason}"
|
|
163
|
+
if self._context.events is not None:
|
|
164
|
+
self._context.events.record(
|
|
165
|
+
EventType.TOOL_STARTED,
|
|
166
|
+
{"tool": call.name, "arguments": call.arguments},
|
|
167
|
+
run_id=self._context.run_id,
|
|
168
|
+
)
|
|
169
|
+
self._context.events.record(
|
|
170
|
+
EventType.TOOL_COMPLETED,
|
|
171
|
+
{"tool": call.name, "ok": False, "denied": True, "error": msg},
|
|
172
|
+
run_id=self._context.run_id,
|
|
173
|
+
)
|
|
174
|
+
raise PermissionDeniedError(msg)
|
|
175
|
+
|
|
176
|
+
if self._context.events is not None:
|
|
177
|
+
self._context.events.record(
|
|
178
|
+
EventType.TOOL_STARTED,
|
|
179
|
+
{"tool": call.name, "arguments": call.arguments},
|
|
180
|
+
run_id=self._context.run_id,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
start = time.perf_counter()
|
|
184
|
+
try:
|
|
185
|
+
result = await tool.execute(call, self._context)
|
|
186
|
+
except ForgexError as exc:
|
|
187
|
+
result = ToolResult(ok=False, error=str(exc))
|
|
188
|
+
except Exception as exc: # noqa: BLE001
|
|
189
|
+
result = ToolResult(ok=False, error=f"Tool execution failed: {exc}")
|
|
190
|
+
|
|
191
|
+
duration = (time.perf_counter() - start) * 1000
|
|
192
|
+
|
|
193
|
+
if self._context.events is not None:
|
|
194
|
+
if result.ok and call.name in ("write_file", "create_file", "delete_file", "move_file"):
|
|
195
|
+
target = call.arguments.get("path") or call.arguments.get("destination")
|
|
196
|
+
self._context.events.record(
|
|
197
|
+
EventType.FILE_CHANGED,
|
|
198
|
+
{"tool": call.name, "path": target},
|
|
199
|
+
run_id=self._context.run_id,
|
|
200
|
+
)
|
|
201
|
+
self._context.events.record(
|
|
202
|
+
EventType.TOOL_COMPLETED,
|
|
203
|
+
{
|
|
204
|
+
"tool": call.name,
|
|
205
|
+
"ok": result.ok,
|
|
206
|
+
"error": result.error,
|
|
207
|
+
"duration_ms": round(duration, 1),
|
|
208
|
+
},
|
|
209
|
+
run_id=self._context.run_id,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return result
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""inspect_dependencies tool (spec section 49) — language-agnostic manifest parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import tomllib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from forgex.sandbox.permissions import Permission
|
|
12
|
+
from forgex.tools.base import SimpleTool, Tool, ToolCall, ToolContext, ToolResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _dep(name: str, spec: Any, *, dev: bool, source: str) -> dict[str, Any]:
|
|
16
|
+
return {
|
|
17
|
+
"name": name,
|
|
18
|
+
"spec": spec if isinstance(spec, str) and spec else None,
|
|
19
|
+
"dev": dev,
|
|
20
|
+
"source": source,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _python_deps(root: Path) -> list[dict[str, Any]]:
|
|
25
|
+
deps: list[dict[str, Any]] = []
|
|
26
|
+
|
|
27
|
+
pyproject = root / "pyproject.toml"
|
|
28
|
+
if pyproject.is_file():
|
|
29
|
+
try:
|
|
30
|
+
with pyproject.open("rb") as fh:
|
|
31
|
+
data = tomllib.load(fh)
|
|
32
|
+
project = data.get("project") or {}
|
|
33
|
+
for entry in project.get("dependencies") or []:
|
|
34
|
+
name = re.split(r"[=<>!~^ ;\[]", str(entry), maxsplit=1)[0]
|
|
35
|
+
deps.append(_dep(name, str(entry), dev=False, source="pyproject.toml"))
|
|
36
|
+
for group, items in (project.get("optional-dependencies") or {}).items():
|
|
37
|
+
for entry in items:
|
|
38
|
+
name = re.split(r"[=<>!~^ ;\[]", str(entry), maxsplit=1)[0]
|
|
39
|
+
deps.append(_dep(name, str(entry), dev=(group == "dev"), source="pyproject.toml"))
|
|
40
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
reqs = root / "requirements.txt"
|
|
44
|
+
if reqs.is_file():
|
|
45
|
+
try:
|
|
46
|
+
for line in reqs.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
47
|
+
line = line.strip()
|
|
48
|
+
if not line or line.startswith("#"):
|
|
49
|
+
continue
|
|
50
|
+
name = re.split(r"[=<>!~^ ;]", line, maxsplit=1)[0]
|
|
51
|
+
deps.append(_dep(name, line, dev=False, source="requirements.txt"))
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
return deps
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _node_deps(root: Path) -> list[dict[str, Any]]:
|
|
59
|
+
deps: list[dict[str, Any]] = []
|
|
60
|
+
pkg = root / "package.json"
|
|
61
|
+
if pkg.is_file():
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
64
|
+
for name, spec in (data.get("dependencies") or {}).items():
|
|
65
|
+
deps.append(_dep(name, str(spec), dev=False, source="package.json"))
|
|
66
|
+
for name, spec in (data.get("devDependencies") or {}).items():
|
|
67
|
+
deps.append(_dep(name, str(spec), dev=True, source="package.json"))
|
|
68
|
+
except (OSError, json.JSONDecodeError):
|
|
69
|
+
pass
|
|
70
|
+
return deps
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cargo_deps(root: Path) -> list[dict[str, Any]]:
|
|
74
|
+
deps: list[dict[str, Any]] = []
|
|
75
|
+
cargo = root / "Cargo.toml"
|
|
76
|
+
if cargo.is_file():
|
|
77
|
+
try:
|
|
78
|
+
with cargo.open("rb") as fh:
|
|
79
|
+
data = tomllib.load(fh)
|
|
80
|
+
for name, spec in (data.get("dependencies") or {}).items():
|
|
81
|
+
deps.append(_dep(name, str(spec), dev=False, source="Cargo.toml"))
|
|
82
|
+
for name, spec in (data.get("dev-dependencies") or {}).items():
|
|
83
|
+
deps.append(_dep(name, str(spec), dev=True, source="Cargo.toml"))
|
|
84
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
85
|
+
pass
|
|
86
|
+
return deps
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _go_deps(root: Path) -> list[dict[str, Any]]:
|
|
90
|
+
deps: list[dict[str, Any]] = []
|
|
91
|
+
mod = root / "go.mod"
|
|
92
|
+
if mod.is_file():
|
|
93
|
+
try:
|
|
94
|
+
content = mod.read_text(encoding="utf-8", errors="replace")
|
|
95
|
+
in_require = False
|
|
96
|
+
for line in content.splitlines():
|
|
97
|
+
line = line.strip()
|
|
98
|
+
if line.startswith("require ("):
|
|
99
|
+
in_require = True
|
|
100
|
+
continue
|
|
101
|
+
if in_require and line == ")":
|
|
102
|
+
in_require = False
|
|
103
|
+
continue
|
|
104
|
+
if in_require or line.startswith("require "):
|
|
105
|
+
if line.startswith("require "):
|
|
106
|
+
line = line[8:].strip()
|
|
107
|
+
parts = line.split()
|
|
108
|
+
if len(parts) >= 2:
|
|
109
|
+
deps.append(_dep(parts[0], parts[1], dev=False, source="go.mod"))
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
return deps
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _inspect_dependencies(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
116
|
+
root = ctx.workspace
|
|
117
|
+
all_deps: list[dict[str, Any]] = []
|
|
118
|
+
all_deps.extend(_python_deps(root))
|
|
119
|
+
all_deps.extend(_node_deps(root))
|
|
120
|
+
all_deps.extend(_cargo_deps(root))
|
|
121
|
+
all_deps.extend(_go_deps(root))
|
|
122
|
+
|
|
123
|
+
lines = [f"{d['name']} ({d['spec'] or 'any'}) [source: {d['source']}{', dev' if d['dev'] else ''}]" for d in all_deps]
|
|
124
|
+
output = "\n".join(lines) if lines else "(no dependencies declared)"
|
|
125
|
+
return ToolResult(ok=True, output=output, data={"dependencies": all_deps})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def create_tools() -> list[Tool]:
|
|
129
|
+
return [
|
|
130
|
+
SimpleTool(
|
|
131
|
+
name="inspect_dependencies",
|
|
132
|
+
handler=_inspect_dependencies,
|
|
133
|
+
permissions={Permission.READ},
|
|
134
|
+
description="Inspect declared project dependencies across pyproject, package.json, Cargo.toml, go.mod.",
|
|
135
|
+
parameters={"type": "object", "properties": {}, "additionalProperties": False},
|
|
136
|
+
)
|
|
137
|
+
]
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Filesystem tools (spec sections 18, 46, 75).
|
|
2
|
+
|
|
3
|
+
Every agent filesystem operation routes through resolve_in_workspace().
|
|
4
|
+
Protected paths (.forgex/, .git/, workspace root) and secret files (.env*, keys)
|
|
5
|
+
are guarded.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from forgex.sandbox.permissions import Permission
|
|
14
|
+
from forgex.schemas.error import PathViolationError, ToolError
|
|
15
|
+
from forgex.tools.base import SimpleTool, Tool, ToolCall, ToolContext, ToolResult
|
|
16
|
+
from forgex.utils.paths import resolve_in_workspace
|
|
17
|
+
from forgex.utils.redact import redact_patterns
|
|
18
|
+
|
|
19
|
+
IGNORED_DIRS: set[str] = {
|
|
20
|
+
".git",
|
|
21
|
+
".forgex",
|
|
22
|
+
"node_modules",
|
|
23
|
+
".venv",
|
|
24
|
+
"venv",
|
|
25
|
+
"__pycache__",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
".mypy_cache",
|
|
29
|
+
".ruff_cache",
|
|
30
|
+
".pytest_cache",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_SECRET_FILE_PATTERNS = [
|
|
34
|
+
".env*",
|
|
35
|
+
"*.pem",
|
|
36
|
+
"id_rsa*",
|
|
37
|
+
"id_ed25519*",
|
|
38
|
+
".npmrc",
|
|
39
|
+
"*.key",
|
|
40
|
+
"*credentials*",
|
|
41
|
+
"*secret*",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
MAX_READ_BYTES = 200_000
|
|
45
|
+
MAX_READ_LINES = 2000
|
|
46
|
+
MAX_LIST_COUNT = 500
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_secret_file(name: str) -> bool:
|
|
50
|
+
lower = name.lower()
|
|
51
|
+
return any(fnmatch.fnmatch(lower, pat) for pat in _SECRET_FILE_PATTERNS)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolve_path(ctx: ToolContext, raw: str) -> Path:
|
|
55
|
+
try:
|
|
56
|
+
return resolve_in_workspace(ctx.workspace, raw)
|
|
57
|
+
except PathViolationError as exc:
|
|
58
|
+
raise ToolError(str(exc)) from exc
|
|
59
|
+
except (ValueError, OSError) as exc:
|
|
60
|
+
raise ToolError(f"invalid path: {exc}") from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rel(ctx: ToolContext, path: Path) -> str:
|
|
64
|
+
try:
|
|
65
|
+
return str(path.relative_to(ctx.workspace))
|
|
66
|
+
except ValueError:
|
|
67
|
+
return str(path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _assert_mutable(ctx: ToolContext, path: Path, raw: str) -> None:
|
|
71
|
+
if path == ctx.workspace.resolve():
|
|
72
|
+
raise ToolError("refusing to modify the workspace root")
|
|
73
|
+
|
|
74
|
+
lexical = Path(raw)
|
|
75
|
+
if not lexical.is_absolute():
|
|
76
|
+
lexical = ctx.workspace / lexical
|
|
77
|
+
if lexical.is_symlink():
|
|
78
|
+
raise ToolError(f"refusing to modify through a symlink: {raw}")
|
|
79
|
+
|
|
80
|
+
rel = Path(_rel(ctx, path))
|
|
81
|
+
top = rel.parts[0] if rel.parts else ""
|
|
82
|
+
if top in {".forgex", ".git"}:
|
|
83
|
+
raise ToolError("refusing to modify protected path (.forgex/ and .git/ are system-owned)")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _list_files(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
87
|
+
sub = str(call.arguments.get("path", ".") or ".")
|
|
88
|
+
pattern = call.arguments.get("pattern")
|
|
89
|
+
target = _resolve_path(ctx, sub)
|
|
90
|
+
|
|
91
|
+
if not target.is_dir():
|
|
92
|
+
return ToolResult(ok=False, error=f"Not a directory: {sub}")
|
|
93
|
+
|
|
94
|
+
entries: list[str] = []
|
|
95
|
+
truncated = False
|
|
96
|
+
|
|
97
|
+
for item in sorted(target.rglob("*")):
|
|
98
|
+
# Skip ignored directories
|
|
99
|
+
rel = item.relative_to(ctx.workspace)
|
|
100
|
+
parts = rel.parts
|
|
101
|
+
if any(p in IGNORED_DIRS for p in parts):
|
|
102
|
+
continue
|
|
103
|
+
if item.is_file():
|
|
104
|
+
if pattern and not fnmatch.fnmatch(item.name, pattern):
|
|
105
|
+
continue
|
|
106
|
+
entries.append(rel.as_posix())
|
|
107
|
+
if len(entries) >= MAX_LIST_COUNT:
|
|
108
|
+
truncated = True
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
lines = list(entries)
|
|
112
|
+
if truncated:
|
|
113
|
+
lines.append(f"... [truncated at {MAX_LIST_COUNT} entries]")
|
|
114
|
+
|
|
115
|
+
return ToolResult(
|
|
116
|
+
ok=True,
|
|
117
|
+
output="\n".join(lines) or "(empty)",
|
|
118
|
+
data={"count": len(entries), "truncated": truncated},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _read_file(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
123
|
+
path_arg = call.arguments.get("path")
|
|
124
|
+
if not path_arg:
|
|
125
|
+
return ToolResult(ok=False, error="missing required argument: path")
|
|
126
|
+
|
|
127
|
+
target = _resolve_path(ctx, str(path_arg))
|
|
128
|
+
|
|
129
|
+
if is_secret_file(target.name):
|
|
130
|
+
return ToolResult(ok=False, error=f"refusing to read secret-bearing file: {target.name}")
|
|
131
|
+
|
|
132
|
+
if not target.is_file():
|
|
133
|
+
return ToolResult(ok=False, error=f"file not found: {path_arg}")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
raw_bytes = target.read_bytes()
|
|
137
|
+
except OSError as exc:
|
|
138
|
+
return ToolResult(ok=False, error=f"failed to read file: {exc}")
|
|
139
|
+
|
|
140
|
+
# Redact patterns before processing
|
|
141
|
+
text = redact_patterns(raw_bytes[:MAX_READ_BYTES].decode("utf-8", errors="replace"))
|
|
142
|
+
lines = text.splitlines()
|
|
143
|
+
|
|
144
|
+
truncated = False
|
|
145
|
+
if len(raw_bytes) > MAX_READ_BYTES or len(lines) > MAX_READ_LINES:
|
|
146
|
+
truncated = True
|
|
147
|
+
lines = lines[:MAX_READ_LINES]
|
|
148
|
+
lines.append("... [file truncated]")
|
|
149
|
+
|
|
150
|
+
return ToolResult(
|
|
151
|
+
ok=True,
|
|
152
|
+
output="\n".join(lines),
|
|
153
|
+
data={"path": _rel(ctx, target), "lines": len(lines), "truncated": truncated},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _write_file(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
158
|
+
path_arg = call.arguments.get("path")
|
|
159
|
+
content = call.arguments.get("content")
|
|
160
|
+
if path_arg is None or content is None:
|
|
161
|
+
return ToolResult(ok=False, error="missing path or content argument")
|
|
162
|
+
|
|
163
|
+
target = _resolve_path(ctx, str(path_arg))
|
|
164
|
+
_assert_mutable(ctx, target, str(path_arg))
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
target.write_text(str(content), encoding="utf-8")
|
|
169
|
+
return ToolResult(ok=True, output=f"wrote {len(content)} characters to {path_arg}")
|
|
170
|
+
except OSError as exc:
|
|
171
|
+
return ToolResult(ok=False, error=f"failed to write file: {exc}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def _create_file(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
175
|
+
path_arg = call.arguments.get("path")
|
|
176
|
+
content = call.arguments.get("content", "")
|
|
177
|
+
if path_arg is None:
|
|
178
|
+
return ToolResult(ok=False, error="missing path argument")
|
|
179
|
+
|
|
180
|
+
target = _resolve_path(ctx, str(path_arg))
|
|
181
|
+
_assert_mutable(ctx, target, str(path_arg))
|
|
182
|
+
|
|
183
|
+
if target.exists():
|
|
184
|
+
return ToolResult(ok=False, error=f"file already exists: {path_arg}")
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
target.write_text(str(content), encoding="utf-8")
|
|
189
|
+
return ToolResult(ok=True, output=f"created file {path_arg}")
|
|
190
|
+
except OSError as exc:
|
|
191
|
+
return ToolResult(ok=False, error=f"failed to create file: {exc}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def _delete_file(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
195
|
+
path_arg = call.arguments.get("path")
|
|
196
|
+
if not path_arg:
|
|
197
|
+
return ToolResult(ok=False, error="missing path argument")
|
|
198
|
+
|
|
199
|
+
target = _resolve_path(ctx, str(path_arg))
|
|
200
|
+
_assert_mutable(ctx, target, str(path_arg))
|
|
201
|
+
|
|
202
|
+
if not target.exists():
|
|
203
|
+
return ToolResult(ok=False, error=f"file not found: {path_arg}")
|
|
204
|
+
if target.is_dir():
|
|
205
|
+
return ToolResult(ok=False, error=f"refusing to delete directory: {path_arg}")
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
target.unlink()
|
|
209
|
+
return ToolResult(ok=True, output=f"deleted file {path_arg}")
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
return ToolResult(ok=False, error=f"failed to delete file: {exc}")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def _move_file(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
215
|
+
src_arg = call.arguments.get("source")
|
|
216
|
+
dst_arg = call.arguments.get("destination")
|
|
217
|
+
if not src_arg or not dst_arg:
|
|
218
|
+
return ToolResult(ok=False, error="missing source or destination argument")
|
|
219
|
+
|
|
220
|
+
src = _resolve_path(ctx, str(src_arg))
|
|
221
|
+
dst = _resolve_path(ctx, str(dst_arg))
|
|
222
|
+
_assert_mutable(ctx, src, str(src_arg))
|
|
223
|
+
_assert_mutable(ctx, dst, str(dst_arg))
|
|
224
|
+
|
|
225
|
+
if not src.exists():
|
|
226
|
+
return ToolResult(ok=False, error=f"source file not found: {src_arg}")
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
src.rename(dst)
|
|
231
|
+
return ToolResult(ok=True, output=f"moved {src_arg} -> {dst_arg}")
|
|
232
|
+
except OSError as exc:
|
|
233
|
+
return ToolResult(ok=False, error=f"failed to move file: {exc}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def create_tools() -> list[Tool]:
|
|
237
|
+
path_prop = {"type": "string", "description": "Workspace-relative file path"}
|
|
238
|
+
return [
|
|
239
|
+
SimpleTool(
|
|
240
|
+
name="list_files",
|
|
241
|
+
handler=_list_files,
|
|
242
|
+
permissions={Permission.READ},
|
|
243
|
+
description="List files in the workspace (ignores git/cache/build noise).",
|
|
244
|
+
parameters={
|
|
245
|
+
"type": "object",
|
|
246
|
+
"properties": {
|
|
247
|
+
"path": {"type": "string", "description": "Subdirectory (default: workspace root)"},
|
|
248
|
+
"pattern": {"type": "string", "description": "Optional glob filter on file names"},
|
|
249
|
+
},
|
|
250
|
+
"additionalProperties": False,
|
|
251
|
+
},
|
|
252
|
+
),
|
|
253
|
+
SimpleTool(
|
|
254
|
+
name="read_file",
|
|
255
|
+
handler=_read_file,
|
|
256
|
+
permissions={Permission.READ},
|
|
257
|
+
description="Read a file (capped at 200KB / 2000 lines; secret files like .env are refused).",
|
|
258
|
+
parameters={
|
|
259
|
+
"type": "object",
|
|
260
|
+
"properties": {"path": path_prop},
|
|
261
|
+
"required": ["path"],
|
|
262
|
+
"additionalProperties": False,
|
|
263
|
+
},
|
|
264
|
+
),
|
|
265
|
+
SimpleTool(
|
|
266
|
+
name="write_file",
|
|
267
|
+
handler=_write_file,
|
|
268
|
+
permissions={Permission.WRITE},
|
|
269
|
+
description="Write a file (overwrites; creates parent directories).",
|
|
270
|
+
parameters={
|
|
271
|
+
"type": "object",
|
|
272
|
+
"properties": {
|
|
273
|
+
"path": path_prop,
|
|
274
|
+
"content": {"type": "string"},
|
|
275
|
+
},
|
|
276
|
+
"required": ["path", "content"],
|
|
277
|
+
"additionalProperties": False,
|
|
278
|
+
},
|
|
279
|
+
),
|
|
280
|
+
SimpleTool(
|
|
281
|
+
name="create_file",
|
|
282
|
+
handler=_create_file,
|
|
283
|
+
permissions={Permission.WRITE},
|
|
284
|
+
description="Create a new file; fails if it already exists.",
|
|
285
|
+
parameters={
|
|
286
|
+
"type": "object",
|
|
287
|
+
"properties": {
|
|
288
|
+
"path": path_prop,
|
|
289
|
+
"content": {"type": "string"},
|
|
290
|
+
},
|
|
291
|
+
"required": ["path", "content"],
|
|
292
|
+
"additionalProperties": False,
|
|
293
|
+
},
|
|
294
|
+
),
|
|
295
|
+
SimpleTool(
|
|
296
|
+
name="delete_file",
|
|
297
|
+
handler=_delete_file,
|
|
298
|
+
permissions={Permission.DELETE},
|
|
299
|
+
description="Delete a single file (never directories).",
|
|
300
|
+
parameters={
|
|
301
|
+
"type": "object",
|
|
302
|
+
"properties": {"path": path_prop},
|
|
303
|
+
"required": ["path"],
|
|
304
|
+
"additionalProperties": False,
|
|
305
|
+
},
|
|
306
|
+
),
|
|
307
|
+
SimpleTool(
|
|
308
|
+
name="move_file",
|
|
309
|
+
handler=_move_file,
|
|
310
|
+
permissions={Permission.WRITE},
|
|
311
|
+
description="Move or rename a file inside the workspace.",
|
|
312
|
+
parameters={
|
|
313
|
+
"type": "object",
|
|
314
|
+
"properties": {
|
|
315
|
+
"source": path_prop,
|
|
316
|
+
"destination": path_prop,
|
|
317
|
+
},
|
|
318
|
+
"required": ["source", "destination"],
|
|
319
|
+
"additionalProperties": False,
|
|
320
|
+
},
|
|
321
|
+
),
|
|
322
|
+
]
|