forgeoptimizer 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 (159) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +149 -0
  19. forgecli/cli/commands_wrappers.py +80 -0
  20. forgecli/cli/daemon.py +744 -0
  21. forgecli/cli/main.py +234 -0
  22. forgecli/cli/ui.py +56 -0
  23. forgecli/config/__init__.py +28 -0
  24. forgecli/config/loader.py +85 -0
  25. forgecli/config/settings.py +206 -0
  26. forgecli/config/writer.py +90 -0
  27. forgecli/core/__init__.py +35 -0
  28. forgecli/core/container.py +68 -0
  29. forgecli/core/context.py +54 -0
  30. forgecli/core/credentials.py +114 -0
  31. forgecli/core/errors.py +27 -0
  32. forgecli/core/events.py +67 -0
  33. forgecli/core/logging.py +47 -0
  34. forgecli/core/models.py +214 -0
  35. forgecli/core/plugins.py +54 -0
  36. forgecli/core/service.py +22 -0
  37. forgecli/docs/__init__.py +5 -0
  38. forgecli/docs/generator.py +115 -0
  39. forgecli/engine/__init__.py +105 -0
  40. forgecli/engine/context.py +163 -0
  41. forgecli/engine/defaults.py +89 -0
  42. forgecli/engine/events.py +185 -0
  43. forgecli/engine/execution.py +519 -0
  44. forgecli/engine/plugins.py +145 -0
  45. forgecli/engine/runner.py +158 -0
  46. forgecli/engine/stages/__init__.py +33 -0
  47. forgecli/engine/stages/caveman_optimizer.py +47 -0
  48. forgecli/engine/stages/context_optimizer.py +44 -0
  49. forgecli/engine/stages/execution_engine_stage.py +102 -0
  50. forgecli/engine/stages/git_engine.py +30 -0
  51. forgecli/engine/stages/intent_analyzer.py +38 -0
  52. forgecli/engine/stages/model_router.py +65 -0
  53. forgecli/engine/stages/planning_engine.py +45 -0
  54. forgecli/engine/stages/repository_analyzer.py +54 -0
  55. forgecli/engine/stages/validation_engine.py +87 -0
  56. forgecli/git/__init__.py +9 -0
  57. forgecli/git/repo.py +55 -0
  58. forgecli/git/service.py +45 -0
  59. forgecli/graph/__init__.py +56 -0
  60. forgecli/graph/backend_graphify.py +448 -0
  61. forgecli/graph/edge.py +19 -0
  62. forgecli/graph/graph.py +85 -0
  63. forgecli/graph/graphify.py +405 -0
  64. forgecli/graph/indexer.py +82 -0
  65. forgecli/graph/node.py +47 -0
  66. forgecli/graph/repository.py +164 -0
  67. forgecli/memory/__init__.py +12 -0
  68. forgecli/memory/cache.py +61 -0
  69. forgecli/memory/history.py +136 -0
  70. forgecli/memory/store.py +85 -0
  71. forgecli/optimizer/__init__.py +13 -0
  72. forgecli/optimizer/caveman/__init__.py +169 -0
  73. forgecli/optimizer/caveman/cli.py +62 -0
  74. forgecli/optimizer/caveman/decorator.py +67 -0
  75. forgecli/optimizer/caveman/factory.py +51 -0
  76. forgecli/optimizer/caveman/ruleset.py +156 -0
  77. forgecli/optimizer/caveman/state.py +50 -0
  78. forgecli/optimizer/chunker.py +85 -0
  79. forgecli/optimizer/optimizer.py +81 -0
  80. forgecli/optimizer/ponytail/__init__.py +181 -0
  81. forgecli/optimizer/ponytail/cli.py +159 -0
  82. forgecli/optimizer/ponytail/decorator.py +70 -0
  83. forgecli/optimizer/ponytail/factory.py +51 -0
  84. forgecli/optimizer/ponytail/ruleset.py +168 -0
  85. forgecli/optimizer/ponytail/state.py +51 -0
  86. forgecli/optimizer/ranker.py +37 -0
  87. forgecli/optimizer/summarizer.py +44 -0
  88. forgecli/orchestrator/__init__.py +706 -0
  89. forgecli/planner/__init__.py +56 -0
  90. forgecli/planner/agent.py +61 -0
  91. forgecli/planner/plan.py +65 -0
  92. forgecli/planner/planner.py +17 -0
  93. forgecli/planner/render.py +267 -0
  94. forgecli/planner/serialize.py +104 -0
  95. forgecli/planner/software.py +832 -0
  96. forgecli/platform/__init__.py +91 -0
  97. forgecli/platform/core.py +201 -0
  98. forgecli/platform/deps.py +361 -0
  99. forgecli/platform/paths.py +234 -0
  100. forgecli/platform/shell.py +176 -0
  101. forgecli/platform/update.py +253 -0
  102. forgecli/plugins/__init__.py +249 -0
  103. forgecli/prompts/__init__.py +11 -0
  104. forgecli/prompts/loader.py +28 -0
  105. forgecli/prompts/registry.py +32 -0
  106. forgecli/prompts/renderer.py +23 -0
  107. forgecli/providers/__init__.py +39 -0
  108. forgecli/providers/anthropic.py +207 -0
  109. forgecli/providers/base.py +204 -0
  110. forgecli/providers/builtin.py +26 -0
  111. forgecli/providers/conversation.py +74 -0
  112. forgecli/providers/google.py +290 -0
  113. forgecli/providers/http_base.py +207 -0
  114. forgecli/providers/mock.py +111 -0
  115. forgecli/providers/openai.py +206 -0
  116. forgecli/providers/openai_compatible.py +787 -0
  117. forgecli/providers/router.py +340 -0
  118. forgecli/providers/router_state.py +89 -0
  119. forgecli/review/__init__.py +45 -0
  120. forgecli/review/analyzer.py +124 -0
  121. forgecli/review/analyzers/__init__.py +1 -0
  122. forgecli/review/analyzers/architecture.py +255 -0
  123. forgecli/review/analyzers/complexity.py +162 -0
  124. forgecli/review/analyzers/dead_code.py +255 -0
  125. forgecli/review/analyzers/duplicates.py +161 -0
  126. forgecli/review/analyzers/performance.py +161 -0
  127. forgecli/review/analyzers/security.py +244 -0
  128. forgecli/review/finding.py +68 -0
  129. forgecli/review/report.py +321 -0
  130. forgecli/review/repository.py +130 -0
  131. forgecli/review/suggestions.py +98 -0
  132. forgecli/runtime/__init__.py +6 -0
  133. forgecli/runtime/cache_store.py +75 -0
  134. forgecli/runtime/mcp_config.py +118 -0
  135. forgecli/runtime/prepare.py +203 -0
  136. forgecli/runtime/wrappers.py +150 -0
  137. forgecli/sdk/__init__.py +132 -0
  138. forgecli/sdk/events.py +206 -0
  139. forgecli/sdk/interfaces.py +282 -0
  140. forgecli/sdk/loader.py +239 -0
  141. forgecli/sdk/manager.py +678 -0
  142. forgecli/sdk/manifest.py +395 -0
  143. forgecli/sdk/sandbox.py +247 -0
  144. forgecli/sdk/version.py +313 -0
  145. forgecli/templates/__init__.py +9 -0
  146. forgecli/templates/engine.py +37 -0
  147. forgecli/templates/registry.py +32 -0
  148. forgecli/utils/__init__.py +19 -0
  149. forgecli/utils/fs.py +121 -0
  150. forgecli/utils/ids.py +11 -0
  151. forgecli/utils/io.py +27 -0
  152. forgecli/utils/paths.py +78 -0
  153. forgecli/utils/stats.py +179 -0
  154. forgecli/utils/timing.py +25 -0
  155. forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
  156. forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
  157. forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
  158. forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
  159. forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
