forgex-cli 0.1.0__py3-none-any.whl

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