forgex-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)