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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""`forgex context` — inspect the context index and retrieval (spec section 17)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from forgex.cli.ui import JsonFlag, Output, PlainFlag, ui_from
|
|
11
|
+
from forgex.context.manager import ContextManager
|
|
12
|
+
from forgex.schemas.error import ProjectError
|
|
13
|
+
from forgex.utils.paths import find_project_root
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(no_args_is_help=True, help="Inspect the project context index.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("stats")
|
|
19
|
+
def stats_command(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
refresh: Annotated[bool, typer.Option("--refresh", help="Force a full re-index.")] = False,
|
|
22
|
+
json_out: JsonFlag = False,
|
|
23
|
+
plain: PlainFlag = False,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Index summary: files, languages, symbols, cache stats."""
|
|
26
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
27
|
+
root = find_project_root()
|
|
28
|
+
if root is None:
|
|
29
|
+
raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
|
|
30
|
+
|
|
31
|
+
manager = ContextManager(root)
|
|
32
|
+
index = manager.ensure_index(refresh=refresh)
|
|
33
|
+
languages: dict[str, int] = {}
|
|
34
|
+
symbols = 0
|
|
35
|
+
imports = 0
|
|
36
|
+
for entry in index.files.values():
|
|
37
|
+
languages[entry.language] = languages.get(entry.language, 0) + 1
|
|
38
|
+
symbols += len(entry.symbols)
|
|
39
|
+
imports += len(entry.imports)
|
|
40
|
+
|
|
41
|
+
payload = {
|
|
42
|
+
"files": len(index.files),
|
|
43
|
+
"languages": languages,
|
|
44
|
+
"symbols": symbols,
|
|
45
|
+
"imports": imports,
|
|
46
|
+
"stats": index.stats,
|
|
47
|
+
}
|
|
48
|
+
ui.data(payload)
|
|
49
|
+
|
|
50
|
+
if ui.mode is Output.RICH:
|
|
51
|
+
table = Table(show_header=True, header_style="bold")
|
|
52
|
+
table.add_column("Metric")
|
|
53
|
+
table.add_column("Value")
|
|
54
|
+
table.add_row("Total Files", str(len(index.files)))
|
|
55
|
+
table.add_row("Languages", ", ".join(f"{k}: {v}" for k, v in languages.items()))
|
|
56
|
+
table.add_row("Symbols", str(symbols))
|
|
57
|
+
table.add_row("Imports", str(imports))
|
|
58
|
+
table.add_row("Cache Stats", str(index.stats))
|
|
59
|
+
ui.console.print(table)
|
|
60
|
+
elif ui.mode is Output.PLAIN:
|
|
61
|
+
ui.text(f"Files: {len(index.files)} | Symbols: {symbols} | Imports: {imports} | Stats: {index.stats}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("search")
|
|
65
|
+
def search_command(
|
|
66
|
+
ctx: typer.Context,
|
|
67
|
+
query: Annotated[str, typer.Option("--query", "-q", help="Search query")] = "",
|
|
68
|
+
seeds: Annotated[list[str] | None, typer.Option("--seeds", "-s", help="Seed file paths")] = None,
|
|
69
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Max results")] = 10,
|
|
70
|
+
json_out: JsonFlag = False,
|
|
71
|
+
plain: PlainFlag = False,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Relevance search across project files."""
|
|
74
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
75
|
+
root = find_project_root()
|
|
76
|
+
if root is None:
|
|
77
|
+
raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
|
|
78
|
+
|
|
79
|
+
manager = ContextManager(root)
|
|
80
|
+
ranked = manager.relevant_files(query=query, seeds=seeds or [], limit=limit)
|
|
81
|
+
payload = [{"path": r.path, "score": r.score, "reasons": r.reasons} for r in ranked]
|
|
82
|
+
ui.data({"results": payload})
|
|
83
|
+
|
|
84
|
+
if ui.mode is Output.RICH:
|
|
85
|
+
table = Table(show_header=True, header_style="bold")
|
|
86
|
+
table.add_column("Score", style="cyan")
|
|
87
|
+
table.add_column("Path")
|
|
88
|
+
table.add_column("Reasons", overflow="fold")
|
|
89
|
+
for r in ranked:
|
|
90
|
+
table.add_row(str(r.score), r.path, "; ".join(r.reasons))
|
|
91
|
+
ui.console.print(table)
|
|
92
|
+
elif ui.mode is Output.PLAIN:
|
|
93
|
+
for r in ranked:
|
|
94
|
+
ui.text(f"[{r.score}] {r.path}: {'; '.join(r.reasons)}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""`forgex diff` — show the current Git diff (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.schemas.error import ProjectError
|
|
11
|
+
from forgex.tools.git import git_diff, has_git_repo
|
|
12
|
+
from forgex.utils.paths import find_project_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def diff_command(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
staged: Annotated[bool, typer.Option("--staged", help="Show staged changes.")] = False,
|
|
18
|
+
json_out: JsonFlag = False,
|
|
19
|
+
plain: PlainFlag = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Show the current Git diff."""
|
|
22
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
23
|
+
root = find_project_root()
|
|
24
|
+
if root is None:
|
|
25
|
+
raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
|
|
26
|
+
if not has_git_repo(root):
|
|
27
|
+
ui.text("not a git repository")
|
|
28
|
+
raise typer.Exit(code=0)
|
|
29
|
+
|
|
30
|
+
diff = git_diff(root, staged=staged)
|
|
31
|
+
ui.data({"staged": staged, "diff": diff})
|
|
32
|
+
ui.text(diff or "(no changes)")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""`forgex do` — end-to-end autonomous execution pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from forgex.cli.progress import render_pipeline_header, render_runtime_summary
|
|
11
|
+
from forgex.cli.runtime import AutonomousRuntime
|
|
12
|
+
from forgex.cli.ui import JsonFlag, Output, PlainFlag, ui_from
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def do_command(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
request: Annotated[list[str], typer.Argument(help="What to build or modify")],
|
|
18
|
+
resume: Annotated[bool, typer.Option("--resume", "-r", help="Resume interrupted build.")] = False,
|
|
19
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show execution preview without making changes.")] = False,
|
|
20
|
+
json_out: JsonFlag = False,
|
|
21
|
+
plain: PlainFlag = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Run full autonomous pipeline: auto-init → plan → build → verify → repair → checkpoint."""
|
|
24
|
+
req_text = " ".join(request).strip()
|
|
25
|
+
if not req_text:
|
|
26
|
+
typer.echo("Error: Please describe what to build or modify.")
|
|
27
|
+
raise typer.Exit(code=2)
|
|
28
|
+
|
|
29
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
30
|
+
if ui.mode is Output.RICH and not dry_run:
|
|
31
|
+
render_pipeline_header(req_text, console=ui.console)
|
|
32
|
+
|
|
33
|
+
runtime = AutonomousRuntime(ui=ui)
|
|
34
|
+
result = asyncio.run(runtime.execute(request=req_text, dry_run=dry_run, resume=resume))
|
|
35
|
+
|
|
36
|
+
if json_out or (ctx.obj and ctx.obj.get("json")):
|
|
37
|
+
ui.data({
|
|
38
|
+
"status": result.status,
|
|
39
|
+
"request": result.request,
|
|
40
|
+
"tasks_total": result.tasks_total,
|
|
41
|
+
"tasks_completed": result.tasks_completed,
|
|
42
|
+
"changed_files": result.changed_files,
|
|
43
|
+
"checkpoint": result.checkpoint_id,
|
|
44
|
+
"phases": result.phases_completed,
|
|
45
|
+
"duration": result.duration_seconds,
|
|
46
|
+
})
|
|
47
|
+
else:
|
|
48
|
+
render_runtime_summary(result, console=ui.console)
|
|
49
|
+
|
|
50
|
+
raise typer.Exit(code=0 if result.is_success else 1)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""`forgex doctor` — environment, tools, and configuration health checks (spec section 37)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sqlite3
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from forgex import __version__
|
|
18
|
+
from forgex.cli.ui import JsonFlag, Output, PlainFlag, ui_from
|
|
19
|
+
from forgex.config import global_config_path, load_config
|
|
20
|
+
from forgex.models.router import collect_health
|
|
21
|
+
from forgex.schemas.error import ConfigError
|
|
22
|
+
from forgex.utils.paths import find_project_root
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Check:
|
|
27
|
+
name: str
|
|
28
|
+
ok: bool
|
|
29
|
+
required: bool
|
|
30
|
+
detail: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _check_python() -> Check:
|
|
34
|
+
v = sys.version_info
|
|
35
|
+
ok = (v.major, v.minor) >= (3, 12)
|
|
36
|
+
return Check(
|
|
37
|
+
"Python",
|
|
38
|
+
ok,
|
|
39
|
+
required=True,
|
|
40
|
+
detail=f"{v.major}.{v.minor}.{v.micro} (>= 3.12 required)",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check_binary(name: str, *, required: bool, purpose: str) -> Check:
|
|
45
|
+
found = shutil.which(name) is not None
|
|
46
|
+
detail = f"found — {purpose}" if found else f"not found — {purpose}"
|
|
47
|
+
return Check(name, found, required=required, detail=detail)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_docker(config) -> list[Check]:
|
|
51
|
+
found = shutil.which("docker") is not None
|
|
52
|
+
sandbox_on = config is not None and config.sandbox.enabled
|
|
53
|
+
purpose = "sandboxed execution" + (" (ENABLED)" if sandbox_on else " (disabled)")
|
|
54
|
+
checks = [Check("Docker", found, required=sandbox_on, detail=f"found — {purpose}" if found else f"not found — {purpose}")]
|
|
55
|
+
if sandbox_on and found:
|
|
56
|
+
image = config.sandbox.image
|
|
57
|
+
try:
|
|
58
|
+
proc = subprocess.run(
|
|
59
|
+
["docker", "image", "inspect", image],
|
|
60
|
+
capture_output=True,
|
|
61
|
+
timeout=15,
|
|
62
|
+
check=False,
|
|
63
|
+
)
|
|
64
|
+
ok = proc.returncode == 0
|
|
65
|
+
detail = f"image {image} present" if ok else f"image missing — run: docker pull {image}"
|
|
66
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
67
|
+
ok, detail = False, "docker daemon unreachable"
|
|
68
|
+
checks.append(Check("Sandbox image", ok, required=False, detail=detail))
|
|
69
|
+
return checks
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _check_credentials(config) -> list[Check]:
|
|
73
|
+
checks = []
|
|
74
|
+
for name, p_cfg in sorted(config.providers.items()):
|
|
75
|
+
env_var = p_cfg.api_key_env
|
|
76
|
+
if not env_var:
|
|
77
|
+
continue
|
|
78
|
+
val = os.environ.get(env_var)
|
|
79
|
+
checks.append(
|
|
80
|
+
Check(
|
|
81
|
+
f"Credential: {env_var}",
|
|
82
|
+
bool(val),
|
|
83
|
+
required=False,
|
|
84
|
+
detail=f"configured for provider '{name}'" if val else f"not set in environment (used by {name})",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return checks
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _check_workspace(root: Path) -> Check:
|
|
91
|
+
return Check("Workspace", root.is_dir(), required=True, detail=str(root))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_sqlite() -> Check:
|
|
95
|
+
try:
|
|
96
|
+
conn = sqlite3.connect(":memory:")
|
|
97
|
+
conn.execute("CREATE TABLE t (x INT);")
|
|
98
|
+
conn.close()
|
|
99
|
+
except Exception as exc: # noqa: BLE001
|
|
100
|
+
return Check("SQLite", False, required=True, detail=str(exc))
|
|
101
|
+
return Check("SQLite", True, required=True, detail="available (metadata storage)")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def doctor_command(
|
|
105
|
+
ctx: typer.Context,
|
|
106
|
+
json_out: JsonFlag = False,
|
|
107
|
+
plain: PlainFlag = False,
|
|
108
|
+
ping: Annotated[bool, typer.Option("--ping", help="Also ping configured providers (network).")] = False,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Check environment, tools and configuration."""
|
|
111
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
112
|
+
root = find_project_root() or Path.cwd()
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
config = load_config(root)
|
|
116
|
+
config_error = None
|
|
117
|
+
except ConfigError as exc:
|
|
118
|
+
config, config_error = None, str(exc)
|
|
119
|
+
|
|
120
|
+
checks = [
|
|
121
|
+
_check_python(),
|
|
122
|
+
_check_binary("git", required=False, purpose="checkpoints and diffs"),
|
|
123
|
+
_check_binary("rg", required=False, purpose="fast code search (falls back to Python)"),
|
|
124
|
+
]
|
|
125
|
+
checks.extend(_check_docker(config))
|
|
126
|
+
|
|
127
|
+
if config is not None:
|
|
128
|
+
gpath = global_config_path()
|
|
129
|
+
ppath = root / ".forgex" / "config.toml" if find_project_root() else None
|
|
130
|
+
sources = []
|
|
131
|
+
if gpath.is_file():
|
|
132
|
+
sources.append("global")
|
|
133
|
+
if ppath and ppath.is_file():
|
|
134
|
+
sources.append("project")
|
|
135
|
+
detail = "valid — " + (", ".join(sources) if sources else "defaults")
|
|
136
|
+
checks.append(Check("Configuration", True, required=True, detail=detail))
|
|
137
|
+
checks.extend(_check_credentials(config))
|
|
138
|
+
else:
|
|
139
|
+
checks.append(Check("Configuration", False, required=True, detail=config_error or "invalid"))
|
|
140
|
+
|
|
141
|
+
checks.append(_check_workspace(root))
|
|
142
|
+
checks.append(_check_sqlite())
|
|
143
|
+
|
|
144
|
+
if ping and config is not None:
|
|
145
|
+
for name, health in sorted(collect_health(config).items()):
|
|
146
|
+
checks.append(Check(f"Provider: {name}", health.ok, required=False, detail=health.detail))
|
|
147
|
+
|
|
148
|
+
all_required_ok = all(c.ok for c in checks if c.required)
|
|
149
|
+
ui.data({
|
|
150
|
+
"forgex": __version__,
|
|
151
|
+
"root": str(root),
|
|
152
|
+
"ok": all_required_ok,
|
|
153
|
+
"checks": [asdict(c) for c in checks],
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if ui.mode is Output.RICH:
|
|
157
|
+
table = Table(show_header=True, header_style="bold", expand=False)
|
|
158
|
+
table.add_column("Check", style="bold")
|
|
159
|
+
table.add_column("Status")
|
|
160
|
+
table.add_column("Detail", overflow="fold")
|
|
161
|
+
for c in checks:
|
|
162
|
+
if c.ok:
|
|
163
|
+
status = "[green]✓[/green]"
|
|
164
|
+
elif c.required:
|
|
165
|
+
status = "[red]✗[/red]"
|
|
166
|
+
else:
|
|
167
|
+
status = "[yellow]○[/yellow] optional"
|
|
168
|
+
table.add_row(c.name, status, c.detail)
|
|
169
|
+
ui.console.print(table)
|
|
170
|
+
elif ui.mode is Output.PLAIN:
|
|
171
|
+
for c in checks:
|
|
172
|
+
label = "OK" if c.ok else ("FAIL" if c.required else "WARN")
|
|
173
|
+
ui.console.print(f"{label}: {c.name} — {c.detail}")
|
|
174
|
+
|
|
175
|
+
raise typer.Exit(code=0 if all_required_ok else 1)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""`forgex fix` — verify → supervise → repair until green (spec sections 12, 22)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from forgex.cli.build_views import _render_failure, _render_success, format_progress
|
|
10
|
+
from forgex.cli.ui import JsonFlag, Output, PlainFlag, make_approval, ui_from
|
|
11
|
+
from forgex.config import load_config
|
|
12
|
+
from forgex.orchestration.engine import BuildEngine
|
|
13
|
+
from forgex.orchestration.stores import open_stores
|
|
14
|
+
from forgex.schemas.error import ProjectError
|
|
15
|
+
from forgex.utils.paths import find_project_root
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fix_command(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
json_out: JsonFlag = False,
|
|
21
|
+
plain: PlainFlag = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Run verification + supervision + the repair loop. No rebuild."""
|
|
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
|
+
config = load_config(root)
|
|
30
|
+
with open_stores(root) as stores:
|
|
31
|
+
engine = BuildEngine(
|
|
32
|
+
stores=stores,
|
|
33
|
+
config=config,
|
|
34
|
+
approval=make_approval(ui),
|
|
35
|
+
progress=lambda line: ui.text(format_progress(line, rich_mode=(ui.mode is Output.RICH))),
|
|
36
|
+
)
|
|
37
|
+
result = asyncio.run(engine.fix())
|
|
38
|
+
|
|
39
|
+
ui.data(result.model_dump(mode="json"))
|
|
40
|
+
if ui.mode in (Output.RICH, Output.PLAIN):
|
|
41
|
+
if result.status == "completed":
|
|
42
|
+
_render_success(ui, result)
|
|
43
|
+
else:
|
|
44
|
+
_render_failure(ui, result)
|
|
45
|
+
|
|
46
|
+
raise typer.Exit(code=0 if result.status == "completed" else 1)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""`forgex init` — scaffold the .forgex directory and detect the project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from forgex.cli.ui import JsonFlag, PlainFlag, ui_from
|
|
11
|
+
from forgex.orchestration.state import StateStore
|
|
12
|
+
from forgex.project import detect_project, initialize_project
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def init_command(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
name: Annotated[str | None, typer.Argument(help="Target directory (defaults to current).")] = None,
|
|
18
|
+
json_out: JsonFlag = False,
|
|
19
|
+
plain: PlainFlag = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Initialize FORGEX in a project directory."""
|
|
22
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
23
|
+
target = Path(name).resolve() if name else Path.cwd()
|
|
24
|
+
|
|
25
|
+
root = initialize_project(target)
|
|
26
|
+
detection = detect_project(root)
|
|
27
|
+
state = StateStore(root).load()
|
|
28
|
+
|
|
29
|
+
ui.data({
|
|
30
|
+
"root": str(root),
|
|
31
|
+
"project_id": state.project_id if state else None,
|
|
32
|
+
"detection": detection.as_dict(),
|
|
33
|
+
})
|
|
34
|
+
ui.ok(f"Initialized FORGEX project at {root}")
|
|
35
|
+
if state:
|
|
36
|
+
ui.text(f"Project ID: {state.project_id[:8]}")
|
|
37
|
+
ui.text(f"Detected: {detection.summary()}")
|
|
38
|
+
if detection.has_git:
|
|
39
|
+
ui.text("Git repository detected — checkpoints will be available.")
|
|
40
|
+
else:
|
|
41
|
+
ui.warn("No Git repository detected. Run 'git init' to enable checkpoints.")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""`forgex logs` — inspect the project event stream (spec section 24)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
|
|
13
|
+
from forgex.cli.ui import JsonFlag, Output, PlainFlag, ui_from
|
|
14
|
+
from forgex.orchestration.events import read_events
|
|
15
|
+
from forgex.schemas.error import ConfigError, ProjectError
|
|
16
|
+
from forgex.schemas.event import Event, EventType
|
|
17
|
+
from forgex.utils.paths import find_project_root
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _pairs(event: Event, width: int = 60) -> str:
|
|
21
|
+
parts = []
|
|
22
|
+
for key, value in event.data.items():
|
|
23
|
+
text = str(value).replace("\n", " ")
|
|
24
|
+
if len(text) > width:
|
|
25
|
+
text = text[: width - 1] + "…"
|
|
26
|
+
parts.append(f"{key}={text}")
|
|
27
|
+
return " ".join(parts)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _render(event: Event, *, rich: bool) -> str:
|
|
31
|
+
ts = event.ts.strftime("%H:%M:%S")
|
|
32
|
+
run = f" run={event.run_id[:8]}" if event.run_id else ""
|
|
33
|
+
pairs = _pairs(event)
|
|
34
|
+
if rich:
|
|
35
|
+
return (
|
|
36
|
+
f"[dim]{ts}[/dim] [bold cyan]{event.type.value}[/bold cyan]"
|
|
37
|
+
f"[dim]{escape(run)} {escape(pairs)}[/dim]"
|
|
38
|
+
).rstrip()
|
|
39
|
+
return f"{ts} {event.type.value}{run} {pairs}".rstrip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _follow(events_path: Path, event_type: EventType | None, ui) -> None:
|
|
43
|
+
"""Poll JSONL and print appended events until interrupted."""
|
|
44
|
+
with events_path.open("r", encoding="utf-8") as fh:
|
|
45
|
+
fh.seek(0, os.SEEK_END)
|
|
46
|
+
while True:
|
|
47
|
+
line = fh.readline()
|
|
48
|
+
if not line:
|
|
49
|
+
time.sleep(0.5)
|
|
50
|
+
continue
|
|
51
|
+
line = line.strip()
|
|
52
|
+
if not line:
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
event = Event.from_jsonl(line)
|
|
56
|
+
except Exception: # noqa: BLE001
|
|
57
|
+
continue
|
|
58
|
+
if event_type is not None and event.type is not event_type:
|
|
59
|
+
continue
|
|
60
|
+
if ui.mode is Output.JSON:
|
|
61
|
+
ui.console.print(event.model_dump_json(), soft_wrap=True, highlight=False)
|
|
62
|
+
else:
|
|
63
|
+
ui.console.print(_render(event, rich=(ui.mode is Output.RICH)))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def logs_command(
|
|
67
|
+
ctx: typer.Context,
|
|
68
|
+
event_type: Annotated[str | None, typer.Option("--type", "-t", help="Filter by EventType name.")] = None,
|
|
69
|
+
limit: Annotated[int | None, typer.Option("--limit", "-n", help="Limit event count (default: all).")] = None,
|
|
70
|
+
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow event stream in real-time.")] = False,
|
|
71
|
+
json_out: JsonFlag = False,
|
|
72
|
+
plain: PlainFlag = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Show recorded FORGEX events."""
|
|
75
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
76
|
+
root = find_project_root()
|
|
77
|
+
if root is None:
|
|
78
|
+
raise ProjectError("Not inside a FORGEX project — run `forgex init` first.")
|
|
79
|
+
|
|
80
|
+
etype = None
|
|
81
|
+
if event_type:
|
|
82
|
+
try:
|
|
83
|
+
etype = EventType(event_type)
|
|
84
|
+
except ValueError:
|
|
85
|
+
valid = ", ".join(e.value for e in EventType)
|
|
86
|
+
raise ConfigError(f"Unknown event type '{event_type}'. Valid: {valid}") from None
|
|
87
|
+
|
|
88
|
+
events_path = root / ".forgex" / "events.jsonl"
|
|
89
|
+
if not events_path.is_file():
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if follow:
|
|
93
|
+
_follow(events_path, etype, ui)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
events = read_events(events_path, limit=limit, event_type=etype)
|
|
97
|
+
if ui.mode is Output.JSON:
|
|
98
|
+
for e in events:
|
|
99
|
+
ui.console.print(e.model_dump_json(), soft_wrap=True, highlight=False)
|
|
100
|
+
else:
|
|
101
|
+
for e in events:
|
|
102
|
+
ui.console.print(_render(e, rich=(ui.mode is Output.RICH)))
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""`forgex models` — inspect agent bindings, catalog, and provider health."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from forgex.cli.ui import JsonFlag, Output, PlainFlag, ui_from
|
|
11
|
+
from forgex.config import load_config
|
|
12
|
+
from forgex.models.registry import ModelRegistry
|
|
13
|
+
from forgex.models.router import collect_health
|
|
14
|
+
from forgex.schemas.agent import AgentRole
|
|
15
|
+
from forgex.utils.paths import find_project_root
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="Inspect model bindings, catalog, and provider health.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _binding_rows(config) -> list[dict]:
|
|
21
|
+
rows = []
|
|
22
|
+
models_cfg = config.models
|
|
23
|
+
for role in AgentRole:
|
|
24
|
+
binding = getattr(models_cfg, role.value, None)
|
|
25
|
+
if not binding:
|
|
26
|
+
continue
|
|
27
|
+
prov_cfg = config.providers.get(binding.provider)
|
|
28
|
+
model = binding.model or (prov_cfg.default_model if prov_cfg else None) or "—"
|
|
29
|
+
env_var = prov_cfg.api_key_env if prov_cfg else None
|
|
30
|
+
cred = "set" if (env_var and os.environ.get(env_var)) else ("keyless" if not env_var else "missing")
|
|
31
|
+
rows.append({
|
|
32
|
+
"agent": role.value,
|
|
33
|
+
"provider": binding.provider,
|
|
34
|
+
"model": model,
|
|
35
|
+
"credential": cred,
|
|
36
|
+
})
|
|
37
|
+
return rows
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.callback(invoke_without_command=True)
|
|
41
|
+
def models_default(
|
|
42
|
+
ctx: typer.Context,
|
|
43
|
+
json_out: JsonFlag = False,
|
|
44
|
+
plain: PlainFlag = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
if ctx.invoked_subcommand is not None:
|
|
47
|
+
return
|
|
48
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
49
|
+
root = find_project_root()
|
|
50
|
+
config = load_config(root)
|
|
51
|
+
rows = _binding_rows(config)
|
|
52
|
+
|
|
53
|
+
ensemble_reviewers = None
|
|
54
|
+
if config.ensemble.enabled:
|
|
55
|
+
from forgex.agents.ensemble import resolve_reviewer_bindings
|
|
56
|
+
ensemble_reviewers = [f"{b.provider}/{b.model}" for b in resolve_reviewer_bindings(config)]
|
|
57
|
+
|
|
58
|
+
ui.data({"bindings": rows, "ensemble": ensemble_reviewers})
|
|
59
|
+
if ui.mode is Output.RICH:
|
|
60
|
+
table = Table(show_header=True, header_style="bold")
|
|
61
|
+
table.add_column("Agent")
|
|
62
|
+
table.add_column("Provider")
|
|
63
|
+
table.add_column("Model")
|
|
64
|
+
table.add_column("Credential")
|
|
65
|
+
for r in rows:
|
|
66
|
+
table.add_row(r["agent"], r["provider"], r["model"], r["credential"])
|
|
67
|
+
ui.console.print(table)
|
|
68
|
+
if ensemble_reviewers:
|
|
69
|
+
ui.text(f"Ensemble reviewers: {', '.join(ensemble_reviewers)}")
|
|
70
|
+
elif ui.mode is Output.PLAIN:
|
|
71
|
+
for r in rows:
|
|
72
|
+
ui.text(f"{r['agent']}: {r['provider']}/{r['model']} ({r['credential']})")
|
|
73
|
+
if ensemble_reviewers:
|
|
74
|
+
ui.text(f"Ensemble reviewers: {', '.join(ensemble_reviewers)}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("catalog")
|
|
78
|
+
def catalog_command(
|
|
79
|
+
ctx: typer.Context,
|
|
80
|
+
json_out: JsonFlag = False,
|
|
81
|
+
plain: PlainFlag = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Show the advisory model capability catalog."""
|
|
84
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
85
|
+
root = find_project_root()
|
|
86
|
+
config = load_config(root)
|
|
87
|
+
reg = ModelRegistry(config.models.capabilities)
|
|
88
|
+
catalog = reg.catalog()
|
|
89
|
+
payload = [c.model_dump() for c in catalog]
|
|
90
|
+
ui.data({"catalog": payload})
|
|
91
|
+
|
|
92
|
+
if ui.mode is Output.RICH:
|
|
93
|
+
table = Table(show_header=True, header_style="bold")
|
|
94
|
+
table.add_column("Model")
|
|
95
|
+
table.add_column("Context")
|
|
96
|
+
table.add_column("Tools")
|
|
97
|
+
table.add_column("JSON")
|
|
98
|
+
table.add_column("Stream")
|
|
99
|
+
for c in catalog:
|
|
100
|
+
table.add_row(
|
|
101
|
+
c.model,
|
|
102
|
+
str(c.context_window or "—"),
|
|
103
|
+
"✓" if c.supports_tools else "—",
|
|
104
|
+
"✓" if c.supports_json else "—",
|
|
105
|
+
"✓" if c.supports_streaming else "—",
|
|
106
|
+
)
|
|
107
|
+
ui.console.print(table)
|
|
108
|
+
elif ui.mode is Output.PLAIN:
|
|
109
|
+
for c in catalog:
|
|
110
|
+
ui.text(f"{c.model}: ctx={c.context_window} tools={c.supports_tools} json={c.supports_json}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command("health")
|
|
114
|
+
def health_command(
|
|
115
|
+
ctx: typer.Context,
|
|
116
|
+
json_out: JsonFlag = False,
|
|
117
|
+
plain: PlainFlag = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Live ping of all configured providers."""
|
|
120
|
+
ui = ui_from(ctx, json_out=json_out, plain=plain)
|
|
121
|
+
root = find_project_root()
|
|
122
|
+
config = load_config(root)
|
|
123
|
+
health_results = collect_health(config)
|
|
124
|
+
|
|
125
|
+
all_ok = all(h.ok for h in health_results.values())
|
|
126
|
+
payload = {name: h.model_dump() for name, h in health_results.items()}
|
|
127
|
+
ui.data({"ok": all_ok, "providers": payload})
|
|
128
|
+
|
|
129
|
+
if ui.mode is Output.RICH:
|
|
130
|
+
table = Table(show_header=True, header_style="bold")
|
|
131
|
+
table.add_column("Provider")
|
|
132
|
+
table.add_column("Status")
|
|
133
|
+
table.add_column("Detail")
|
|
134
|
+
table.add_column("Latency")
|
|
135
|
+
for name, h in sorted(health_results.items()):
|
|
136
|
+
status = "[green]✓[/green]" if h.ok else "[red]✗[/red]"
|
|
137
|
+
lat = f"{h.latency_ms:.0f}ms" if h.latency_ms is not None else "—"
|
|
138
|
+
table.add_row(name, status, h.detail, lat)
|
|
139
|
+
ui.console.print(table)
|
|
140
|
+
elif ui.mode is Output.PLAIN:
|
|
141
|
+
for name, h in sorted(health_results.items()):
|
|
142
|
+
ui.text(f"{name}: {'OK' if h.ok else 'FAIL'} ({h.detail})")
|
|
143
|
+
|
|
144
|
+
raise typer.Exit(code=0 if all_ok else 1)
|