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/app.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Typer application assembly and the controlled-error entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from typer._click import Command, Context
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from forgex import __version__
|
|
14
|
+
from forgex.cli.commands import (
|
|
15
|
+
build as build_cmd,
|
|
16
|
+
)
|
|
17
|
+
from forgex.cli.commands import (
|
|
18
|
+
checkpoint as checkpoint_cmd,
|
|
19
|
+
)
|
|
20
|
+
from forgex.cli.commands import (
|
|
21
|
+
config as config_cmd,
|
|
22
|
+
)
|
|
23
|
+
from forgex.cli.commands import (
|
|
24
|
+
context as context_cmd,
|
|
25
|
+
)
|
|
26
|
+
from forgex.cli.commands import (
|
|
27
|
+
diff as diff_cmd,
|
|
28
|
+
)
|
|
29
|
+
from forgex.cli.commands import (
|
|
30
|
+
do_cmd,
|
|
31
|
+
)
|
|
32
|
+
from forgex.cli.commands import (
|
|
33
|
+
doctor as doctor_cmd,
|
|
34
|
+
)
|
|
35
|
+
from forgex.cli.commands import (
|
|
36
|
+
fix as fix_cmd,
|
|
37
|
+
)
|
|
38
|
+
from forgex.cli.commands import (
|
|
39
|
+
init as init_cmd,
|
|
40
|
+
)
|
|
41
|
+
from forgex.cli.commands import (
|
|
42
|
+
logs as logs_cmd,
|
|
43
|
+
)
|
|
44
|
+
from forgex.cli.commands import (
|
|
45
|
+
models as models_cmd,
|
|
46
|
+
)
|
|
47
|
+
from forgex.cli.commands import (
|
|
48
|
+
plan as plan_cmd,
|
|
49
|
+
)
|
|
50
|
+
from forgex.cli.commands import (
|
|
51
|
+
review as review_cmd,
|
|
52
|
+
)
|
|
53
|
+
from forgex.cli.commands import (
|
|
54
|
+
rollback as rollback_cmd,
|
|
55
|
+
)
|
|
56
|
+
from forgex.cli.commands import (
|
|
57
|
+
run as run_cmd,
|
|
58
|
+
)
|
|
59
|
+
from forgex.cli.commands import (
|
|
60
|
+
status as status_cmd,
|
|
61
|
+
)
|
|
62
|
+
from forgex.cli.commands import (
|
|
63
|
+
supervise as supervise_cmd,
|
|
64
|
+
)
|
|
65
|
+
from forgex.cli.commands import (
|
|
66
|
+
task as task_cmd,
|
|
67
|
+
)
|
|
68
|
+
from forgex.cli.commands import (
|
|
69
|
+
tools as tools_cmd,
|
|
70
|
+
)
|
|
71
|
+
from forgex.cli.commands import (
|
|
72
|
+
verify as verify_cmd,
|
|
73
|
+
)
|
|
74
|
+
from forgex.cli.repl import start_repl
|
|
75
|
+
from forgex.config.loader import load_env_files
|
|
76
|
+
from forgex.schemas.error import ForgexError
|
|
77
|
+
from forgex.utils.logging import configure_logging
|
|
78
|
+
from forgex.utils.paths import find_project_root
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ForgexTyperGroup(typer.core.TyperGroup):
|
|
82
|
+
"""Custom TyperGroup that routes unrecognized command inputs to autonomous execution."""
|
|
83
|
+
|
|
84
|
+
def resolve_command(
|
|
85
|
+
self, ctx: Context, args: list[str]
|
|
86
|
+
) -> tuple[str | None, Command | None, list[str]]:
|
|
87
|
+
if not args:
|
|
88
|
+
return super().resolve_command(ctx, args)
|
|
89
|
+
cmd_name = args[0]
|
|
90
|
+
# Registered commands or options start with '-'
|
|
91
|
+
if cmd_name in self.commands or cmd_name.startswith("-"):
|
|
92
|
+
return super().resolve_command(ctx, args)
|
|
93
|
+
# Unrecognized command / natural language prose -> route to 'do' with full args
|
|
94
|
+
cmd = self.get_command(ctx, "do")
|
|
95
|
+
return "do", cmd, args
|
|
96
|
+
|
|
97
|
+
def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
|
|
98
|
+
cmd = super().get_command(ctx, cmd_name)
|
|
99
|
+
if cmd is not None:
|
|
100
|
+
return cmd
|
|
101
|
+
if not cmd_name.startswith("-") and "do" in self.commands:
|
|
102
|
+
return self.commands["do"]
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
app = typer.Typer(
|
|
107
|
+
cls=ForgexTyperGroup,
|
|
108
|
+
name="forgex",
|
|
109
|
+
help="FORGEX — autonomous multi-agent AI software engineering. Plan. Build. Refine. Verify.",
|
|
110
|
+
no_args_is_help=False,
|
|
111
|
+
add_completion=True,
|
|
112
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _version_callback(value: bool) -> None:
|
|
117
|
+
if value:
|
|
118
|
+
typer.echo(f"forgex {__version__}")
|
|
119
|
+
raise typer.Exit
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.callback(invoke_without_command=True)
|
|
123
|
+
def root(
|
|
124
|
+
ctx: typer.Context,
|
|
125
|
+
version: Annotated[bool | None, typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version and exit.")] = None,
|
|
126
|
+
no_repl: Annotated[bool, typer.Option("--no-repl", help="Do not start interactive REPL when no command given.")] = False,
|
|
127
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Verbose logging.")] = False,
|
|
128
|
+
quiet: Annotated[bool, typer.Option("--quiet", help="Suppress non-essential output.")] = False,
|
|
129
|
+
json_out: Annotated[bool, typer.Option("--json", help="Machine-readable JSON output.")] = False,
|
|
130
|
+
plain: Annotated[bool, typer.Option("--plain", help="Plain output (CI/SSH safe).")] = False,
|
|
131
|
+
debug: Annotated[bool, typer.Option("--debug", help="Show full tracebacks on errors.")] = False,
|
|
132
|
+
offline: Annotated[bool, typer.Option("--offline", help="Offline mode: no web research, no remote providers.")] = False,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""FORGEX — an AI software engineering team for your terminal."""
|
|
135
|
+
if offline:
|
|
136
|
+
os.environ["FORGEX_OFFLINE"] = "1"
|
|
137
|
+
if debug:
|
|
138
|
+
os.environ["FORGEX_DEBUG"] = "1"
|
|
139
|
+
|
|
140
|
+
load_env_files(find_project_root())
|
|
141
|
+
configure_logging(level="debug" if verbose else "warning")
|
|
142
|
+
ctx.obj = {"verbose": verbose, "quiet": quiet, "json": json_out, "plain": plain}
|
|
143
|
+
|
|
144
|
+
if ctx.invoked_subcommand is None:
|
|
145
|
+
import sys
|
|
146
|
+
|
|
147
|
+
if no_repl or not sys.stdin.isatty():
|
|
148
|
+
typer.echo(ctx.get_help())
|
|
149
|
+
raise typer.Exit()
|
|
150
|
+
start_repl()
|
|
151
|
+
raise typer.Exit()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def repl_command() -> None:
|
|
155
|
+
"""Start interactive persistent REPL session."""
|
|
156
|
+
start_repl()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Register all commands
|
|
160
|
+
app.command(name="repl", help="Start interactive persistent REPL session.")(repl_command)
|
|
161
|
+
app.command(name="init", help="Initialize FORGEX in a project directory.")(init_cmd.init_command)
|
|
162
|
+
app.command(name="doctor", help="Check environment, tools and configuration.")(doctor_cmd.doctor_command)
|
|
163
|
+
app.command(name="status", help="Show project status.")(status_cmd.status_command)
|
|
164
|
+
app.add_typer(config_cmd.app, name="config", help="View and modify configuration.")
|
|
165
|
+
app.command(name="logs", help="Show recorded FORGEX events.")(logs_cmd.logs_command)
|
|
166
|
+
app.add_typer(models_cmd.app, name="models", help="Inspect model bindings, catalog, and provider health.")
|
|
167
|
+
app.add_typer(tools_cmd.app, name="tools", help="Inspect and manually run FORGEX tools.")
|
|
168
|
+
app.command(name="plan", help="Run the Architect: analysis, research, implementation plan.")(plan_cmd.plan_command)
|
|
169
|
+
app.add_typer(task_cmd.app, name="task", help="Inspect and execute plan tasks.")
|
|
170
|
+
app.command(name="checkpoint", help="Create a Git checkpoint of the current state.")(checkpoint_cmd.checkpoint_command)
|
|
171
|
+
app.command(name="rollback", help="Roll back to a checkpoint (reversible).")(rollback_cmd.rollback_command)
|
|
172
|
+
app.command(name="diff", help="Show the current Git diff.")(diff_cmd.diff_command)
|
|
173
|
+
app.command(name="review", help="Expert review of recent changes.")(review_cmd.review_command)
|
|
174
|
+
app.command(name="verify", help="Run tests/build/lint — deterministic, no LLM.")(verify_cmd.verify_command)
|
|
175
|
+
app.command(name="supervise", help="Full supervision: verification + audit + verdict.")(supervise_cmd.supervise_command)
|
|
176
|
+
app.command(name="build", help="Full build: plan → build → review → verify → supervise → repair loop.")(build_cmd.build_command)
|
|
177
|
+
app.command(name="fix", help="Verify, supervise, and repair until green — no rebuild.")(fix_cmd.fix_command)
|
|
178
|
+
app.command(name="do", help="Autonomous execution: auto-init → plan → build → verify → repair → checkpoint.")(do_cmd.do_command)
|
|
179
|
+
app.command(name="run", help="Run the project's start command.")(run_cmd.run_command)
|
|
180
|
+
app.add_typer(context_cmd.app, name="context", help="Inspect the context index and retrieval.")
|
|
181
|
+
|
|
182
|
+
REGISTERED_COMMANDS = {
|
|
183
|
+
"repl",
|
|
184
|
+
"init",
|
|
185
|
+
"doctor",
|
|
186
|
+
"status",
|
|
187
|
+
"config",
|
|
188
|
+
"logs",
|
|
189
|
+
"models",
|
|
190
|
+
"tools",
|
|
191
|
+
"plan",
|
|
192
|
+
"task",
|
|
193
|
+
"checkpoint",
|
|
194
|
+
"rollback",
|
|
195
|
+
"diff",
|
|
196
|
+
"review",
|
|
197
|
+
"verify",
|
|
198
|
+
"supervise",
|
|
199
|
+
"build",
|
|
200
|
+
"fix",
|
|
201
|
+
"run",
|
|
202
|
+
"context",
|
|
203
|
+
"do",
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def main() -> None:
|
|
208
|
+
"""Console-script entry point with controlled error handling."""
|
|
209
|
+
# Natural language command line routing:
|
|
210
|
+
# If user invokes `forgex "add auth"` or `forgex build me a bot`, route to `do` command.
|
|
211
|
+
if len(sys.argv) > 1:
|
|
212
|
+
first = sys.argv[1].lower()
|
|
213
|
+
if not first.startswith("-") and first not in REGISTERED_COMMANDS or first == "build" and len(sys.argv) > 2 and not any(a.startswith("-") for a in sys.argv[2:]):
|
|
214
|
+
sys.argv = [sys.argv[0], "do", *sys.argv[1:]]
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
app()
|
|
218
|
+
except ForgexError as exc:
|
|
219
|
+
if os.environ.get("FORGEX_DEBUG"):
|
|
220
|
+
raise
|
|
221
|
+
Console(stderr=True).print(f"[red]✗[/red] {exc}")
|
|
222
|
+
raise SystemExit(exc.exit_code) from None
|
|
223
|
+
except KeyboardInterrupt:
|
|
224
|
+
Console(stderr=True).print("[yellow]Interrupted — on-disk state is preserved.[/yellow]")
|
|
225
|
+
raise SystemExit(130) from None
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
if os.environ.get("FORGEX_DEBUG"):
|
|
228
|
+
raise
|
|
229
|
+
err = Console(stderr=True)
|
|
230
|
+
err.print(f"[red]✗[/red] internal error: {type(exc).__name__}: {exc}")
|
|
231
|
+
err.print("[dim]re-run with --debug for the full traceback[/dim]")
|
|
232
|
+
raise SystemExit(70) from None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Shared output formatters for build and fix commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.markup import escape
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
|
|
8
|
+
from forgex.cli.ui import UI, Output
|
|
9
|
+
from forgex.orchestration.engine import BuildRunResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _tests_line(result: BuildRunResult) -> str:
|
|
13
|
+
tests = result.tests
|
|
14
|
+
if tests is None:
|
|
15
|
+
return "not run"
|
|
16
|
+
if tests.passed is None:
|
|
17
|
+
return f"exit {tests.exit_code}"
|
|
18
|
+
return f"{tests.passed} passed, {tests.failed or 0} failed"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_progress(line: str, *, rich_mode: bool) -> str:
|
|
22
|
+
"""Format an agent progress message with modern terminal styling."""
|
|
23
|
+
if not rich_mode:
|
|
24
|
+
return line
|
|
25
|
+
styled = line
|
|
26
|
+
if "Architect:" in line:
|
|
27
|
+
styled = line.replace("Architect:", "[bold cyan]● [Architect][/bold cyan]")
|
|
28
|
+
elif "Plan created:" in line:
|
|
29
|
+
styled = f"[green]✓[/green] [bold]{line}[/bold]"
|
|
30
|
+
elif "Builder:" in line or "Task " in line:
|
|
31
|
+
styled = line.replace("Builder:", "[bold blue]● [Builder][/bold blue]")
|
|
32
|
+
elif "FAILED" in line:
|
|
33
|
+
styled = f"[red]✗[/red] {line}"
|
|
34
|
+
elif "Supervisor:" in line:
|
|
35
|
+
styled = line.replace("Supervisor:", "[bold magenta]● [Supervisor][/bold magenta]")
|
|
36
|
+
elif "Self-healing" in line:
|
|
37
|
+
styled = f"[bold yellow]⟳ {line}[/bold yellow]"
|
|
38
|
+
elif "Testing" in line or "quality verification" in line:
|
|
39
|
+
styled = f"[bold yellow]● [Verification][/bold yellow] {line}"
|
|
40
|
+
return styled
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _render_success(ui: UI, result: BuildRunResult) -> None:
|
|
44
|
+
if ui.mode is Output.RICH:
|
|
45
|
+
ui.console.print(Panel("[bold green]FORGEX BUILD COMPLETE[/bold green]", expand=False))
|
|
46
|
+
lines = [
|
|
47
|
+
f"Tasks {result.tasks_completed}/{result.tasks_total}",
|
|
48
|
+
f"Files {len(result.changed_files)} changed",
|
|
49
|
+
f"Tests {_tests_line(result)}",
|
|
50
|
+
f"Build {'passed' if result.build_ok else 'failed' if result.build_ok is False else '—'}",
|
|
51
|
+
f"Supervisor {result.verdict}",
|
|
52
|
+
f"Iterations {result.iterations}",
|
|
53
|
+
f"Checkpoint {result.checkpoint[:8] if result.checkpoint else '—'}",
|
|
54
|
+
]
|
|
55
|
+
if result.usage:
|
|
56
|
+
prompt_tok = result.usage.get("prompt_tokens") or 0
|
|
57
|
+
comp_tok = result.usage.get("completion_tokens") or 0
|
|
58
|
+
cost = result.usage.get("estimated_cost_usd")
|
|
59
|
+
if prompt_tok > 0 or comp_tok > 0:
|
|
60
|
+
cost_str = f" (${cost:.4f})" if cost is not None else ""
|
|
61
|
+
lines.append(f"Tokens {prompt_tok:,} in / {comp_tok:,} out{cost_str}")
|
|
62
|
+
for line in lines:
|
|
63
|
+
ui.text(line)
|
|
64
|
+
if result.changed_files and ui.mode in (Output.RICH, Output.PLAIN):
|
|
65
|
+
ui.text("")
|
|
66
|
+
ui.text("Changed files:")
|
|
67
|
+
for path in result.changed_files[:30]:
|
|
68
|
+
ui.text(f" {escape(path)}")
|
|
69
|
+
for warning in result.warnings:
|
|
70
|
+
ui.warn(warning)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _render_failure(ui: UI, result: BuildRunResult) -> None:
|
|
74
|
+
if ui.mode is Output.RICH:
|
|
75
|
+
ui.console.print(Panel(f"[bold red]FORGEX BUILD FAILED[/bold red]\n{result.reason}", expand=False))
|
|
76
|
+
else:
|
|
77
|
+
ui.error(f"FORGEX BUILD FAILED: {result.reason}")
|
|
78
|
+
|
|
79
|
+
lines = [
|
|
80
|
+
f"Tasks {result.tasks_completed}/{result.tasks_total}",
|
|
81
|
+
f"Remaining {', '.join(result.remaining_tasks) or 'none'}",
|
|
82
|
+
f"Tests {_tests_line(result)}",
|
|
83
|
+
f"Supervisor {result.verdict}",
|
|
84
|
+
f"Iterations {result.iterations}",
|
|
85
|
+
]
|
|
86
|
+
if result.usage:
|
|
87
|
+
prompt_tok = result.usage.get("prompt_tokens") or 0
|
|
88
|
+
comp_tok = result.usage.get("completion_tokens") or 0
|
|
89
|
+
cost = result.usage.get("estimated_cost_usd")
|
|
90
|
+
if prompt_tok > 0 or comp_tok > 0:
|
|
91
|
+
cost_str = f" (${cost:.4f})" if cost is not None else ""
|
|
92
|
+
lines.append(f"Tokens {prompt_tok:,} in / {comp_tok:,} out{cost_str}")
|
|
93
|
+
for line in lines:
|
|
94
|
+
ui.text(line)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules."""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""`forgex build` — full multi-agent build pipeline with repair loop (spec sections 12, 22, 57, 58)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from forgex.cli.build_views import _render_failure, _render_success, format_progress
|
|
12
|
+
from forgex.cli.ui import JsonFlag, Output, PlainFlag, make_approval, ui_from
|
|
13
|
+
from forgex.config import load_config
|
|
14
|
+
from forgex.orchestration.engine import BuildEngine, DryRunReport
|
|
15
|
+
from forgex.orchestration.stores import open_stores
|
|
16
|
+
from forgex.schemas.error import ProjectError
|
|
17
|
+
from forgex.utils.paths import find_project_root
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_command(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
request: Annotated[str | None, typer.Argument(help="What should be built?")] = None,
|
|
23
|
+
file: Annotated[Path | None, typer.Option("--file", "-f", help="Read user request from file.")] = None,
|
|
24
|
+
resume: Annotated[bool, typer.Option("--resume", "-r", help="Resume interrupted build.")] = False,
|
|
25
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show execution preview without making changes.")] = False,
|
|
26
|
+
json_out: JsonFlag = False,
|
|
27
|
+
plain: PlainFlag = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Full build: plan → build → review → verify → supervise → repair loop."""
|
|
30
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
31
|
+
root = find_project_root()
|
|
32
|
+
if root is None:
|
|
33
|
+
raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
|
|
34
|
+
|
|
35
|
+
user_req = request
|
|
36
|
+
if file and file.is_file():
|
|
37
|
+
user_req = file.read_text(encoding="utf-8")
|
|
38
|
+
|
|
39
|
+
config = load_config(root)
|
|
40
|
+
with open_stores(root) as stores:
|
|
41
|
+
engine = BuildEngine(
|
|
42
|
+
stores=stores,
|
|
43
|
+
config=config,
|
|
44
|
+
approval=make_approval(ui),
|
|
45
|
+
progress=lambda line: ui.text(format_progress(line, rich_mode=(ui.mode is Output.RICH))),
|
|
46
|
+
)
|
|
47
|
+
result = asyncio.run(engine.run(user_req, dry_run=dry_run, resume=resume))
|
|
48
|
+
|
|
49
|
+
if isinstance(result, DryRunReport):
|
|
50
|
+
ui.data(result.model_dump(mode="json"))
|
|
51
|
+
ui.ok(f"Dry run preview: {len(result.planned_tasks)} planned tasks, {len(result.planned_files)} files.")
|
|
52
|
+
for t in result.planned_tasks:
|
|
53
|
+
ui.text(f" - [{t.get('priority')}] {t.get('id')}: {t.get('description')}")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
ui.data(result.model_dump(mode="json"))
|
|
57
|
+
if ui.mode in (Output.RICH, Output.PLAIN):
|
|
58
|
+
if result.status == "completed":
|
|
59
|
+
_render_success(ui, result)
|
|
60
|
+
else:
|
|
61
|
+
_render_failure(ui, result)
|
|
62
|
+
|
|
63
|
+
raise typer.Exit(code=0 if result.status == "completed" else 1)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""`forgex checkpoint` — create a Git checkpoint of current state (spec section 21)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from forgex.cli.ui import JsonFlag, PlainFlag, ui_from
|
|
10
|
+
from forgex.orchestration.checkpoints import create_checkpoint
|
|
11
|
+
from forgex.orchestration.stores import open_stores
|
|
12
|
+
from forgex.schemas.error import ProjectError
|
|
13
|
+
from forgex.tools.git import has_git_repo
|
|
14
|
+
from forgex.utils.paths import find_project_root
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def checkpoint_command(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
label: Annotated[str | None, typer.Argument(help="Checkpoint label")] = None,
|
|
20
|
+
json_out: JsonFlag = False,
|
|
21
|
+
plain: PlainFlag = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Commit all current work as a FORGEX checkpoint."""
|
|
24
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
25
|
+
root = find_project_root()
|
|
26
|
+
if root is None:
|
|
27
|
+
raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
|
|
28
|
+
|
|
29
|
+
if not has_git_repo(root):
|
|
30
|
+
ui.warn("No git repository detected — checkpointing requires git. Run 'git init'.")
|
|
31
|
+
raise typer.Exit(code=0)
|
|
32
|
+
|
|
33
|
+
with open_stores(root) as stores:
|
|
34
|
+
record = create_checkpoint(label or "manual", stores=stores)
|
|
35
|
+
|
|
36
|
+
if record is None:
|
|
37
|
+
ui.error("Checkpoint failed (git error — see git status).")
|
|
38
|
+
raise typer.Exit(code=1)
|
|
39
|
+
|
|
40
|
+
ui.data({"id": record.id, "label": record.label, "git_commit": record.git_commit})
|
|
41
|
+
ui.ok(f"Checkpoint {record.id} — {record.git_commit[:8] if record.git_commit else '?'} ({record.label})")
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""`forgex config` — view and modify layered configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.prompt import Confirm, Prompt
|
|
12
|
+
|
|
13
|
+
from forgex.cli.ui import JsonFlag, PlainFlag, ui_from
|
|
14
|
+
from forgex.config import (
|
|
15
|
+
CONFIG_FILE_NAME,
|
|
16
|
+
global_config_path,
|
|
17
|
+
load_config,
|
|
18
|
+
project_config_path,
|
|
19
|
+
set_config_value,
|
|
20
|
+
store_secret,
|
|
21
|
+
)
|
|
22
|
+
from forgex.schemas.error import ConfigError
|
|
23
|
+
from forgex.utils.paths import find_project_root
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(no_args_is_help=True, help="View and modify FORGEX configuration.")
|
|
26
|
+
|
|
27
|
+
GlobalFlag = Annotated[
|
|
28
|
+
bool,
|
|
29
|
+
typer.Option("--global", "-g", help="Operate on ~/.forgex/config.toml instead of project config."),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _target_path(global_: bool) -> Path:
|
|
34
|
+
if global_:
|
|
35
|
+
return global_config_path()
|
|
36
|
+
root = find_project_root()
|
|
37
|
+
if root is None:
|
|
38
|
+
raise ConfigError("Not inside a FORGEX project. Use --global to edit global configuration.")
|
|
39
|
+
return project_config_path(root)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_dotted(data: dict[str, Any], dotted: str) -> Any:
|
|
43
|
+
node: Any = data
|
|
44
|
+
for part in dotted.split("."):
|
|
45
|
+
if not isinstance(node, dict) or part not in node:
|
|
46
|
+
raise ConfigError(f"Configuration key '{dotted}' not found")
|
|
47
|
+
node = node[part]
|
|
48
|
+
return node
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command("show")
|
|
52
|
+
def show_command(
|
|
53
|
+
ctx: typer.Context,
|
|
54
|
+
json_out: JsonFlag = False,
|
|
55
|
+
plain: PlainFlag = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Show the currently active merged configuration."""
|
|
58
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
59
|
+
root = find_project_root()
|
|
60
|
+
config = load_config(root)
|
|
61
|
+
payload = config.model_dump(mode="json", exclude_none=True)
|
|
62
|
+
ui.data(payload)
|
|
63
|
+
if not json_out and not ctx.obj.get("json"):
|
|
64
|
+
ui.text(json.dumps(payload, indent=2))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("get")
|
|
68
|
+
def get_command(
|
|
69
|
+
ctx: typer.Context,
|
|
70
|
+
key: Annotated[str, typer.Argument(help="Dotted configuration key, e.g. workflow.max_iterations")],
|
|
71
|
+
json_out: JsonFlag = False,
|
|
72
|
+
plain: PlainFlag = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Get a configuration value by dotted key."""
|
|
75
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
76
|
+
root = find_project_root()
|
|
77
|
+
config = load_config(root)
|
|
78
|
+
val = _get_dotted(config.model_dump(mode="json"), key)
|
|
79
|
+
ui.data({"key": key, "value": val})
|
|
80
|
+
if not json_out and not ctx.obj.get("json"):
|
|
81
|
+
ui.text(str(val))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("set")
|
|
85
|
+
def set_command(
|
|
86
|
+
ctx: typer.Context,
|
|
87
|
+
key: Annotated[str, typer.Argument(help="Dotted configuration key, e.g. models.architect.model")],
|
|
88
|
+
value: Annotated[str, typer.Argument(help="New value")],
|
|
89
|
+
global_: GlobalFlag = False,
|
|
90
|
+
json_out: JsonFlag = False,
|
|
91
|
+
plain: PlainFlag = False,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Set a configuration value in project (or global) config.toml."""
|
|
94
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
95
|
+
path = _target_path(global_)
|
|
96
|
+
set_config_value(path, key, value)
|
|
97
|
+
ui.data({"key": key, "value": value, "path": str(path)})
|
|
98
|
+
ui.ok(f"Set {key} = {value} in {path}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class SetupAnswers:
|
|
103
|
+
provider: str
|
|
104
|
+
base_url: str | None = None
|
|
105
|
+
api_key_env: str | None = None
|
|
106
|
+
api_key: str | None = None
|
|
107
|
+
models: dict[str, str] = field(default_factory=dict)
|
|
108
|
+
sandbox_enabled: bool = False
|
|
109
|
+
approval_mode: str = "ask"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def apply_setup(answers: SetupAnswers, *, global_dir: Path) -> None:
|
|
113
|
+
path = global_dir / CONFIG_FILE_NAME
|
|
114
|
+
if answers.base_url:
|
|
115
|
+
set_config_value(path, f"providers.{answers.provider}.type", "openai_compatible")
|
|
116
|
+
set_config_value(path, f"providers.{answers.provider}.base_url", answers.base_url)
|
|
117
|
+
if answers.api_key_env:
|
|
118
|
+
set_config_value(path, f"providers.{answers.provider}.api_key_env", answers.api_key_env)
|
|
119
|
+
if answers.api_key and answers.api_key_env:
|
|
120
|
+
store_secret(answers.api_key_env, answers.api_key, global_dir=global_dir)
|
|
121
|
+
|
|
122
|
+
for role, model in answers.models.items():
|
|
123
|
+
set_config_value(path, f"models.{role}.provider", answers.provider)
|
|
124
|
+
set_config_value(path, f"models.{role}.model", model)
|
|
125
|
+
|
|
126
|
+
set_config_value(path, "sandbox.enabled", "true" if answers.sandbox_enabled else "false")
|
|
127
|
+
set_config_value(path, "permissions.mode", answers.approval_mode)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _test_provider_connectivity(provider: str, base_url: str | None, api_key: str | None) -> tuple[bool, str]:
|
|
131
|
+
"""Perform a lightweight test request to verify API connectivity."""
|
|
132
|
+
import httpx
|
|
133
|
+
|
|
134
|
+
url = (base_url or ("https://api.groq.com/openai/v1" if provider == "groq" else "https://api.openai.com/v1")).rstrip("/") + "/models"
|
|
135
|
+
headers = {}
|
|
136
|
+
if api_key:
|
|
137
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
138
|
+
try:
|
|
139
|
+
with httpx.Client(timeout=4.0) as client:
|
|
140
|
+
resp = client.get(url, headers=headers)
|
|
141
|
+
if resp.status_code in (200, 201):
|
|
142
|
+
return True, "Connection verified (HTTP 200)"
|
|
143
|
+
return False, f"Provider returned HTTP {resp.status_code}"
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
return False, str(exc)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command("setup")
|
|
149
|
+
def setup_command(
|
|
150
|
+
ctx: typer.Context,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Interactive setup wizard for provider credentials and defaults."""
|
|
153
|
+
ui = ui_from(ctx)
|
|
154
|
+
ui.text("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
|
155
|
+
ui.text("FORGEX Configuration Setup Wizard — Welcome!")
|
|
156
|
+
ui.text("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
|
157
|
+
ui.text("Security & Terms Notice:")
|
|
158
|
+
ui.text(" • FORGEX operates locally and executes developer tools.")
|
|
159
|
+
ui.text(" • Reversible checkpoints are created before file edits.")
|
|
160
|
+
ui.text(" • API keys are stored with restricted permissions (0600) in ~/.forgex/.env.")
|
|
161
|
+
ui.text(" • You control permissions via mode: ask (default), auto, or safe.\n")
|
|
162
|
+
|
|
163
|
+
prov = Prompt.ask("Choose provider", choices=["groq", "openai", "local", "custom"], default="groq")
|
|
164
|
+
|
|
165
|
+
base_url = None
|
|
166
|
+
api_key_env: str | None = "FORGEX_GROQ_API_KEY"
|
|
167
|
+
api_key = None
|
|
168
|
+
|
|
169
|
+
if prov == "groq":
|
|
170
|
+
provider_name = "groq"
|
|
171
|
+
api_key = Prompt.ask("Enter your Groq API key (leave empty to skip)", password=True, default="")
|
|
172
|
+
default_model = "llama-3.3-70b-versatile"
|
|
173
|
+
elif prov == "openai":
|
|
174
|
+
provider_name = "openai_compatible"
|
|
175
|
+
api_key_env = "FORGEX_OPENAI_API_KEY"
|
|
176
|
+
api_key = Prompt.ask("Enter your OpenAI API key (leave empty to skip)", password=True, default="")
|
|
177
|
+
default_model = "gpt-4o-mini"
|
|
178
|
+
elif prov == "local":
|
|
179
|
+
provider_name = "local"
|
|
180
|
+
base_url = Prompt.ask("Enter local OpenAI-compatible endpoint", default="http://localhost:11434/v1")
|
|
181
|
+
api_key_env = None
|
|
182
|
+
default_model = Prompt.ask("Enter model name", default="llama3.1:8b")
|
|
183
|
+
else:
|
|
184
|
+
provider_name = Prompt.ask("Enter custom provider name", default="custom")
|
|
185
|
+
base_url = Prompt.ask("Enter base URL", default="http://localhost:8080/v1")
|
|
186
|
+
api_key_env = f"FORGEX_{provider_name.upper()}_API_KEY"
|
|
187
|
+
api_key = Prompt.ask("Enter API key (if required)", password=True, default="")
|
|
188
|
+
default_model = Prompt.ask("Enter default model", default="qwen2.5:14b")
|
|
189
|
+
|
|
190
|
+
# Health check verification
|
|
191
|
+
if api_key or (prov == "local" and base_url):
|
|
192
|
+
ui.text(f"Testing connectivity to {provider_name}...")
|
|
193
|
+
ok, detail = _test_provider_connectivity(provider_name, base_url, api_key)
|
|
194
|
+
if ok:
|
|
195
|
+
ui.ok(f"Provider connection verified: {detail}")
|
|
196
|
+
else:
|
|
197
|
+
ui.warn(f"Provider test: {detail} (proceeding; test with `forgex doctor --ping`)")
|
|
198
|
+
|
|
199
|
+
models = {
|
|
200
|
+
"architect": default_model,
|
|
201
|
+
"builder": default_model,
|
|
202
|
+
"expert_coder": default_model,
|
|
203
|
+
"supervisor": default_model,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if Confirm.ask("Customize models for specific agent roles?", default=False):
|
|
207
|
+
for role in ("architect", "builder", "expert_coder", "supervisor"):
|
|
208
|
+
models[role] = Prompt.ask(f"Model for {role}", default=default_model)
|
|
209
|
+
|
|
210
|
+
use_sandbox = Confirm.ask("Enable Docker sandbox for command execution?", default=False)
|
|
211
|
+
mode = Prompt.ask("Approval mode for commands and network", choices=["auto", "ask", "safe"], default="ask")
|
|
212
|
+
|
|
213
|
+
answers = SetupAnswers(
|
|
214
|
+
provider=provider_name,
|
|
215
|
+
base_url=base_url,
|
|
216
|
+
api_key_env=api_key_env,
|
|
217
|
+
api_key=api_key or None,
|
|
218
|
+
models=models,
|
|
219
|
+
sandbox_enabled=use_sandbox,
|
|
220
|
+
approval_mode=mode,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
g_dir = global_config_path().parent
|
|
224
|
+
apply_setup(answers, global_dir=g_dir)
|
|
225
|
+
ui.ok(f"Setup complete! Config written to {g_dir}")
|