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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +149 -0
- forgecli/cli/commands_wrappers.py +80 -0
- forgecli/cli/daemon.py +744 -0
- forgecli/cli/main.py +234 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +206 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +214 -0
- forgecli/core/plugins.py +54 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +115 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +163 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +519 -0
- forgecli/engine/plugins.py +145 -0
- forgecli/engine/runner.py +158 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +102 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +65 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +87 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +448 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +405 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +136 -0
- forgecli/memory/store.py +85 -0
- forgecli/optimizer/__init__.py +13 -0
- forgecli/optimizer/caveman/__init__.py +169 -0
- forgecli/optimizer/caveman/cli.py +62 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +50 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +181 -0
- forgecli/optimizer/ponytail/cli.py +159 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +51 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +706 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +267 -0
- forgecli/planner/serialize.py +104 -0
- forgecli/planner/software.py +832 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +361 -0
- forgecli/platform/paths.py +234 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +253 -0
- forgecli/plugins/__init__.py +249 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +207 -0
- forgecli/providers/base.py +204 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +74 -0
- forgecli/providers/google.py +290 -0
- forgecli/providers/http_base.py +207 -0
- forgecli/providers/mock.py +111 -0
- forgecli/providers/openai.py +206 -0
- forgecli/providers/openai_compatible.py +787 -0
- forgecli/providers/router.py +340 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +255 -0
- forgecli/review/analyzers/complexity.py +162 -0
- forgecli/review/analyzers/dead_code.py +255 -0
- forgecli/review/analyzers/duplicates.py +161 -0
- forgecli/review/analyzers/performance.py +161 -0
- forgecli/review/analyzers/security.py +244 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +321 -0
- forgecli/review/repository.py +130 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +75 -0
- forgecli/runtime/mcp_config.py +118 -0
- forgecli/runtime/prepare.py +203 -0
- forgecli/runtime/wrappers.py +150 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +239 -0
- forgecli/sdk/manager.py +678 -0
- forgecli/sdk/manifest.py +395 -0
- forgecli/sdk/sandbox.py +247 -0
- forgecli/sdk/version.py +313 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +121 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +78 -0
- forgecli/utils/stats.py +179 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
- forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
- forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
- forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
- 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 {}
|