forgecli/cli/main.py ADDED
@@ -0,0 +1,234 @@
1
+ """Top-level Typer application for Forge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import signal
7
+ import warnings
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from forgecli import __app_name__, __version__
13
+ from forgecli.cli import commands_graph, commands_wrappers
14
+ from forgecli.cli.bootstrap import bootstrap_context
15
+ from forgecli.cli.ui import error, get_console
16
+ from forgecli.core.errors import ForgeCLIError
17
+
18
+ warnings.filterwarnings("ignore")
19
+
20
+ with contextlib.suppress(AttributeError):
21
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
22
+
23
+ app = typer.Typer(
24
+ name="forge",
25
+ help="Forge — AI optimization runtime for Claude Code, Codex, Cursor, OpenCode, and CommandCode CLI.",
26
+ no_args_is_help=False,
27
+ add_completion=False,
28
+ rich_markup_mode="rich",
29
+ )
30
+
31
+ app.add_typer(commands_graph.app, name="graph")
32
+ app.command(
33
+ "claude",
34
+ help="Launch Claude Code with Forge prompt + token optimization.",
35
+ context_settings=commands_wrappers._WRAPPER_SETTINGS,
36
+ )(commands_wrappers.claude_cmd)
37
+ app.command(
38
+ "codex",
39
+ help="Launch Codex CLI with Forge prompt + token optimization.",
40
+ context_settings=commands_wrappers._WRAPPER_SETTINGS,
41
+ )(commands_wrappers.codex_cmd)
42
+ app.command(
43
+ "cursor",
44
+ help="Launch Cursor CLI with Forge prompt + token optimization.",
45
+ context_settings=commands_wrappers._WRAPPER_SETTINGS,
46
+ )(commands_wrappers.cursor_cmd)
47
+ app.command(
48
+ "opencode",
49
+ help="Launch OpenCode CLI with Forge prompt + token optimization.",
50
+ context_settings=commands_wrappers._WRAPPER_SETTINGS,
51
+ )(commands_wrappers.opencode_cmd)
52
+ app.command(
53
+ "commandcode",
54
+ help="Launch CommandCode CLI with Forge prompt + token optimization.",
55
+ context_settings=commands_wrappers._WRAPPER_SETTINGS,
56
+ )(commands_wrappers.commandcode_cmd)
57
+ app.command(
58
+ "antigravity",
59
+ help="Launch Antigravity CLI with Forge prompt + token optimization.",
60
+ context_settings=commands_wrappers._WRAPPER_SETTINGS,
61
+ )(commands_wrappers.antigravity_cmd)
62
+
63
+
64
+ @app.command("start", help="Start the long-running Forge Context Runtime daemon.")
65
+ def start_cmd(
66
+ port: int = typer.Option(16868, "--port", "-p", help="Port to run the daemon HTTP server on."),
67
+ ) -> None:
68
+ """Start the long-running Forge Context Runtime daemon."""
69
+ import uvicorn
70
+
71
+ from forgecli.cli.daemon import get_watcher_for_path, is_daemon_running
72
+ from forgecli.cli.ui import info, success
73
+
74
+ if is_daemon_running():
75
+ info("Forge Runtime daemon is already running on http://127.0.0.1:16868")
76
+ raise typer.Exit(code=0)
77
+
78
+ success("Starting Forge Runtime daemon on http://127.0.0.1:16868...")
79
+ get_watcher_for_path(Path.cwd())
80
+
81
+ from forgecli.cli.daemon import app as daemon_app
82
+
83
+ uvicorn.run(daemon_app, host="127.0.0.1", port=port, log_level="warning")
84
+
85
+
86
+ @app.command("mcp", help="Start the Forge MCP Server over stdio.")
87
+ def mcp_cmd() -> None:
88
+ """Start the Forge MCP Server over stdio."""
89
+ from forgecli.cli.daemon import run_mcp_stdio
90
+
91
+ run_mcp_stdio()
92
+
93
+
94
+ @app.command(
95
+ "stats",
96
+ help="Display the prompt and token optimization statistics from the latest wrapper runs.",
97
+ )
98
+ def stats_cmd() -> None:
99
+ """Display the prompt and token optimization statistics from the latest wrapper runs."""
100
+ from forgecli.cli.ui import get_console
101
+ from forgecli.utils.stats import get_stats_history
102
+
103
+ history = get_stats_history()
104
+ if not history:
105
+ get_console().print("No optimization statistics available yet.")
106
+ return
107
+
108
+ console = get_console()
109
+ console.print()
110
+
111
+ for idx, run in enumerate(history):
112
+ run_title = "[bold cyan]Forge Optimization Report"
113
+ if len(history) > 1:
114
+ run_title += f" (Run #{idx + 1}"
115
+ if idx == 0:
116
+ run_title += " - Latest"
117
+ run_title += ")"
118
+ run_title += "[/bold cyan]"
119
+
120
+ console.print(run_title)
121
+
122
+ # Capitalize the CLI used (e.g. claude -> Claude, antigravity -> Antigravity)
123
+ cli_raw = run.get("cli_used", "N/A")
124
+ cli_display = cli_raw.capitalize() if isinstance(cli_raw, str) else str(cli_raw)
125
+
126
+ console.print(f"Timestamp : [muted]{run.get('timestamp', 'N/A')}[/muted]")
127
+ console.print(f"Repository : [white]{run.get('repo_name', 'N/A')}[/white]")
128
+ console.print(f"AI CLI : [white]{cli_display}[/white]")
129
+ console.print()
130
+
131
+ # Tokens section
132
+ console.print("[bold]Estimated Tokens[/bold]")
133
+ console.print("[dim]────────────────────────[/dim]")
134
+
135
+ red_abs = run.get("reduction_tokens", 0)
136
+ red_pct = run.get("reduction_pct", 0.0)
137
+
138
+ if red_abs <= 0:
139
+ console.print("[yellow]No meaningful token reduction for this repository.[/yellow]")
140
+ else:
141
+ console.print(
142
+ f"Original : [white]{run.get('original_tokens', 0):,}[/white]"
143
+ )
144
+ console.print(
145
+ f"Optimized : [white]{run.get('optimized_tokens', 0):,}[/white]"
146
+ )
147
+ console.print(f"Saved : [green]{red_abs:,}[/green]")
148
+ console.print(f"Reduction : [green]{red_pct:.1f}%[/green]")
149
+ console.print()
150
+
151
+ # Optimization section
152
+ console.print("[bold]Optimization[/bold]")
153
+ console.print("[dim]────────────────────────[/dim]")
154
+ console.print(f"Files Scanned : [white]{run.get('files_scanned', 0)}[/white]")
155
+ console.print(f"Relevant Files : [white]{run.get('files_count', 0)}[/white]")
156
+ console.print(f"Excluded Files : [white]{run.get('excluded_files', 0)}[/white]")
157
+ console.print(f"Preparation Time : [white]{run.get('prep_time', 0.0):.2f} s[/white]")
158
+ console.print(
159
+ f"Prompt Optimization : [white]{run.get('prompt_opt_status', 'N/A')}[/white]"
160
+ )
161
+ console.print(f"Token Optimization : [white]{run.get('token_opt_status', 'N/A')}[/white]")
162
+
163
+ if idx < len(history) - 1:
164
+ console.print()
165
+ console.print("[dim]==================================================[/dim]")
166
+ console.print()
167
+
168
+
169
+ def _version_callback(value: bool) -> None:
170
+ if value:
171
+ get_console().print(f"{__app_name__} [muted]v{__version__}[/muted]")
172
+ raise typer.Exit()
173
+
174
+
175
+ @app.callback(invoke_without_command=True)
176
+ def main(
177
+ ctx: typer.Context,
178
+ config: Path | None = typer.Option(
179
+ None,
180
+ "--config",
181
+ "-c",
182
+ help="Path to a forgecli.toml file.",
183
+ ),
184
+ verbose: bool = typer.Option(False, "--verbose", help="Enable debug logging."),
185
+ version: bool = typer.Option(
186
+ False,
187
+ "--version",
188
+ callback=_version_callback,
189
+ is_eager=True,
190
+ help="Show the version and exit.",
191
+ ),
192
+ ) -> None:
193
+ """Forge global entry point."""
194
+ bootstrap_context(config_path=config, extras={"verbose": verbose})
195
+ if ctx.invoked_subcommand is not None:
196
+ return
197
+
198
+ console = get_console()
199
+ console.print()
200
+ console.print(
201
+ f" [bold cyan]Forge[/bold cyan] [dim]v{__version__}[/dim] • "
202
+ "[bold white]AI Optimization Runtime[/bold white]"
203
+ )
204
+ console.print(" [dim]Prompt + token optimization for AI coding CLIs.[/dim]\n")
205
+ console.print(
206
+ " [bold]Commands[/bold]\n"
207
+ " [cyan]forge claude[/cyan] Launch Claude Code with optimized context\n"
208
+ " [cyan]forge codex[/cyan] Launch Codex CLI with optimized context\n"
209
+ " [cyan]forge cursor[/cyan] Launch Cursor CLI with optimized context\n"
210
+ " [cyan]forge opencode[/cyan] Launch OpenCode CLI with optimized context\n"
211
+ " [cyan]forge commandcode[/cyan] Launch CommandCode CLI with optimized context\n"
212
+ " [cyan]forge antigravity[/cyan] Launch Antigravity CLI with optimized context\n"
213
+ " [cyan]forge start[/cyan] Start the background context optimization daemon\n"
214
+ " [cyan]forge mcp[/cyan] Start the stdio Model Context Protocol (MCP) server\n"
215
+ " [cyan]forge stats[/cyan] Display optimization statistics\n"
216
+ " [cyan]forge graph build[/cyan] Build a full knowledge graph (optional)\n"
217
+ " [cyan]forge --help[/cyan] Show all options\n"
218
+ )
219
+
220
+
221
+ def _run() -> None:
222
+ """Run the Typer app and translate ForgeCLIError to clean exit codes."""
223
+ try:
224
+ app()
225
+ except ForgeCLIError as exc:
226
+ error(str(exc))
227
+ raise SystemExit(2) from exc
228
+
229
+
230
+ if __name__ == "__main__":
231
+ _run()
232
+
233
+
234
+ __all__ = ["app", "main"]
forgecli/cli/ui.py ADDED
@@ -0,0 +1,56 @@
1
+ """Rich-based terminal UI helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+ from rich.theme import Theme
8
+
9
+ _THEME = Theme(
10
+ {
11
+ "info": "cyan",
12
+ "warn": "yellow",
13
+ "error": "bold red",
14
+ "success": "bold green",
15
+ "muted": "dim",
16
+ "accent": "magenta",
17
+ "accent.bold": "bold magenta",
18
+ }
19
+ )
20
+
21
+
22
+ def get_console() -> Console:
23
+ """Return a shared Rich :class:`Console` with the ForgeCLI theme."""
24
+ return Console(theme=_THEME, soft_wrap=False)
25
+
26
+
27
+ def info(message: str) -> None:
28
+ get_console().print(f"[info]ℹ[/info] {message}")
29
+
30
+
31
+ def warn(message: str) -> None:
32
+ get_console().print(f"[warn]![/warn] {message}")
33
+
34
+
35
+ def error(message: str) -> None:
36
+ get_console().print(f"[error]✗[/error] {message}")
37
+
38
+
39
+ def success(message: str) -> None:
40
+ get_console().print(f"[success]✓[/success] {message}")
41
+
42
+
43
+ def warn_deprecated(command: str) -> None:
44
+ get_console().print(
45
+ f"[yellow]Deprecated[/yellow] — [bold]{command}[/bold] may be removed in a future release."
46
+ )
47
+
48
+
49
+ def table(headers: list[str], rows: list[list[str]], *, title: str | None = None) -> None:
50
+ """Print a small :class:`rich.table.Table` to the shared console."""
51
+ tbl = Table(title=title, header_style="accent.bold")
52
+ for header in headers:
53
+ tbl.add_column(header)
54
+ for row in rows:
55
+ tbl.add_row(*row)
56
+ get_console().print(tbl)
@@ -0,0 +1,28 @@
1
+ """Configuration loading and validation.
2
+
3
+ Public names are exposed lazily to avoid import cycles with
4
+ :mod:`forgecli.core.context`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ __all__ = ["ConfigLoader", "ForgeSettings"]
12
+
13
+
14
+ if TYPE_CHECKING: # pragma: no cover - typing only
15
+ from forgecli.config.loader import ConfigLoader
16
+ from forgecli.config.settings import ForgeSettings
17
+
18
+
19
+ def __getattr__(name: str) -> Any:
20
+ if name == "ConfigLoader":
21
+ from forgecli.config.loader import ConfigLoader
22
+
23
+ return ConfigLoader
24
+ if name == "ForgeSettings":
25
+ from forgecli.config.settings import ForgeSettings
26
+
27
+ return ForgeSettings
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,85 @@
1
+ """Load configuration from TOML files and merge with environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ try:
9
+ import tomllib # py3.11+
10
+ except ModuleNotFoundError: # pragma: no cover - fallback for older runtimes
11
+ import tomli as tomllib # type: ignore[no-redef,import-not-found]
12
+
13
+ from forgecli.config.settings import ForgeSettings
14
+ from forgecli.core.errors import ConfigError
15
+
16
+
17
+ class ConfigLoader:
18
+ """Resolve and load configuration from disk and the environment."""
19
+
20
+ DEFAULT_CANDIDATES: tuple[Path, ...] = (
21
+ Path("./forgecli.toml"),
22
+ Path("./.forgecli.toml"),
23
+ Path("./pyproject.toml"),
24
+ )
25
+
26
+ def __init__(self, *user_paths: Path) -> None:
27
+ self._user_paths: tuple[Path, ...] = user_paths
28
+ self._cached: ForgeSettings | None = None
29
+
30
+ def load(self, *, force: bool = False) -> ForgeSettings:
31
+ """Load the configuration, caching the result for subsequent calls."""
32
+ if self._cached is not None and not force:
33
+ return self._cached
34
+
35
+ data: dict[str, Any] = {}
36
+ for path in self._candidate_paths():
37
+ if not path.exists():
38
+ continue
39
+ data = self._merge(data, self._read_toml(path))
40
+
41
+ try:
42
+ settings = ForgeSettings(**data)
43
+ except Exception as exc: # pragma: no cover - delegated validation
44
+ raise ConfigError(f"Invalid configuration: {exc}") from exc
45
+
46
+ self._cached = settings
47
+ return settings
48
+
49
+ def invalidate(self) -> None:
50
+ """Clear the in-memory cache so the next ``load`` re-reads files."""
51
+ self._cached = None
52
+
53
+ def _candidate_paths(self) -> list[Path]:
54
+ if self._user_paths:
55
+ return list(self._user_paths)
56
+ return list(self.DEFAULT_CANDIDATES)
57
+
58
+ @staticmethod
59
+ def _read_toml(path: Path) -> dict[str, Any]:
60
+ if path.name == "pyproject.toml":
61
+ try:
62
+ payload = tomllib.loads(path.read_text(encoding="utf-8"))
63
+ except OSError as exc:
64
+ raise ConfigError(f"Unable to read {path}: {exc}") from exc
65
+ tool = payload.get("tool", {}).get("forgecli")
66
+ return dict(tool) if isinstance(tool, dict) else {}
67
+
68
+ try:
69
+ return tomllib.loads(path.read_text(encoding="utf-8"))
70
+ except OSError as exc:
71
+ raise ConfigError(f"Unable to read {path}: {exc}") from exc
72
+ except tomllib.TOMLDecodeError as exc:
73
+ raise ConfigError(f"Malformed TOML in {path}: {exc}") from exc
74
+
75
+ @staticmethod
76
+ def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
77
+ """Recursively merge two dictionaries, ``override`` winning on conflict."""
78
+ result: dict[str, Any] = dict(base)
79
+ for key, value in override.items():
80
+ existing = result.get(key)
81
+ if isinstance(existing, dict) and isinstance(value, dict):
82
+ result[key] = ConfigLoader._merge(existing, value)
83
+ else:
84
+ result[key] = value
85
+ return result
@@ -0,0 +1,206 @@
1
+ """Pydantic models describing the typed ForgeCLI configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+
12
+ class AppSection(BaseModel):
13
+ """Top-level application metadata."""
14
+
15
+ model_config = ConfigDict(extra="allow")
16
+
17
+ name: str = "forgecli"
18
+ version: str = "0.1.0"
19
+ log_level: str = "INFO"
20
+ data_dir: Path = Field(default_factory=lambda: Path("~/.local/share/forgecli"))
21
+ plugins_dir: Path = Field(default_factory=lambda: Path("~/.config/forgecli/plugins"))
22
+
23
+
24
+ class ProviderSection(BaseModel):
25
+ """Configuration for a single AI provider."""
26
+
27
+ model_config = ConfigDict(extra="allow")
28
+
29
+ api_key_env: str | None = None
30
+ base_url: str | None = None
31
+ base_url_env: str | None = None
32
+ default_model: str | None = None
33
+ max_tokens: int = 8192
34
+ temperature: float = 0.2
35
+ enabled: bool = True
36
+
37
+
38
+ class ProvidersConfig(BaseModel):
39
+ """Provider registry configuration."""
40
+
41
+ model_config = ConfigDict(extra="allow")
42
+
43
+ default: str | None = None
44
+ default_model: str | None = None
45
+ allowed: list[str] = Field(default_factory=list)
46
+ providers: dict[str, ProviderSection] = Field(default_factory=dict)
47
+
48
+
49
+ class GitSection(BaseModel):
50
+ """Git automation settings."""
51
+
52
+ model_config = ConfigDict(extra="allow")
53
+
54
+ default_branch: str = "main"
55
+ user_name: str = ""
56
+ user_email: str = ""
57
+ auto_fetch: bool = True
58
+
59
+
60
+ class OptimizerSection(BaseModel):
61
+ """Context optimizer settings."""
62
+
63
+ model_config = ConfigDict(extra="allow")
64
+
65
+ max_context_tokens: int = 200_000
66
+ chunk_size: int = 4000
67
+ chunk_overlap: int = 200
68
+ strategy: str = "auto" # auto | sliding | map-reduce | graph
69
+
70
+
71
+ class PromptOptimizerSection(BaseModel):
72
+ """Ponytail prompt-optimizer settings."""
73
+
74
+ model_config = ConfigDict(extra="allow")
75
+
76
+ enabled: bool = True
77
+ intensity: str = "lite" # off | lite | full | ultra
78
+ backend: str = "ruleset" # ruleset | cli | auto
79
+ binary: str = "ponytail" # only used when backend = "cli"
80
+ timeout_seconds: float = 30.0
81
+
82
+
83
+ class CavemanSection(BaseModel):
84
+ """Caveman token-optimizer settings."""
85
+
86
+ model_config = ConfigDict(extra="allow")
87
+
88
+ enabled: bool = False
89
+ intensity: str = "off" # off | lite | full | ultra | wenyan
90
+ backend: str = "ruleset" # ruleset | cli | auto
91
+ binary: str = "caveman" # only used when backend = "cli"
92
+ timeout_seconds: float = 30.0
93
+
94
+
95
+ class GraphSection(BaseModel):
96
+ """Repository graph settings."""
97
+
98
+ model_config = ConfigDict(extra="allow")
99
+
100
+ enabled: bool = True
101
+ languages: list[str] = Field(default_factory=lambda: ["python", "typescript"])
102
+ max_depth: int = 8
103
+ ignore_patterns: list[str] = Field(default_factory=list)
104
+
105
+
106
+ class MemorySection(BaseModel):
107
+ """Local memory store settings."""
108
+
109
+ model_config = ConfigDict(extra="allow")
110
+
111
+ db_path: Path = Field(default_factory=lambda: Path("~/.local/share/forgecli/history.db"))
112
+ history_limit: int = 1000
113
+ embedding_provider: str = "mock"
114
+
115
+
116
+ class ReviewSection(BaseModel):
117
+ """Code review settings."""
118
+
119
+ model_config = ConfigDict(extra="allow")
120
+
121
+ enabled: bool = True
122
+ auto_review: bool = False
123
+ max_diff_lines: int = 2000
124
+ lint_first: bool = True
125
+
126
+
127
+ class BuilderSection(BaseModel):
128
+ """Builder/pipeline settings."""
129
+
130
+ model_config = ConfigDict(extra="allow")
131
+
132
+ auto_format: bool = True
133
+ formatter: str = "auto"
134
+ test_command: str = ""
135
+ max_iterations: int = 3
136
+
137
+
138
+ class PlannerSection(BaseModel):
139
+ """Planner/agent settings."""
140
+
141
+ model_config = ConfigDict(extra="allow")
142
+
143
+ default_strategy: str = "react"
144
+ max_steps: int = 20
145
+ allow_shell: bool = False
146
+
147
+
148
+ class PromptsSection(BaseModel):
149
+ """Prompt template settings."""
150
+
151
+ model_config = ConfigDict(extra="allow")
152
+
153
+ dir: Path = Field(default_factory=lambda: Path("~/.config/forgecli/prompts"))
154
+ render_backend: str = "jinja"
155
+
156
+
157
+ class TelemetrySection(BaseModel):
158
+ """Telemetry and analytics settings."""
159
+
160
+ model_config = ConfigDict(extra="allow")
161
+
162
+ enabled: bool = False
163
+ endpoint: str = ""
164
+
165
+
166
+ class ForgeSettings(BaseSettings):
167
+ """Root settings model combining file config and environment variables."""
168
+
169
+ model_config = SettingsConfigDict(
170
+ env_prefix="FORGECLI_",
171
+ env_file=".env",
172
+ env_file_encoding="utf-8",
173
+ env_nested_delimiter="__",
174
+ extra="ignore",
175
+ case_sensitive=False,
176
+ )
177
+
178
+ app: AppSection = Field(default_factory=AppSection)
179
+ providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
180
+ git: GitSection = Field(default_factory=GitSection)
181
+ optimizer: OptimizerSection = Field(default_factory=OptimizerSection)
182
+ prompt_optimizer: PromptOptimizerSection = Field(default_factory=PromptOptimizerSection)
183
+ caveman: CavemanSection = Field(default_factory=CavemanSection)
184
+ graph: GraphSection = Field(default_factory=GraphSection)
185
+ memory: MemorySection = Field(default_factory=MemorySection)
186
+ review: ReviewSection = Field(default_factory=ReviewSection)
187
+ builder: BuilderSection = Field(default_factory=BuilderSection)
188
+ planner: PlannerSection = Field(default_factory=PlannerSection)
189
+ prompts: PromptsSection = Field(default_factory=PromptsSection)
190
+ telemetry: TelemetrySection = Field(default_factory=TelemetrySection)
191
+ extra: dict[str, Any] = Field(default_factory=dict)
192
+
193
+ @field_validator(
194
+ "app",
195
+ "providers",
196
+ "git",
197
+ "optimizer",
198
+ "prompt_optimizer",
199
+ "caveman",
200
+ "graph",
201
+ mode="before",
202
+ )
203
+ @classmethod
204
+ def _coerce_none(cls, value: Any) -> Any:
205
+ """Allow empty sections to be passed as ``None``."""
206
+ return value or {}