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.
Files changed (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. 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
+ ]