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/cli/repl.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Persistent interactive REPL for FORGEX."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from prompt_toolkit import PromptSession
|
|
13
|
+
from prompt_toolkit.completion import WordCompleter
|
|
14
|
+
from prompt_toolkit.formatted_text import HTML
|
|
15
|
+
from prompt_toolkit.history import FileHistory
|
|
16
|
+
from prompt_toolkit.input import DummyInput, create_input
|
|
17
|
+
from prompt_toolkit.output import DummyOutput, create_output
|
|
18
|
+
from prompt_toolkit.styles import Style
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.panel import Panel
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
|
|
23
|
+
from forgex import __version__
|
|
24
|
+
from forgex.cli.intent import IntentAction, classify_intent
|
|
25
|
+
from forgex.cli.progress import render_pipeline_header, render_runtime_summary
|
|
26
|
+
from forgex.cli.runtime import AutonomousRuntime
|
|
27
|
+
from forgex.cli.session import Session
|
|
28
|
+
from forgex.config.loader import global_config_path
|
|
29
|
+
from forgex.schemas.error import ForgexError
|
|
30
|
+
|
|
31
|
+
BANNER = r"""[bold cyan]
|
|
32
|
+
███████╗ ██████╗ ██████╗ ██████╗ ███████╗██╗ ██╗
|
|
33
|
+
██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝╚██╗██╔╝
|
|
34
|
+
█████╗ ██║ ██║██████╔╝██║ ███╗█████╗ ╚███╔╝
|
|
35
|
+
██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ ██╔██╗
|
|
36
|
+
██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗██╔╝ ██╗
|
|
37
|
+
╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝[/bold cyan]
|
|
38
|
+
[dim]Autonomous Adaptive Multi-Model Software Engineering CLI[/dim]
|
|
39
|
+
[dim]Just describe what you want. We Plan, Build, Verify & Repair. (v{version})[/dim]
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
COMMANDS = [
|
|
43
|
+
"do",
|
|
44
|
+
"init",
|
|
45
|
+
"plan",
|
|
46
|
+
"build",
|
|
47
|
+
"fix",
|
|
48
|
+
"review",
|
|
49
|
+
"verify",
|
|
50
|
+
"supervise",
|
|
51
|
+
"status",
|
|
52
|
+
"doctor",
|
|
53
|
+
"models",
|
|
54
|
+
"tools",
|
|
55
|
+
"checkpoint",
|
|
56
|
+
"rollback",
|
|
57
|
+
"diff",
|
|
58
|
+
"config",
|
|
59
|
+
"logs",
|
|
60
|
+
"task",
|
|
61
|
+
"context",
|
|
62
|
+
"run",
|
|
63
|
+
"cd",
|
|
64
|
+
"clear",
|
|
65
|
+
"help",
|
|
66
|
+
"exit",
|
|
67
|
+
"quit",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
SLASH_COMMANDS = [f"/{cmd}" for cmd in COMMANDS]
|
|
71
|
+
|
|
72
|
+
PROMPT_STYLE = Style.from_dict(
|
|
73
|
+
{
|
|
74
|
+
"brand": "#00d7ff bold",
|
|
75
|
+
"project": "#00ff87 bold",
|
|
76
|
+
"unattached": "#ffd700 dim",
|
|
77
|
+
"sep": "#666666",
|
|
78
|
+
"arrow": "#00ff87 bold",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ForgexREPL:
|
|
84
|
+
"""Interactive persistent shell session for FORGEX."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
session: Session | None = None,
|
|
89
|
+
*,
|
|
90
|
+
output: Any = None,
|
|
91
|
+
input: Any = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
self.session = session or Session()
|
|
94
|
+
self.console = Console()
|
|
95
|
+
hist_dir = Path.home() / ".forgex"
|
|
96
|
+
try:
|
|
97
|
+
hist_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
self.history_file = hist_dir / "repl_history"
|
|
101
|
+
|
|
102
|
+
# Safe input/output initialization to support headless CI & Windows non-console buffers
|
|
103
|
+
try:
|
|
104
|
+
pt_output = output or create_output()
|
|
105
|
+
except Exception:
|
|
106
|
+
pt_output = DummyOutput()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
pt_input = input or create_input()
|
|
110
|
+
except Exception:
|
|
111
|
+
pt_input = DummyInput()
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
self.prompt_session: PromptSession = PromptSession(
|
|
115
|
+
history=FileHistory(str(self.history_file)),
|
|
116
|
+
completer=WordCompleter(COMMANDS + SLASH_COMMANDS, ignore_case=True),
|
|
117
|
+
style=PROMPT_STYLE,
|
|
118
|
+
output=pt_output,
|
|
119
|
+
input=pt_input,
|
|
120
|
+
)
|
|
121
|
+
except Exception:
|
|
122
|
+
self.prompt_session = PromptSession(
|
|
123
|
+
output=DummyOutput(),
|
|
124
|
+
input=DummyInput(),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def print_banner(self) -> None:
|
|
128
|
+
"""Display the welcome banner and session overview."""
|
|
129
|
+
self.console.print(BANNER.format(version=__version__))
|
|
130
|
+
|
|
131
|
+
grid = Table.grid(padding=(0, 2))
|
|
132
|
+
grid.add_column(style="dim")
|
|
133
|
+
grid.add_column()
|
|
134
|
+
|
|
135
|
+
proj_display = (
|
|
136
|
+
f"[bold green]{self.session.project_root.name}[/bold green] [dim]({self.session.project_root})[/dim]"
|
|
137
|
+
if self.session.is_attached and self.session.project_root
|
|
138
|
+
else "[bold yellow]None[/bold yellow] [dim](Type 'init' or 'cd <dir>' to attach)[/dim]"
|
|
139
|
+
)
|
|
140
|
+
grid.add_row("Attached Project:", proj_display)
|
|
141
|
+
|
|
142
|
+
arch_model = f"{self.session.config.models.architect.provider}:{self.session.config.models.architect.model}"
|
|
143
|
+
grid.add_row("Default Model:", f"[cyan]{arch_model}[/cyan]")
|
|
144
|
+
grid.add_row("Permissions:", f"[magenta]{self.session.config.permissions.mode}[/magenta]")
|
|
145
|
+
if os.environ.get("FORGEX_OFFLINE") == "1":
|
|
146
|
+
grid.add_row("Network:", "[red]Offline mode active[/red]")
|
|
147
|
+
|
|
148
|
+
self.console.print(Panel(grid, title="[bold]Session Context[/bold]", border_style="cyan"))
|
|
149
|
+
self.console.print("[dim]Type [bold white]help[/bold white] for commands, or describe what to build in plain English.[/dim]\n")
|
|
150
|
+
|
|
151
|
+
def check_first_run(self) -> None:
|
|
152
|
+
"""Guide the user through onboarding if global config does not exist."""
|
|
153
|
+
g_cfg = global_config_path()
|
|
154
|
+
if not g_cfg.is_file():
|
|
155
|
+
self.console.print(
|
|
156
|
+
Panel(
|
|
157
|
+
"[bold yellow]First time using FORGEX?[/bold yellow]\n\n"
|
|
158
|
+
"No global configuration was found at ~/.forgex/config.toml.\n"
|
|
159
|
+
"Let's configure your AI model provider and local profile.",
|
|
160
|
+
border_style="yellow",
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
if sys.stdin.isatty():
|
|
164
|
+
from rich.prompt import Confirm
|
|
165
|
+
if Confirm.ask("Launch the guided setup wizard now?", default=True):
|
|
166
|
+
self._run_cli_command(["config", "setup"])
|
|
167
|
+
|
|
168
|
+
def print_help(self) -> None:
|
|
169
|
+
"""Display help table for natural language usage and power user commands."""
|
|
170
|
+
self.console.print(
|
|
171
|
+
Panel(
|
|
172
|
+
"[bold white]Describe what you want to build in plain English:[/bold white]\n\n"
|
|
173
|
+
" [cyan]❯[/cyan] [green]Build me a REST API with FastAPI and SQLite[/green]\n"
|
|
174
|
+
" [cyan]❯[/cyan] [green]Add JWT authentication with refresh tokens[/green]\n"
|
|
175
|
+
" [cyan]❯[/cyan] [green]Fix the failing tests in users.py[/green]\n"
|
|
176
|
+
" [cyan]❯[/cyan] [green]Create a full-stack dashboard with React[/green]\n\n"
|
|
177
|
+
"[dim]FORGEX will automatically initialize, plan, build, verify, repair, and checkpoint.[/dim]",
|
|
178
|
+
title="[bold cyan]FORGEX Universal CLI — How It Works[/bold cyan]",
|
|
179
|
+
border_style="cyan",
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
table = Table(title="Power User Commands (Optional Granular Control)", border_style="dim", show_header=True)
|
|
184
|
+
table.add_column("Command", style="bold cyan", width=22)
|
|
185
|
+
table.add_column("Description")
|
|
186
|
+
|
|
187
|
+
workflows = [
|
|
188
|
+
("do <request>", "Execute end-to-end autonomous pipeline directly"),
|
|
189
|
+
("plan <goal>", "Run only Architect: planning & task breakdown"),
|
|
190
|
+
("build", "Run build pipeline for existing plan tasks"),
|
|
191
|
+
("fix", "Auto-repair errors until tests pass — no rebuild"),
|
|
192
|
+
("verify", "Deterministic verification (tests, lint, typecheck)"),
|
|
193
|
+
("review", "Senior engineer code review of diff"),
|
|
194
|
+
("supervise", "Run verification + code audit + soft gates verdict"),
|
|
195
|
+
("status", "Project state, task breakdown, branch, and token usage"),
|
|
196
|
+
("diff", "Inspect current uncommitted git changes"),
|
|
197
|
+
("checkpoint [msg]", "Create reversible snapshot of codebase state"),
|
|
198
|
+
("rollback [id]", "Reversibly rollback to a checkpoint"),
|
|
199
|
+
("init [dir]", "Initialize FORGEX in current or target directory"),
|
|
200
|
+
("models [catalog|health]", "Inspect configured models and providers"),
|
|
201
|
+
("tools [list|run]", "Inspect and manually execute system tools"),
|
|
202
|
+
("config [show|set|setup]", "View or modify configuration"),
|
|
203
|
+
("doctor", "Validate environment, dependencies, and network"),
|
|
204
|
+
("cd <dir>", "Switch directory and auto-attach project"),
|
|
205
|
+
("clear", "Clear terminal screen"),
|
|
206
|
+
("exit / quit", "Exit the interactive session"),
|
|
207
|
+
]
|
|
208
|
+
for cmd, desc in workflows:
|
|
209
|
+
table.add_row(cmd, desc)
|
|
210
|
+
|
|
211
|
+
self.console.print(table)
|
|
212
|
+
|
|
213
|
+
def get_prompt_text(self) -> HTML:
|
|
214
|
+
"""Generate dynamic colored prompt based on session state."""
|
|
215
|
+
if self.session.is_attached and self.session.project_root:
|
|
216
|
+
name = self.session.project_root.name
|
|
217
|
+
return HTML(f"<brand>forgex</brand> <sep>[</sep><project>{name}</project><sep>]</sep> <arrow>❯</arrow> ")
|
|
218
|
+
return HTML("<brand>forgex</brand> <arrow>❯</arrow> ")
|
|
219
|
+
|
|
220
|
+
def dispatch(self, raw_input: str) -> None:
|
|
221
|
+
"""Dispatch user input to built-ins, Typer commands, or natural language intent."""
|
|
222
|
+
text = raw_input.strip()
|
|
223
|
+
if not text:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
self.session.history.append(text)
|
|
227
|
+
|
|
228
|
+
# Strip optional leading slash
|
|
229
|
+
if text.startswith("/"):
|
|
230
|
+
text = text[1:].strip()
|
|
231
|
+
|
|
232
|
+
tokens = shlex.split(text, posix=sys.platform != "win32")
|
|
233
|
+
if not tokens:
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
cmd = tokens[0].lower()
|
|
237
|
+
args = tokens[1:]
|
|
238
|
+
|
|
239
|
+
# Handle built-ins
|
|
240
|
+
if cmd in ("exit", "quit", "q"):
|
|
241
|
+
raise EOFError
|
|
242
|
+
if cmd in ("clear", "cls"):
|
|
243
|
+
self.console.clear()
|
|
244
|
+
return
|
|
245
|
+
if cmd in ("help", "?"):
|
|
246
|
+
self.print_help()
|
|
247
|
+
return
|
|
248
|
+
if cmd == "cd":
|
|
249
|
+
raw_target = text[len(tokens[0]):].strip() if len(tokens) > 0 else ""
|
|
250
|
+
if raw_target:
|
|
251
|
+
target = raw_target.strip("'\"")
|
|
252
|
+
else:
|
|
253
|
+
target = str(Path.home())
|
|
254
|
+
if not self.session.change_directory(target):
|
|
255
|
+
self.console.print(f"[red]Directory not found: {target}[/red]")
|
|
256
|
+
else:
|
|
257
|
+
self.console.print(f"[dim]Switched to {Path.cwd()}[/dim]")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
if cmd == "do" and len(tokens) > 1:
|
|
261
|
+
req = " ".join(tokens[1:])
|
|
262
|
+
self._run_autonomous(req, raw_input=text)
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# Commands that do not accept positional words: if followed by prose (e.g. "build me a ...",
|
|
266
|
+
# "fix the failing ..."), treat as natural language rather than CLI syntax.
|
|
267
|
+
nl_commands = {"build", "fix", "verify", "supervise", "status", "doctor", "diff", "logs", "do"}
|
|
268
|
+
if cmd in nl_commands and len(tokens) > 1 and not tokens[1].startswith("-"):
|
|
269
|
+
pass
|
|
270
|
+
elif cmd in COMMANDS:
|
|
271
|
+
self._run_cli_command(tokens)
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
# Natural language intent classification
|
|
275
|
+
intent = classify_intent(text)
|
|
276
|
+
if intent.action == IntentAction.AUTONOMOUS:
|
|
277
|
+
target_req = intent.raw_request or text
|
|
278
|
+
effective_req = self.session.context.enrich_request(target_req)
|
|
279
|
+
self._run_autonomous(effective_req, raw_input=text)
|
|
280
|
+
return
|
|
281
|
+
elif intent.action != IntentAction.UNKNOWN and intent.confidence >= 0.5:
|
|
282
|
+
steps_desc = " → ".join(" ".join(step) for step in intent.pipeline)
|
|
283
|
+
self.console.print(
|
|
284
|
+
f"[bold cyan]Intent recognized:[/bold cyan] {intent.description}\n"
|
|
285
|
+
f"[dim]Pipeline: {steps_desc}[/dim]"
|
|
286
|
+
)
|
|
287
|
+
self._run_pipeline(intent.pipeline)
|
|
288
|
+
else:
|
|
289
|
+
self.console.print(
|
|
290
|
+
f"[yellow]Unrecognized command: '{cmd}'.[/yellow] "
|
|
291
|
+
"Type [bold]help[/bold] for available commands or describe your task in plain English."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _run_autonomous(self, request: str, raw_input: str) -> bool:
|
|
295
|
+
"""Run the end-to-end autonomous pipeline directly."""
|
|
296
|
+
render_pipeline_header(request, console=self.console)
|
|
297
|
+
runtime = AutonomousRuntime(session=self.session, ui=self.session.ui)
|
|
298
|
+
try:
|
|
299
|
+
result = asyncio.run(runtime.execute(request))
|
|
300
|
+
render_runtime_summary(result, console=self.console)
|
|
301
|
+
self.session.context.record_run(
|
|
302
|
+
request=raw_input,
|
|
303
|
+
status=result.status,
|
|
304
|
+
changed_files=result.changed_files,
|
|
305
|
+
checkpoint_id=result.checkpoint_id,
|
|
306
|
+
)
|
|
307
|
+
return result.is_success
|
|
308
|
+
except KeyboardInterrupt:
|
|
309
|
+
self.console.print("\n[yellow]Autonomous run cancelled by user.[/yellow]")
|
|
310
|
+
return False
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
self.console.print(f"[red]Autonomous run error:[/red] {exc}")
|
|
313
|
+
return False
|
|
314
|
+
finally:
|
|
315
|
+
self.session.refresh()
|
|
316
|
+
|
|
317
|
+
def _run_pipeline(self, pipeline: list[list[str]]) -> None:
|
|
318
|
+
"""Execute a pipeline of CLI commands sequentially, stopping on failure."""
|
|
319
|
+
for i, step in enumerate(pipeline, start=1):
|
|
320
|
+
if len(pipeline) > 1:
|
|
321
|
+
self.console.print(f"\n[bold green]▶ Step {i}/{len(pipeline)}: {' '.join(step)}[/bold green]")
|
|
322
|
+
ok = self._run_cli_command(step)
|
|
323
|
+
if not ok:
|
|
324
|
+
self.console.print(f"[red]Pipeline stopped due to error in step: {' '.join(step)}[/red]")
|
|
325
|
+
break
|
|
326
|
+
|
|
327
|
+
def _run_cli_command(self, args: list[str]) -> bool:
|
|
328
|
+
"""Invoke a Typer CLI command within the REPL session."""
|
|
329
|
+
from typer.testing import CliRunner
|
|
330
|
+
|
|
331
|
+
from forgex.cli.app import app
|
|
332
|
+
|
|
333
|
+
runner = CliRunner()
|
|
334
|
+
success = False
|
|
335
|
+
try:
|
|
336
|
+
result = runner.invoke(app, args, catch_exceptions=True)
|
|
337
|
+
if result.output:
|
|
338
|
+
# Print output directly
|
|
339
|
+
sys.stdout.write(result.output)
|
|
340
|
+
sys.stdout.flush()
|
|
341
|
+
if result.exit_code != 0 and result.exception:
|
|
342
|
+
if isinstance(result.exception, ForgexError):
|
|
343
|
+
self.console.print(f"[red]✗[/red] {result.exception}")
|
|
344
|
+
elif not isinstance(result.exception, SystemExit):
|
|
345
|
+
self.console.print(f"[red]Error:[/red] {result.exception}")
|
|
346
|
+
success = result.exit_code == 0
|
|
347
|
+
except ForgexError as exc:
|
|
348
|
+
self.console.print(f"[red]✗[/red] {exc}")
|
|
349
|
+
except KeyboardInterrupt:
|
|
350
|
+
self.console.print("\n[yellow]Operation cancelled by user.[/yellow]")
|
|
351
|
+
except Exception as exc: # noqa: BLE001
|
|
352
|
+
self.console.print(f"[red]Unexpected error:[/red] {type(exc).__name__}: {exc}")
|
|
353
|
+
finally:
|
|
354
|
+
self.session.refresh()
|
|
355
|
+
return success
|
|
356
|
+
|
|
357
|
+
def run(self) -> None:
|
|
358
|
+
"""Main REPL loop."""
|
|
359
|
+
self.print_banner()
|
|
360
|
+
self.check_first_run()
|
|
361
|
+
|
|
362
|
+
while True:
|
|
363
|
+
try:
|
|
364
|
+
line = self.prompt_session.prompt(self.get_prompt_text())
|
|
365
|
+
self.dispatch(line)
|
|
366
|
+
except KeyboardInterrupt:
|
|
367
|
+
self.console.print("\n[yellow]^C (Use 'exit' or Ctrl+D to quit)[/yellow]")
|
|
368
|
+
continue
|
|
369
|
+
except EOFError:
|
|
370
|
+
self.console.print("\n[dim]Goodbye! On-disk state is preserved.[/dim]")
|
|
371
|
+
break
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def start_repl(session: Session | None = None) -> None:
|
|
375
|
+
"""Entry point to launch the FORGEX REPL."""
|
|
376
|
+
repl = ForgexREPL(session=session)
|
|
377
|
+
repl.run()
|
forgex/cli/runtime.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Autonomous runtime engine for FORGEX Universal CLI.
|
|
2
|
+
|
|
3
|
+
Unifies project initialization, planning, building, verification,
|
|
4
|
+
supervision, self-healing repairs, and git checkpoints into a single,
|
|
5
|
+
frictionless autonomous pipeline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from forgex.cli.build_views import format_progress
|
|
22
|
+
from forgex.cli.session import Session
|
|
23
|
+
from forgex.cli.ui import UI, Output, make_approval
|
|
24
|
+
from forgex.config import load_config
|
|
25
|
+
from forgex.orchestration.checkpoints import latest_checkpoint
|
|
26
|
+
from forgex.orchestration.engine import BuildEngine, DryRunReport
|
|
27
|
+
from forgex.orchestration.stores import open_stores
|
|
28
|
+
from forgex.project import initialize_project
|
|
29
|
+
from forgex.schemas.result import TestResults
|
|
30
|
+
from forgex.utils.paths import find_project_root
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("forgex.runtime")
|
|
33
|
+
|
|
34
|
+
ProgressFn = Callable[[str], None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class RuntimeResult:
|
|
39
|
+
"""Consolidated outcome of an autonomous execution."""
|
|
40
|
+
|
|
41
|
+
status: Literal["completed", "failed"]
|
|
42
|
+
request: str
|
|
43
|
+
phases_completed: list[str] = field(default_factory=list)
|
|
44
|
+
tasks_total: int = 0
|
|
45
|
+
tasks_completed: int = 0
|
|
46
|
+
changed_files: list[str] = field(default_factory=list)
|
|
47
|
+
tests: TestResults | None = None
|
|
48
|
+
build_ok: bool | None = None
|
|
49
|
+
checkpoint_id: str | None = None
|
|
50
|
+
reason: str = ""
|
|
51
|
+
warnings: list[str] = field(default_factory=list)
|
|
52
|
+
usage: dict[str, Any] | None = None
|
|
53
|
+
duration_seconds: float = 0.0
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_success(self) -> bool:
|
|
57
|
+
return self.status == "completed"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AutonomousRuntime:
|
|
61
|
+
"""Unified autonomous pipeline executor for FORGEX.
|
|
62
|
+
|
|
63
|
+
Takes a high-level user request in natural language and drives:
|
|
64
|
+
1. Zero-friction project initialization if not already initialized
|
|
65
|
+
2. Architecture planning and task decomposition
|
|
66
|
+
3. Per-task implementation with builder & expert coder
|
|
67
|
+
4. Deterministic testing, linting, and build verification
|
|
68
|
+
5. Supervisor auditing and soft gates
|
|
69
|
+
6. Self-healing repair loops until green (bounded)
|
|
70
|
+
7. Automated git checkpoint snapshot on completion
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
target_dir: Path | None = None,
|
|
76
|
+
session: Session | None = None,
|
|
77
|
+
ui: UI | None = None,
|
|
78
|
+
progress: ProgressFn | None = None,
|
|
79
|
+
approval: Callable[[str], bool] | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.target_dir = Path(target_dir).resolve() if target_dir else Path.cwd()
|
|
82
|
+
self.session = session
|
|
83
|
+
self.ui = ui or (session.ui if session else UI(mode=Output.RICH))
|
|
84
|
+
self.console = self.ui.console if hasattr(self.ui, "console") else Console()
|
|
85
|
+
self._custom_progress = progress
|
|
86
|
+
self._approval = approval or (make_approval(self.ui) if self.ui else None)
|
|
87
|
+
|
|
88
|
+
def _notify_progress(self, msg: str) -> None:
|
|
89
|
+
if self._custom_progress:
|
|
90
|
+
self._custom_progress(msg)
|
|
91
|
+
elif self.ui:
|
|
92
|
+
is_rich = getattr(self.ui, "mode", None) is Output.RICH
|
|
93
|
+
self.ui.text(format_progress(msg, rich_mode=is_rich))
|
|
94
|
+
|
|
95
|
+
def _ensure_project_initialized(self) -> tuple[Path, bool]:
|
|
96
|
+
"""Check if target directory is a FORGEX project; auto-initialize if not."""
|
|
97
|
+
root = find_project_root(self.target_dir)
|
|
98
|
+
was_initialized = False
|
|
99
|
+
|
|
100
|
+
if root is None or not (root / ".forgex").is_dir():
|
|
101
|
+
self._notify_progress(f"Initializing project workspace at {self.target_dir}...")
|
|
102
|
+
# Auto-init git if git is available and repo doesn't exist
|
|
103
|
+
git_dir = self.target_dir / ".git"
|
|
104
|
+
if not git_dir.exists():
|
|
105
|
+
try:
|
|
106
|
+
subprocess.run(
|
|
107
|
+
["git", "init"],
|
|
108
|
+
cwd=self.target_dir,
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
check=False,
|
|
112
|
+
)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
logger.debug("git init failed: %s", exc)
|
|
115
|
+
|
|
116
|
+
root = initialize_project(self.target_dir)
|
|
117
|
+
was_initialized = True
|
|
118
|
+
if self.session:
|
|
119
|
+
self.session.refresh()
|
|
120
|
+
self._notify_progress("Project workspace initialized.")
|
|
121
|
+
|
|
122
|
+
return root, was_initialized
|
|
123
|
+
|
|
124
|
+
async def execute(
|
|
125
|
+
self,
|
|
126
|
+
request: str,
|
|
127
|
+
*,
|
|
128
|
+
resume: bool = False,
|
|
129
|
+
dry_run: bool = False,
|
|
130
|
+
agents: dict[str, Any] | None = None,
|
|
131
|
+
) -> RuntimeResult:
|
|
132
|
+
"""Run the full autonomous pipeline for the given task request."""
|
|
133
|
+
start_time = time.time()
|
|
134
|
+
phases: list[str] = []
|
|
135
|
+
|
|
136
|
+
root, was_newly_inited = self._ensure_project_initialized()
|
|
137
|
+
if was_newly_inited:
|
|
138
|
+
phases.append("init")
|
|
139
|
+
|
|
140
|
+
config = load_config(root)
|
|
141
|
+
|
|
142
|
+
with open_stores(root) as stores:
|
|
143
|
+
engine = BuildEngine(
|
|
144
|
+
stores=stores,
|
|
145
|
+
config=config,
|
|
146
|
+
approval=self._approval,
|
|
147
|
+
progress=self._notify_progress,
|
|
148
|
+
agents=agents,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
build_result = await engine.run(
|
|
152
|
+
request=request,
|
|
153
|
+
dry_run=dry_run,
|
|
154
|
+
resume=resume,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
duration = time.time() - start_time
|
|
158
|
+
|
|
159
|
+
if isinstance(build_result, DryRunReport):
|
|
160
|
+
phases.extend(["plan", "preview"])
|
|
161
|
+
return RuntimeResult(
|
|
162
|
+
status="completed",
|
|
163
|
+
request=request,
|
|
164
|
+
phases_completed=phases,
|
|
165
|
+
tasks_total=len(build_result.planned_tasks),
|
|
166
|
+
tasks_completed=0,
|
|
167
|
+
changed_files=build_result.planned_files,
|
|
168
|
+
reason="Dry run preview generated successfully",
|
|
169
|
+
duration_seconds=duration,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
phases.extend(["plan", "build", "verify", "supervise"])
|
|
173
|
+
|
|
174
|
+
checkpoint_id = build_result.checkpoint
|
|
175
|
+
if build_result.status == "completed":
|
|
176
|
+
phases.append("checkpoint")
|
|
177
|
+
# If checkpoint wasn't automatically populated by git, look up latest checkpoint
|
|
178
|
+
if not checkpoint_id and (root / ".git").exists():
|
|
179
|
+
try:
|
|
180
|
+
with open_stores(root) as stores:
|
|
181
|
+
latest = latest_checkpoint(stores=stores)
|
|
182
|
+
if latest:
|
|
183
|
+
checkpoint_id = latest.git_commit
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
runtime_res = RuntimeResult(
|
|
188
|
+
status=build_result.status,
|
|
189
|
+
request=request,
|
|
190
|
+
phases_completed=phases,
|
|
191
|
+
tasks_total=build_result.tasks_total,
|
|
192
|
+
tasks_completed=build_result.tasks_completed,
|
|
193
|
+
changed_files=build_result.changed_files,
|
|
194
|
+
tests=build_result.tests,
|
|
195
|
+
build_ok=build_result.build_ok,
|
|
196
|
+
checkpoint_id=checkpoint_id,
|
|
197
|
+
reason=build_result.reason,
|
|
198
|
+
warnings=build_result.warnings,
|
|
199
|
+
usage=build_result.usage,
|
|
200
|
+
duration_seconds=duration,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if self.session:
|
|
204
|
+
self.session.refresh()
|
|
205
|
+
|
|
206
|
+
return runtime_res
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def execute_autonomous(
|
|
210
|
+
request: str,
|
|
211
|
+
target_dir: Path | None = None,
|
|
212
|
+
session: Session | None = None,
|
|
213
|
+
ui: UI | None = None,
|
|
214
|
+
dry_run: bool = False,
|
|
215
|
+
resume: bool = False,
|
|
216
|
+
approval: Callable[[str], bool] | None = None,
|
|
217
|
+
agents: dict[str, Any] | None = None,
|
|
218
|
+
) -> RuntimeResult:
|
|
219
|
+
"""Synchronous convenience wrapper for AutonomousRuntime.execute()."""
|
|
220
|
+
runtime = AutonomousRuntime(
|
|
221
|
+
target_dir=target_dir,
|
|
222
|
+
session=session,
|
|
223
|
+
ui=ui,
|
|
224
|
+
approval=approval,
|
|
225
|
+
)
|
|
226
|
+
return asyncio.run(
|
|
227
|
+
runtime.execute(
|
|
228
|
+
request=request,
|
|
229
|
+
dry_run=dry_run,
|
|
230
|
+
resume=resume,
|
|
231
|
+
agents=agents,
|
|
232
|
+
)
|
|
233
|
+
)
|