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/git.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Git primitives and agent tools (spec section 21).
|
|
2
|
+
|
|
3
|
+
The checkpoint system: `git_checkpoint` commits all current work with a
|
|
4
|
+
'forgex:' message (identity falls back to FORGEX <forgex@localhost> when
|
|
5
|
+
the user has none configured). Non-destructive: commits preserve work.
|
|
6
|
+
Checkpoint metadata + `forgex checkpoint/rollback` CLI arrive in Phase 6.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("forgex.git")
|
|
18
|
+
|
|
19
|
+
MAX_DIFF_BYTES = 50_000
|
|
20
|
+
_EXCLUDE_FORGEX = [":(exclude).forgex"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class GitResult:
|
|
25
|
+
rc: int
|
|
26
|
+
stdout: str
|
|
27
|
+
stderr: str
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def ok(self) -> bool:
|
|
31
|
+
return self.rc == 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _git(args: list[str], cwd: Path) -> GitResult | None:
|
|
35
|
+
try:
|
|
36
|
+
proc = subprocess.run(
|
|
37
|
+
["git", *args],
|
|
38
|
+
cwd=cwd,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
timeout=30,
|
|
42
|
+
check=False,
|
|
43
|
+
)
|
|
44
|
+
return GitResult(rc=proc.returncode, stdout=proc.stdout, stderr=proc.stderr)
|
|
45
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def has_git_repo(root: Path) -> bool:
|
|
50
|
+
return (root / ".git").exists()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def current_branch(root: Path) -> str | None:
|
|
54
|
+
r = _git(["rev-parse", "--abbrev-ref", "HEAD"], root)
|
|
55
|
+
return r.stdout.strip() if r and r.ok else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_dirty(root: Path) -> bool | None:
|
|
59
|
+
r = _git(["status", "--porcelain"], root)
|
|
60
|
+
if r is None or not r.ok:
|
|
61
|
+
return None
|
|
62
|
+
lines = [ln for ln in r.stdout.splitlines() if ".forgex" not in ln]
|
|
63
|
+
return bool(lines)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def git_status(root: Path) -> str:
|
|
67
|
+
if not has_git_repo(root):
|
|
68
|
+
return "not a git repository"
|
|
69
|
+
r = _git(["status", "--short"], root)
|
|
70
|
+
return r.stdout.strip() if r and r.ok else "git status failed"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def git_diff(root: Path, *, staged: bool = False) -> str:
|
|
74
|
+
if not has_git_repo(root):
|
|
75
|
+
return "not a git repository"
|
|
76
|
+
args = ["diff"] + (["--cached"] if staged else []) + ["--", "."] + _EXCLUDE_FORGEX
|
|
77
|
+
r = _git(args, root)
|
|
78
|
+
if r is None or not r.ok:
|
|
79
|
+
return "git diff failed"
|
|
80
|
+
out = r.stdout
|
|
81
|
+
if len(out) > MAX_DIFF_BYTES:
|
|
82
|
+
out = out[:MAX_DIFF_BYTES] + "\n... [diff truncated]"
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def git_diff_range(root: Path, base_commit: str) -> str:
|
|
87
|
+
if not has_git_repo(root):
|
|
88
|
+
return "not a git repository"
|
|
89
|
+
args = ["diff", base_commit, "HEAD", "--", "."] + _EXCLUDE_FORGEX
|
|
90
|
+
r = _git(args, root)
|
|
91
|
+
if r is None or not r.ok:
|
|
92
|
+
return "(no diff available)"
|
|
93
|
+
out = r.stdout
|
|
94
|
+
if len(out) > MAX_DIFF_BYTES:
|
|
95
|
+
out = out[:MAX_DIFF_BYTES] + "\n... [diff truncated]"
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def git_diff_stat(root: Path, target_commit: str) -> str:
|
|
100
|
+
r = _git(["diff", "--stat", "HEAD", target_commit], root)
|
|
101
|
+
return r.stdout.strip() if r and r.ok else "(no diff preview available)"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def git_changed_files(root: Path, base_commit: str | None = None) -> list[str]:
|
|
105
|
+
if not has_git_repo(root):
|
|
106
|
+
return []
|
|
107
|
+
if base_commit:
|
|
108
|
+
r = _git(["diff", "--name-only", base_commit, "HEAD", "--", "."] + _EXCLUDE_FORGEX, root)
|
|
109
|
+
if r is None or not r.ok:
|
|
110
|
+
return []
|
|
111
|
+
return [ln.strip() for ln in r.stdout.splitlines() if ln.strip()]
|
|
112
|
+
|
|
113
|
+
r = _git(["status", "--porcelain"], root)
|
|
114
|
+
if r is None or not r.ok:
|
|
115
|
+
return []
|
|
116
|
+
files: list[str] = []
|
|
117
|
+
for line in r.stdout.splitlines():
|
|
118
|
+
if not line.strip():
|
|
119
|
+
continue
|
|
120
|
+
# Format: XY filename or XY orig -> new
|
|
121
|
+
name = line[3:].strip()
|
|
122
|
+
if " -> " in name:
|
|
123
|
+
name = name.split(" -> ")[1].strip()
|
|
124
|
+
if name.startswith(".forgex/") or name == ".forgex":
|
|
125
|
+
continue
|
|
126
|
+
files.append(name)
|
|
127
|
+
return files
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def git_log(root: Path, limit: int = 10) -> str:
|
|
131
|
+
if not has_git_repo(root):
|
|
132
|
+
return "not a git repository"
|
|
133
|
+
r = _git(["log", f"-n{limit}", "--format=%H %s"], root)
|
|
134
|
+
return r.stdout.strip() if r and r.ok else "git log failed"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _has_user_identity(root: Path) -> bool:
|
|
138
|
+
name = _git(["config", "user.name"], root)
|
|
139
|
+
email = _git(["config", "user.email"], root)
|
|
140
|
+
return bool(name and name.ok and name.stdout.strip() and email and email.ok and email.stdout.strip())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _ensure_git_exclude_forgex(root: Path) -> None:
|
|
144
|
+
exclude_file = root / ".git" / "info" / "exclude"
|
|
145
|
+
if exclude_file.is_file():
|
|
146
|
+
try:
|
|
147
|
+
content = exclude_file.read_text(encoding="utf-8")
|
|
148
|
+
if ".forgex" not in content:
|
|
149
|
+
exclude_file.write_text(content.rstrip() + "\n.forgex\n.forgex/**\n", encoding="utf-8")
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def git_checkpoint(root: Path, label: str) -> str | None:
|
|
155
|
+
if not has_git_repo(root):
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
_ensure_git_exclude_forgex(root)
|
|
159
|
+
|
|
160
|
+
# Check current HEAD commit before committing
|
|
161
|
+
head_before = _git(["rev-parse", "HEAD"], root)
|
|
162
|
+
|
|
163
|
+
_git(["add", "-A", "--", ".", ":(exclude).forgex"], root)
|
|
164
|
+
commit_args = ["commit", "-m", f"forgex: {label}", "--allow-empty-message"]
|
|
165
|
+
if not _has_user_identity(root):
|
|
166
|
+
commit_args = [
|
|
167
|
+
"-c", "user.name=FORGEX",
|
|
168
|
+
"-c", "user.email=forgex@localhost",
|
|
169
|
+
*commit_args,
|
|
170
|
+
]
|
|
171
|
+
_git(commit_args, root)
|
|
172
|
+
head_after = _git(["rev-parse", "HEAD"], root)
|
|
173
|
+
|
|
174
|
+
if head_after and head_after.ok:
|
|
175
|
+
return head_after.stdout.strip()
|
|
176
|
+
return head_before.stdout.strip() if head_before and head_before.ok else None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def git_reset_hard(root: Path, commit: str) -> bool:
|
|
180
|
+
r = _git(["reset", "--hard", commit], root)
|
|
181
|
+
return bool(r and r.ok)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Agent tools factory
|
|
185
|
+
def create_tools() -> list[Any]:
|
|
186
|
+
from forgex.sandbox.permissions import Permission
|
|
187
|
+
from forgex.tools.base import SimpleTool, ToolCall, ToolContext, ToolResult
|
|
188
|
+
|
|
189
|
+
async def _tool_status(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
190
|
+
st = git_status(ctx.workspace)
|
|
191
|
+
return ToolResult(ok=True, output=st)
|
|
192
|
+
|
|
193
|
+
async def _tool_diff(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
194
|
+
staged = bool(call.arguments.get("staged", False))
|
|
195
|
+
diff = git_diff(ctx.workspace, staged=staged)
|
|
196
|
+
return ToolResult(ok=True, output=diff)
|
|
197
|
+
|
|
198
|
+
async def _tool_log(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
199
|
+
limit = int(call.arguments.get("limit", 10))
|
|
200
|
+
lg = git_log(ctx.workspace, limit=limit)
|
|
201
|
+
return ToolResult(ok=True, output=lg)
|
|
202
|
+
|
|
203
|
+
async def _tool_checkpoint(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
204
|
+
label = str(call.arguments.get("label", "manual"))
|
|
205
|
+
c = git_checkpoint(ctx.workspace, label)
|
|
206
|
+
if c is None:
|
|
207
|
+
return ToolResult(ok=False, error="git checkpoint failed (no git repo or commit error)")
|
|
208
|
+
return ToolResult(ok=True, output=f"checkpoint created: {c[:8]} ({label})", data={"commit": c})
|
|
209
|
+
|
|
210
|
+
return [
|
|
211
|
+
SimpleTool(
|
|
212
|
+
name="git_status",
|
|
213
|
+
handler=_tool_status,
|
|
214
|
+
permissions={Permission.GIT},
|
|
215
|
+
description="Show the working tree status.",
|
|
216
|
+
parameters={"type": "object", "properties": {}, "additionalProperties": False},
|
|
217
|
+
),
|
|
218
|
+
SimpleTool(
|
|
219
|
+
name="git_diff",
|
|
220
|
+
handler=_tool_diff,
|
|
221
|
+
permissions={Permission.GIT},
|
|
222
|
+
description="Show changes between commits, commit and working tree, etc.",
|
|
223
|
+
parameters={"type": "object", "properties": {"staged": {"type": "boolean"}}, "additionalProperties": False},
|
|
224
|
+
),
|
|
225
|
+
SimpleTool(
|
|
226
|
+
name="git_log",
|
|
227
|
+
handler=_tool_log,
|
|
228
|
+
permissions={Permission.GIT},
|
|
229
|
+
description="Show recent commit logs.",
|
|
230
|
+
parameters={"type": "object", "properties": {"limit": {"type": "integer"}}, "additionalProperties": False},
|
|
231
|
+
),
|
|
232
|
+
SimpleTool(
|
|
233
|
+
name="git_checkpoint",
|
|
234
|
+
handler=_tool_checkpoint,
|
|
235
|
+
permissions={Permission.GIT},
|
|
236
|
+
description="Create a Git checkpoint of current workspace changes.",
|
|
237
|
+
parameters={"type": "object", "properties": {"label": {"type": "string"}}, "required": ["label"], "additionalProperties": False},
|
|
238
|
+
),
|
|
239
|
+
]
|
forgex/tools/project.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""project_info tool — what an agent first sees about the repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from forgex.project import detect_project
|
|
6
|
+
from forgex.sandbox.permissions import Permission
|
|
7
|
+
from forgex.tools.base import SimpleTool, Tool, ToolCall, ToolContext, ToolResult
|
|
8
|
+
from forgex.tools.git import current_branch, is_dirty
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _project_info(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
12
|
+
detection = detect_project(ctx.workspace)
|
|
13
|
+
branch = current_branch(ctx.workspace)
|
|
14
|
+
dirty = is_dirty(ctx.workspace) if branch else None
|
|
15
|
+
git_state = f"{branch} ({'dirty' if dirty else 'clean'})" if branch else "not a git repository"
|
|
16
|
+
output = "\n".join(
|
|
17
|
+
[
|
|
18
|
+
f"Project: {detection.summary()}",
|
|
19
|
+
f"Workspace: {ctx.workspace}",
|
|
20
|
+
f"Git: {git_state}",
|
|
21
|
+
]
|
|
22
|
+
)
|
|
23
|
+
return ToolResult(
|
|
24
|
+
ok=True,
|
|
25
|
+
output=output,
|
|
26
|
+
data={**detection.as_dict(), "git": {"branch": branch, "dirty": dirty}},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_tools() -> list[Tool]:
|
|
31
|
+
return [
|
|
32
|
+
SimpleTool(
|
|
33
|
+
name="project_info",
|
|
34
|
+
handler=_project_info,
|
|
35
|
+
permissions={Permission.READ},
|
|
36
|
+
description="Overview of the project: languages, frameworks, package managers, git state.",
|
|
37
|
+
parameters={"type": "object", "properties": {}, "additionalProperties": False},
|
|
38
|
+
),
|
|
39
|
+
]
|
forgex/tools/search.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Search tools (spec sections 3, 18, 46): ripgrep when available, pure-Python fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import fnmatch
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from forgex.sandbox.permissions import Permission
|
|
12
|
+
from forgex.tools.base import SimpleTool, Tool, ToolCall, ToolContext, ToolResult
|
|
13
|
+
from forgex.tools.filesystem import IGNORED_DIRS, is_secret_file
|
|
14
|
+
from forgex.utils.redact import redact_patterns
|
|
15
|
+
|
|
16
|
+
MAX_MATCHES = 200
|
|
17
|
+
MAX_SCAN_FILES = 5_000
|
|
18
|
+
TEXT_SUFFIX_SKIP = {
|
|
19
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".zip",
|
|
20
|
+
".gz", ".tar", ".bin", ".exe", ".dll", ".so", ".dylib", ".db",
|
|
21
|
+
".sqlite", ".sqlite3", ".woff", ".woff2", ".ttf", ".class",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rg_available() -> bool:
|
|
26
|
+
return shutil.which("rg") is not None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _rg_search(
|
|
30
|
+
root: Path, query: str, glob: str | None, *, regex: bool, limit: int
|
|
31
|
+
) -> tuple[list[str], bool]:
|
|
32
|
+
argv = [
|
|
33
|
+
"rg",
|
|
34
|
+
"--line-number",
|
|
35
|
+
"--no-heading",
|
|
36
|
+
"--color",
|
|
37
|
+
"never",
|
|
38
|
+
"--smart-case" if regex else "--fixed-strings",
|
|
39
|
+
]
|
|
40
|
+
for ignored in IGNORED_DIRS:
|
|
41
|
+
argv.extend(["--glob", f"!{ignored}/**"])
|
|
42
|
+
argv.extend(["--glob", "!.env*"])
|
|
43
|
+
if glob:
|
|
44
|
+
argv.extend(["--glob", glob])
|
|
45
|
+
argv.extend(["--", query, str(root)])
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
proc = await asyncio.create_subprocess_exec(
|
|
49
|
+
*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
50
|
+
)
|
|
51
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
|
|
52
|
+
except (TimeoutError, OSError):
|
|
53
|
+
return [], False
|
|
54
|
+
|
|
55
|
+
lines = stdout.decode("utf-8", errors="replace").splitlines()
|
|
56
|
+
truncated = len(lines) > limit
|
|
57
|
+
cleaned: list[str] = []
|
|
58
|
+
root_str = str(root.resolve())
|
|
59
|
+
for line in lines[:limit]:
|
|
60
|
+
if line.startswith(root_str):
|
|
61
|
+
line = line[len(root_str) :].lstrip("/\\")
|
|
62
|
+
cleaned.append(line)
|
|
63
|
+
|
|
64
|
+
return cleaned, truncated
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _python_search(
|
|
68
|
+
root: Path, query: str, glob: str | None, *, regex: bool, limit: int
|
|
69
|
+
) -> tuple[list[str], bool]:
|
|
70
|
+
pattern = re.compile(query, re.IGNORECASE) if regex else None
|
|
71
|
+
matches: list[str] = []
|
|
72
|
+
truncated = False
|
|
73
|
+
scanned = 0
|
|
74
|
+
|
|
75
|
+
for path in sorted(root.rglob("*")):
|
|
76
|
+
if scanned >= MAX_SCAN_FILES:
|
|
77
|
+
truncated = True
|
|
78
|
+
break
|
|
79
|
+
try:
|
|
80
|
+
rel = path.relative_to(root)
|
|
81
|
+
except ValueError:
|
|
82
|
+
continue
|
|
83
|
+
if any(p in IGNORED_DIRS for p in rel.parts):
|
|
84
|
+
continue
|
|
85
|
+
if is_secret_file(path.name):
|
|
86
|
+
continue
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
continue
|
|
89
|
+
if path.suffix.lower() in TEXT_SUFFIX_SKIP:
|
|
90
|
+
continue
|
|
91
|
+
if glob and not fnmatch.fnmatch(path.name, glob):
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
scanned += 1
|
|
95
|
+
try:
|
|
96
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
97
|
+
except OSError:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
for idx, line in enumerate(content.splitlines(), start=1):
|
|
101
|
+
matched = bool(pattern.search(line)) if pattern else (query in line)
|
|
102
|
+
if matched:
|
|
103
|
+
matches.append(f"{rel.as_posix()}:{idx}: {line.strip()[:200]}")
|
|
104
|
+
if len(matches) >= limit:
|
|
105
|
+
truncated = True
|
|
106
|
+
return matches, truncated
|
|
107
|
+
|
|
108
|
+
return matches, truncated
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _search_code(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
112
|
+
query = call.arguments.get("query")
|
|
113
|
+
if not query:
|
|
114
|
+
return ToolResult(ok=False, error="missing required argument: query")
|
|
115
|
+
|
|
116
|
+
regex = bool(call.arguments.get("regex", False))
|
|
117
|
+
glob = call.arguments.get("glob")
|
|
118
|
+
limit = int(call.arguments.get("limit", MAX_MATCHES))
|
|
119
|
+
|
|
120
|
+
if _rg_available():
|
|
121
|
+
engine = "ripgrep"
|
|
122
|
+
matches, truncated = await _rg_search(ctx.workspace, str(query), glob, regex=regex, limit=limit)
|
|
123
|
+
else:
|
|
124
|
+
engine = "python"
|
|
125
|
+
matches, truncated = _python_search(ctx.workspace, str(query), glob, regex=regex, limit=limit)
|
|
126
|
+
|
|
127
|
+
redacted_matches = [redact_patterns(m) for m in matches]
|
|
128
|
+
output = "\n".join(redacted_matches) if redacted_matches else "(no matches)"
|
|
129
|
+
|
|
130
|
+
return ToolResult(
|
|
131
|
+
ok=True,
|
|
132
|
+
output=output,
|
|
133
|
+
data={"matches": len(matches), "truncated": truncated, "engine": engine},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _search_files(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
138
|
+
pattern = call.arguments.get("pattern")
|
|
139
|
+
if not pattern:
|
|
140
|
+
return ToolResult(ok=False, error="missing required argument: pattern")
|
|
141
|
+
|
|
142
|
+
sub = str(call.arguments.get("path", ".") or ".")
|
|
143
|
+
try:
|
|
144
|
+
from forgex.utils.paths import resolve_in_workspace
|
|
145
|
+
target = resolve_in_workspace(ctx.workspace, sub)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
return ToolResult(ok=False, error=str(exc))
|
|
148
|
+
|
|
149
|
+
matches: list[str] = []
|
|
150
|
+
truncated = False
|
|
151
|
+
|
|
152
|
+
for path in sorted(target.rglob("*")):
|
|
153
|
+
try:
|
|
154
|
+
rel = path.relative_to(ctx.workspace)
|
|
155
|
+
except ValueError:
|
|
156
|
+
continue
|
|
157
|
+
if any(p in IGNORED_DIRS for p in rel.parts):
|
|
158
|
+
continue
|
|
159
|
+
if is_secret_file(path.name):
|
|
160
|
+
continue
|
|
161
|
+
if fnmatch.fnmatch(path.name, str(pattern)):
|
|
162
|
+
matches.append(rel.as_posix())
|
|
163
|
+
if len(matches) >= MAX_MATCHES:
|
|
164
|
+
truncated = True
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
output = "\n".join(matches) if matches else "(no files matched)"
|
|
168
|
+
return ToolResult(ok=True, output=output, data={"count": len(matches), "truncated": truncated})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def create_tools() -> list[Tool]:
|
|
172
|
+
return [
|
|
173
|
+
SimpleTool(
|
|
174
|
+
name="search_code",
|
|
175
|
+
handler=_search_code,
|
|
176
|
+
permissions={Permission.READ},
|
|
177
|
+
description="Search for text or regex in workspace code files.",
|
|
178
|
+
parameters={
|
|
179
|
+
"type": "object",
|
|
180
|
+
"properties": {
|
|
181
|
+
"query": {"type": "string", "description": "String or regex pattern to search for"},
|
|
182
|
+
"regex": {"type": "boolean", "description": "Whether query is a regex"},
|
|
183
|
+
"glob": {"type": "string", "description": "Optional file glob filter, e.g. *.py"},
|
|
184
|
+
"limit": {"type": "integer", "description": "Maximum match count"},
|
|
185
|
+
},
|
|
186
|
+
"required": ["query"],
|
|
187
|
+
"additionalProperties": False,
|
|
188
|
+
},
|
|
189
|
+
),
|
|
190
|
+
SimpleTool(
|
|
191
|
+
name="search_files",
|
|
192
|
+
handler=_search_files,
|
|
193
|
+
permissions={Permission.READ},
|
|
194
|
+
description="Find files in the workspace matching a name glob pattern.",
|
|
195
|
+
parameters={
|
|
196
|
+
"type": "object",
|
|
197
|
+
"properties": {
|
|
198
|
+
"pattern": {"type": "string", "description": "Filename pattern to match, e.g. *test*.py"},
|
|
199
|
+
"path": {"type": "string", "description": "Subdirectory to search inside"},
|
|
200
|
+
},
|
|
201
|
+
"required": ["pattern"],
|
|
202
|
+
"additionalProperties": False,
|
|
203
|
+
},
|
|
204
|
+
),
|
|
205
|
+
]
|
forgex/tools/terminal.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""run_command tool (spec section 19) — gated through policy → approval → executor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
|
|
7
|
+
from forgex.sandbox.permissions import Permission
|
|
8
|
+
from forgex.schemas.error import ToolError
|
|
9
|
+
from forgex.tools.base import SimpleTool, Tool, ToolCall, ToolContext, ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def _run_command(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
13
|
+
command = call.arguments.get("command")
|
|
14
|
+
if command is None:
|
|
15
|
+
raise ToolError("missing required argument: command")
|
|
16
|
+
|
|
17
|
+
cwd = str(call.arguments.get("cwd", ".") or ".")
|
|
18
|
+
timeout = call.arguments.get("timeout")
|
|
19
|
+
env = call.arguments.get("env")
|
|
20
|
+
|
|
21
|
+
result = await ctx.executor.run(
|
|
22
|
+
command,
|
|
23
|
+
cwd=cwd,
|
|
24
|
+
timeout=float(timeout) if timeout is not None else None,
|
|
25
|
+
env=env,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
head = f"$ {shlex.join(result.argv)}"
|
|
29
|
+
status = "killed (timeout)" if result.timed_out else f"exit code {result.exit_code}"
|
|
30
|
+
sections = [
|
|
31
|
+
head,
|
|
32
|
+
status,
|
|
33
|
+
"--- stdout ---",
|
|
34
|
+
result.stdout or "(empty)",
|
|
35
|
+
"--- stderr ---",
|
|
36
|
+
result.stderr or "(empty)",
|
|
37
|
+
]
|
|
38
|
+
return ToolResult(
|
|
39
|
+
ok=(result.exit_code == 0 and not result.timed_out),
|
|
40
|
+
output="\n".join(sections),
|
|
41
|
+
data={
|
|
42
|
+
"command": result.argv,
|
|
43
|
+
"exit_code": result.exit_code,
|
|
44
|
+
"timed_out": result.timed_out,
|
|
45
|
+
"duration_ms": round(result.duration_ms, 1),
|
|
46
|
+
"truncated": result.truncated,
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_tools() -> list[Tool]:
|
|
52
|
+
return [
|
|
53
|
+
SimpleTool(
|
|
54
|
+
name="run_command",
|
|
55
|
+
handler=_run_command,
|
|
56
|
+
permissions={Permission.TERMINAL},
|
|
57
|
+
description=(
|
|
58
|
+
"Run a command in the project workspace. Prefer an argv array "
|
|
59
|
+
"(['python', '-m', 'pytest']). Strings are parsed with shlex — NO shell "
|
|
60
|
+
"features (no pipes, &&, or redirects). The child environment is sanitized."
|
|
61
|
+
),
|
|
62
|
+
parameters={
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"command": {
|
|
66
|
+
"description": "Argv array (preferred) or shlex-parsable string.",
|
|
67
|
+
"oneOf": [{"type": "array", "items": {"type": "string"}}, {"type": "string"}],
|
|
68
|
+
},
|
|
69
|
+
"cwd": {"type": "string", "description": "Workspace-relative working directory"},
|
|
70
|
+
"timeout": {"type": "integer", "description": "Timeout in seconds"},
|
|
71
|
+
"env": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"additionalProperties": {"type": "string"},
|
|
74
|
+
"description": "Extra environment variables (parent env is NOT inherited)",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
"required": ["command"],
|
|
78
|
+
"additionalProperties": False,
|
|
79
|
+
},
|
|
80
|
+
),
|
|
81
|
+
]
|
forgex/tools/testing.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""run_tests tool + test-command detection (spec section 10)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shlex
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from forgex.project import detect_project
|
|
10
|
+
from forgex.sandbox.permissions import Permission
|
|
11
|
+
from forgex.tools.base import SimpleTool, Tool, ToolCall, ToolContext, ToolResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def detect_test_command(root: Path) -> list[str] | None:
|
|
15
|
+
detection = detect_project(root)
|
|
16
|
+
langs = detection.languages
|
|
17
|
+
frameworks = detection.test_frameworks
|
|
18
|
+
|
|
19
|
+
if "Python" in langs:
|
|
20
|
+
if "Pytest" in frameworks:
|
|
21
|
+
return ["python", "-m", "pytest", "-q"]
|
|
22
|
+
if "unittest" in frameworks:
|
|
23
|
+
return ["python", "-m", "unittest", "discover", "-s", "."]
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
if "JavaScript" in langs or "TypeScript" in langs:
|
|
27
|
+
pkg = root / "package.json"
|
|
28
|
+
if pkg.is_file():
|
|
29
|
+
try:
|
|
30
|
+
scripts = json.loads(pkg.read_text(encoding="utf-8")).get("scripts") or {}
|
|
31
|
+
if scripts.get("test"):
|
|
32
|
+
return ["npm", "test"]
|
|
33
|
+
except (OSError, json.JSONDecodeError):
|
|
34
|
+
return None
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
if "Rust" in langs:
|
|
38
|
+
return ["cargo", "test"]
|
|
39
|
+
|
|
40
|
+
if "Go" in langs:
|
|
41
|
+
return ["go", "test", "./..."]
|
|
42
|
+
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _run_tests(call: ToolCall, ctx: ToolContext) -> ToolResult:
|
|
47
|
+
command = detect_test_command(ctx.workspace)
|
|
48
|
+
if command is None:
|
|
49
|
+
return ToolResult(
|
|
50
|
+
ok=False,
|
|
51
|
+
error="No test command detected for this project (expected pytest / npm test / cargo test / go test).",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
extra = call.arguments.get("extra_args") or []
|
|
55
|
+
full_cmd = list(command) + [str(a) for a in extra]
|
|
56
|
+
|
|
57
|
+
result = await ctx.executor.run(full_cmd, cwd=".")
|
|
58
|
+
status = "killed (timeout)" if result.timed_out else f"exit code {result.exit_code}"
|
|
59
|
+
output = "\n".join(
|
|
60
|
+
[
|
|
61
|
+
f"$ {shlex.join(result.argv)}",
|
|
62
|
+
status,
|
|
63
|
+
"--- stdout ---",
|
|
64
|
+
result.stdout or "(empty)",
|
|
65
|
+
"--- stderr ---",
|
|
66
|
+
result.stderr or "(empty)",
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
return ToolResult(
|
|
70
|
+
ok=(result.exit_code == 0 and not result.timed_out),
|
|
71
|
+
output=output,
|
|
72
|
+
data={
|
|
73
|
+
"command": result.argv,
|
|
74
|
+
"exit_code": result.exit_code,
|
|
75
|
+
"timed_out": result.timed_out,
|
|
76
|
+
"duration_ms": round(result.duration_ms, 1),
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_tools() -> list[Tool]:
|
|
82
|
+
return [
|
|
83
|
+
SimpleTool(
|
|
84
|
+
name="run_tests",
|
|
85
|
+
handler=_run_tests,
|
|
86
|
+
permissions={Permission.TEST},
|
|
87
|
+
description="Run the project's test suite (auto-detected: pytest, npm test, cargo test, go test).",
|
|
88
|
+
parameters={
|
|
89
|
+
"type": "object",
|
|
90
|
+
"properties": {
|
|
91
|
+
"extra_args": {
|
|
92
|
+
"type": "array",
|
|
93
|
+
"items": {"type": "string"},
|
|
94
|
+
"description": "Extra arguments appended to the test command",
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"additionalProperties": False,
|
|
98
|
+
},
|
|
99
|
+
),
|
|
100
|
+
]
|