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/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
+ ]
@@ -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
+ ]
@@ -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
+ ]
@@ -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
+ ]