forgeoptimizer 1.0.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 +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -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 +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -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 +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -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 +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -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 +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -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 +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -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 +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -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 +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -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 +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -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 +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -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 +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Source code formatting dispatcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from forgecli.core.service import Service
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Formatter(Service):
|
|
12
|
+
"""Dispatch code formatting to a language-specific backend.
|
|
13
|
+
|
|
14
|
+
The default implementation is a no-op placeholder; concrete
|
|
15
|
+
formatters (ruff, black, prettier, gofmt) will be plugged in here
|
|
16
|
+
without changing call sites.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name = "builder.formatter"
|
|
20
|
+
|
|
21
|
+
def __init__(self, *, backend: str = "auto") -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self._backend = backend
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def backend(self) -> str:
|
|
27
|
+
return self._backend
|
|
28
|
+
|
|
29
|
+
def format(self, paths: Iterable[Path]) -> list[Path]:
|
|
30
|
+
"""Format ``paths``; placeholder returns the input untouched."""
|
|
31
|
+
return [Path(p) for p in paths]
|
forgecli/cli/__init__.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Build the :class:`AppContext` for a CLI invocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from forgecli.config.loader import ConfigLoader
|
|
10
|
+
from forgecli.core.context import AppContext
|
|
11
|
+
from forgecli.core.logging import configure_logging, get_logger
|
|
12
|
+
from forgecli.providers.anthropic import AnthropicProvider
|
|
13
|
+
from forgecli.providers.base import ProviderRegistry, default_registry
|
|
14
|
+
from forgecli.providers.google import GeminiProvider
|
|
15
|
+
from forgecli.providers.mock import MockProvider
|
|
16
|
+
from forgecli.providers.openai import OpenAIProvider
|
|
17
|
+
from forgecli.providers.router_state import load_state as load_router_state
|
|
18
|
+
from forgecli.utils.paths import ProjectPaths
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
21
|
+
from forgecli.core.container import Container
|
|
22
|
+
from forgecli.optimizer.summarizer import Summarizer
|
|
23
|
+
|
|
24
|
+
log = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bootstrap_context(
|
|
28
|
+
*,
|
|
29
|
+
config_path: Path | None = None,
|
|
30
|
+
cwd: Path | str | None = None,
|
|
31
|
+
extras: dict[str, Any] | None = None,
|
|
32
|
+
) -> AppContext:
|
|
33
|
+
"""Build an :class:`AppContext` with default services registered."""
|
|
34
|
+
if isinstance(cwd, str):
|
|
35
|
+
cwd = Path(cwd)
|
|
36
|
+
import click
|
|
37
|
+
try:
|
|
38
|
+
click_ctx = click.get_current_context(silent=True)
|
|
39
|
+
except Exception:
|
|
40
|
+
click_ctx = None
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
click_ctx is not None
|
|
44
|
+
and click_ctx.obj is not None
|
|
45
|
+
and isinstance(click_ctx.obj, AppContext)
|
|
46
|
+
):
|
|
47
|
+
return click_ctx.obj
|
|
48
|
+
|
|
49
|
+
paths = ProjectPaths.from_env(cwd=cwd).ensure()
|
|
50
|
+
|
|
51
|
+
verbose = (extras or {}).get("verbose", False)
|
|
52
|
+
level = "DEBUG" if verbose else "INFO"
|
|
53
|
+
configure_logging(level=level)
|
|
54
|
+
|
|
55
|
+
loader = ConfigLoader(config_path) if config_path else ConfigLoader()
|
|
56
|
+
|
|
57
|
+
from forgecli.providers.openai_compatible import (
|
|
58
|
+
CohereProvider,
|
|
59
|
+
FireworksProvider,
|
|
60
|
+
GroqProvider,
|
|
61
|
+
LMStudioProvider,
|
|
62
|
+
MiniMaxProvider,
|
|
63
|
+
MistralProvider,
|
|
64
|
+
NvidiaProvider,
|
|
65
|
+
OllamaProvider,
|
|
66
|
+
OpenRouterProvider,
|
|
67
|
+
TogetherProvider,
|
|
68
|
+
VllmProvider,
|
|
69
|
+
XaiProvider,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
provider_registry: ProviderRegistry = default_registry
|
|
73
|
+
for name, cls in (
|
|
74
|
+
("mock", MockProvider),
|
|
75
|
+
("openai", OpenAIProvider),
|
|
76
|
+
("anthropic", AnthropicProvider),
|
|
77
|
+
("google", GeminiProvider),
|
|
78
|
+
("openrouter", OpenRouterProvider),
|
|
79
|
+
("groq", GroqProvider),
|
|
80
|
+
("mistral", MistralProvider),
|
|
81
|
+
("minimax", MiniMaxProvider),
|
|
82
|
+
("xai", XaiProvider),
|
|
83
|
+
("together", TogetherProvider),
|
|
84
|
+
("fireworks", FireworksProvider),
|
|
85
|
+
("cohere", CohereProvider),
|
|
86
|
+
("nvidia", NvidiaProvider),
|
|
87
|
+
("ollama", OllamaProvider),
|
|
88
|
+
("lmstudio", LMStudioProvider),
|
|
89
|
+
("vllm", VllmProvider),
|
|
90
|
+
):
|
|
91
|
+
provider_registry.register(name, cls)
|
|
92
|
+
|
|
93
|
+
container = _build_container(provider_registry, paths)
|
|
94
|
+
context = AppContext(paths=paths, loader=loader, container=container)
|
|
95
|
+
merged = dict(extras or {})
|
|
96
|
+
merged.setdefault(
|
|
97
|
+
"optimizer.state",
|
|
98
|
+
_load_optimizer_state(paths),
|
|
99
|
+
)
|
|
100
|
+
merged.setdefault(
|
|
101
|
+
"router.state",
|
|
102
|
+
load_router_state(paths.data_dir / "router.json"),
|
|
103
|
+
)
|
|
104
|
+
context.extras.update(merged)
|
|
105
|
+
if click_ctx is not None:
|
|
106
|
+
click_ctx.obj = context
|
|
107
|
+
return context
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load_optimizer_state(paths: ProjectPaths):
|
|
111
|
+
"""Read the persisted optimizer state from ``data_dir/optimizer.json``."""
|
|
112
|
+
from forgecli.optimizer.ponytail import Intensity
|
|
113
|
+
from forgecli.optimizer.ponytail.state import OptimizerState
|
|
114
|
+
|
|
115
|
+
state_path = paths.data_dir / "optimizer.json"
|
|
116
|
+
if not state_path.exists():
|
|
117
|
+
return OptimizerState()
|
|
118
|
+
try:
|
|
119
|
+
payload = json.loads(state_path.read_text(encoding="utf-8"))
|
|
120
|
+
except (OSError, json.JSONDecodeError):
|
|
121
|
+
return OptimizerState()
|
|
122
|
+
|
|
123
|
+
state = OptimizerState()
|
|
124
|
+
intensity = payload.get("intensity")
|
|
125
|
+
if isinstance(intensity, str):
|
|
126
|
+
try:
|
|
127
|
+
state.intensity = Intensity.parse(intensity)
|
|
128
|
+
except ValueError:
|
|
129
|
+
state.intensity = Intensity.LITE
|
|
130
|
+
backend = payload.get("backend")
|
|
131
|
+
if isinstance(backend, str):
|
|
132
|
+
state.backend = backend
|
|
133
|
+
binary = payload.get("binary")
|
|
134
|
+
if isinstance(binary, str):
|
|
135
|
+
state.binary = binary
|
|
136
|
+
return state
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build_container(
|
|
140
|
+
provider_registry: ProviderRegistry,
|
|
141
|
+
paths: ProjectPaths,
|
|
142
|
+
):
|
|
143
|
+
"""Register default services in the DI container."""
|
|
144
|
+
from forgecli.core.container import Container
|
|
145
|
+
from forgecli.graph.backend_graphify import GraphifyRepositoryGraph
|
|
146
|
+
from forgecli.graph.graph import CodeGraph
|
|
147
|
+
from forgecli.graph.indexer import Indexer
|
|
148
|
+
from forgecli.graph.repository import RepositoryGraph
|
|
149
|
+
from forgecli.memory.store import MemoryStore
|
|
150
|
+
from forgecli.optimizer.chunker import Chunker
|
|
151
|
+
from forgecli.optimizer.optimizer import ContextOptimizer
|
|
152
|
+
from forgecli.optimizer.ponytail import PromptOptimizer
|
|
153
|
+
from forgecli.optimizer.ponytail.factory import build_optimizer
|
|
154
|
+
from forgecli.optimizer.ranker import Ranker
|
|
155
|
+
from forgecli.optimizer.summarizer import Summarizer
|
|
156
|
+
from forgecli.prompts.loader import PromptLoader
|
|
157
|
+
from forgecli.prompts.registry import PromptRegistry
|
|
158
|
+
from forgecli.prompts.renderer import PromptRenderer
|
|
159
|
+
from forgecli.providers.router import ModelRouter
|
|
160
|
+
from forgecli.templates.engine import TemplateEngine
|
|
161
|
+
from forgecli.templates.registry import TemplateRegistry
|
|
162
|
+
|
|
163
|
+
container = Container()
|
|
164
|
+
|
|
165
|
+
container.register_instance(ProviderRegistry, provider_registry)
|
|
166
|
+
container.register_instance(ProjectPaths, paths)
|
|
167
|
+
container.register_instance(CodeGraph, CodeGraph())
|
|
168
|
+
|
|
169
|
+
container.register(MemoryStore, lambda c: MemoryStore(paths.data_dir / "history.db"))
|
|
170
|
+
container.register(Summarizer, lambda c: _build_default_summarizer(c))
|
|
171
|
+
container.register(
|
|
172
|
+
ContextOptimizer,
|
|
173
|
+
lambda c: ContextOptimizer(
|
|
174
|
+
chunker=Chunker(),
|
|
175
|
+
ranker=Ranker(),
|
|
176
|
+
summarizer=c.resolve(Summarizer) if c.has(Summarizer) else None,
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
container.register(
|
|
180
|
+
Indexer,
|
|
181
|
+
lambda c: Indexer(
|
|
182
|
+
graph=c.resolve(CodeGraph),
|
|
183
|
+
root=paths.cwd,
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
container.register(
|
|
187
|
+
RepositoryGraph, # type: ignore[type-abstract]
|
|
188
|
+
lambda _c: GraphifyRepositoryGraph(root=paths.cwd),
|
|
189
|
+
)
|
|
190
|
+
container.register(
|
|
191
|
+
PromptOptimizer, # type: ignore[type-abstract]
|
|
192
|
+
lambda _c: build_optimizer(_load_optimizer_state(paths), ConfigLoader().load()),
|
|
193
|
+
)
|
|
194
|
+
container.register(
|
|
195
|
+
ModelRouter,
|
|
196
|
+
lambda c: ModelRouter(registry=c.resolve(ProviderRegistry)),
|
|
197
|
+
)
|
|
198
|
+
container.register(PromptRenderer, lambda _c: PromptRenderer())
|
|
199
|
+
container.register(
|
|
200
|
+
PromptLoader, lambda _c: PromptLoader(paths.prompts_dir)
|
|
201
|
+
)
|
|
202
|
+
container.register(PromptRegistry, lambda _c: PromptRegistry())
|
|
203
|
+
container.register(TemplateEngine, lambda c: TemplateEngine(renderer=c.resolve(PromptRenderer)))
|
|
204
|
+
container.register(TemplateRegistry, lambda _c: TemplateRegistry())
|
|
205
|
+
|
|
206
|
+
return container
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _build_default_summarizer(container: Container) -> Summarizer:
|
|
210
|
+
"""Wire the default summarizer against the mock provider.
|
|
211
|
+
|
|
212
|
+
Real provider wiring will live in a separate composition function and
|
|
213
|
+
will be selected via configuration.
|
|
214
|
+
"""
|
|
215
|
+
from forgecli.optimizer.summarizer import Summarizer
|
|
216
|
+
from forgecli.providers.mock import MockProvider, MockProviderConfig
|
|
217
|
+
|
|
218
|
+
provider = MockProvider(MockProviderConfig())
|
|
219
|
+
return Summarizer(provider=provider)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def resolve_provider_and_decision(
|
|
223
|
+
*,
|
|
224
|
+
live: bool,
|
|
225
|
+
cwd: Path | str,
|
|
226
|
+
) -> tuple[Any, Any]:
|
|
227
|
+
"""Unified provider and route decision resolver for all AI commands.
|
|
228
|
+
|
|
229
|
+
If live is True and no provider has credentials (which resolves to mock),
|
|
230
|
+
it displays the error requesting configuration and exits with code 1.
|
|
231
|
+
"""
|
|
232
|
+
import typer
|
|
233
|
+
|
|
234
|
+
from forgecli.providers.base import ProviderRegistry
|
|
235
|
+
from forgecli.providers.mock import MockProvider, MockProviderConfig
|
|
236
|
+
from forgecli.providers.router import (
|
|
237
|
+
ModelRouter,
|
|
238
|
+
RouteDecision,
|
|
239
|
+
SelectionMode,
|
|
240
|
+
_provider_has_credentials,
|
|
241
|
+
)
|
|
242
|
+
from forgecli.providers.router_state import load_state as load_router_state
|
|
243
|
+
|
|
244
|
+
if isinstance(cwd, str):
|
|
245
|
+
cwd = Path(cwd)
|
|
246
|
+
|
|
247
|
+
app_context = bootstrap_context(cwd=cwd)
|
|
248
|
+
state = load_router_state(app_context.paths.data_dir / "router.json")
|
|
249
|
+
router = app_context.container.resolve(ModelRouter)
|
|
250
|
+
decision = router.select(state.choice)
|
|
251
|
+
|
|
252
|
+
if not live:
|
|
253
|
+
provider: Any = MockProvider(MockProviderConfig())
|
|
254
|
+
decision = RouteDecision(
|
|
255
|
+
provider_name="mock",
|
|
256
|
+
model=decision.model,
|
|
257
|
+
mode=SelectionMode.FALLBACK,
|
|
258
|
+
)
|
|
259
|
+
return provider, decision
|
|
260
|
+
|
|
261
|
+
if decision.provider_name == "mock" or not _provider_has_credentials(decision.provider_name):
|
|
262
|
+
from forgecli.cli.ui import error
|
|
263
|
+
error(
|
|
264
|
+
"No AI provider configured.\n\n"
|
|
265
|
+
"Run:\n"
|
|
266
|
+
" forge auth login\n"
|
|
267
|
+
" forge provider use <provider>\n"
|
|
268
|
+
" forge model use <model>\n\n"
|
|
269
|
+
"Then retry."
|
|
270
|
+
)
|
|
271
|
+
raise typer.Exit(code=1)
|
|
272
|
+
|
|
273
|
+
registry = app_context.container.resolve(ProviderRegistry)
|
|
274
|
+
if not registry.has(decision.provider_name):
|
|
275
|
+
from forgecli.cli.ui import error
|
|
276
|
+
error(f"Unknown provider '{decision.provider_name}'.")
|
|
277
|
+
raise typer.Exit(code=1)
|
|
278
|
+
|
|
279
|
+
provider_cls = registry.get(decision.provider_name)
|
|
280
|
+
provider = provider_cls() # type: ignore[call-arg]
|
|
281
|
+
return provider, decision
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""``forge graph`` subcommand group: build.
|
|
2
|
+
|
|
3
|
+
These commands integrate the external Graphify CLI behind the
|
|
4
|
+
:mod:`forgecli.graph.repository` abstraction. When Graphify is not
|
|
5
|
+
installed the commands print an installation hint instead of failing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from forgecli.cli.ui import (
|
|
16
|
+
error,
|
|
17
|
+
get_console,
|
|
18
|
+
info,
|
|
19
|
+
success,
|
|
20
|
+
)
|
|
21
|
+
from forgecli.graph.backend_graphify import GraphifyRepositoryGraph
|
|
22
|
+
from forgecli.utils.fs import has_supported_source_files
|
|
23
|
+
from forgecli.utils.paths import to_privacy_path
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
help="Build the codebase graph.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
rich_markup_mode="rich",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_backend(path: Path) -> GraphifyRepositoryGraph:
|
|
33
|
+
return GraphifyRepositoryGraph(root=path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _require_graphify(backend: GraphifyRepositoryGraph) -> None:
|
|
37
|
+
if not await backend.is_available():
|
|
38
|
+
raise typer.Exit(code=1) from None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def setup_graphify_credentials(path: Path) -> str | None:
|
|
42
|
+
"""Read the active provider, load its API key, set env vars, and return provider name if configured."""
|
|
43
|
+
from forgecli.cli.bootstrap import bootstrap_context
|
|
44
|
+
from forgecli.core.credentials import get_api_key
|
|
45
|
+
from forgecli.providers.router import _PROVIDER_ENV_VARS, ModelRouter
|
|
46
|
+
from forgecli.providers.router_state import load_state as load_router_state
|
|
47
|
+
|
|
48
|
+
app_context = bootstrap_context(cwd=path)
|
|
49
|
+
state = load_router_state(app_context.paths.data_dir / "router.json")
|
|
50
|
+
router = app_context.container.resolve(ModelRouter)
|
|
51
|
+
decision = router.select(state.choice)
|
|
52
|
+
|
|
53
|
+
provider_name = decision.provider_name
|
|
54
|
+
if provider_name == "mock":
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# Try env vars first
|
|
58
|
+
env_vars = _PROVIDER_ENV_VARS.get(provider_name, ())
|
|
59
|
+
for ev in env_vars:
|
|
60
|
+
if os.environ.get(ev):
|
|
61
|
+
return provider_name
|
|
62
|
+
|
|
63
|
+
# Try keychain/credential manager
|
|
64
|
+
api_key = get_api_key(provider_name)
|
|
65
|
+
if api_key:
|
|
66
|
+
for ev in env_vars:
|
|
67
|
+
os.environ[ev] = api_key
|
|
68
|
+
return provider_name
|
|
69
|
+
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("build")
|
|
74
|
+
def build_cmd(
|
|
75
|
+
path: str = typer.Option(".", "--path", "-p", help="Project root to index."),
|
|
76
|
+
force: bool = typer.Option(
|
|
77
|
+
False,
|
|
78
|
+
"--force",
|
|
79
|
+
help="Overwrite graph.json even if the rebuild has fewer nodes.",
|
|
80
|
+
),
|
|
81
|
+
no_cluster: bool = typer.Option(
|
|
82
|
+
False, "--no-cluster", help="Skip Leiden clustering."
|
|
83
|
+
),
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Build (or rebuild) the codebase graph for ``path``."""
|
|
86
|
+
import asyncio
|
|
87
|
+
|
|
88
|
+
path_obj = Path(path).resolve()
|
|
89
|
+
|
|
90
|
+
# 1. Exit cleanly if no supported source files found
|
|
91
|
+
if not has_supported_source_files(path_obj):
|
|
92
|
+
get_console().print("No supported source files found. Nothing to build.")
|
|
93
|
+
raise typer.Exit(code=0)
|
|
94
|
+
|
|
95
|
+
# 2. Check credentials first. Never continue without a valid provider configuration.
|
|
96
|
+
active_provider = setup_graphify_credentials(path_obj)
|
|
97
|
+
if not active_provider:
|
|
98
|
+
error(
|
|
99
|
+
"No API key configured. Run 'forge auth login' or export "
|
|
100
|
+
"OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY before running 'forge graph build'."
|
|
101
|
+
)
|
|
102
|
+
raise typer.Exit(code=1)
|
|
103
|
+
|
|
104
|
+
backend = _build_backend(path_obj)
|
|
105
|
+
|
|
106
|
+
async def _run() -> None:
|
|
107
|
+
if not await backend.is_available():
|
|
108
|
+
get_console().print(await backend.install_hint())
|
|
109
|
+
raise typer.Exit(code=1)
|
|
110
|
+
|
|
111
|
+
info(f"Building graph for [accent]{to_privacy_path(backend.root)}[/accent] ...")
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
result = await backend.build(force=force, no_cluster=no_cluster)
|
|
115
|
+
snapshot = result.snapshot
|
|
116
|
+
get_console().print(
|
|
117
|
+
f" nodes: [bold]{len(snapshot.nodes)}[/bold]\n"
|
|
118
|
+
f" edges: [bold]{len(snapshot.edges)}[/bold]\n"
|
|
119
|
+
f" communities:[bold]{len(snapshot.communities)}[/bold]"
|
|
120
|
+
)
|
|
121
|
+
for label, value in result.artifacts.items():
|
|
122
|
+
get_console().print(f" [muted]{label}:[/muted] {to_privacy_path(value)}")
|
|
123
|
+
success("Graph built.")
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
error(f"Graph build failed: {exc}")
|
|
126
|
+
raise typer.Exit(code=1) from exc
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
asyncio.run(_run())
|
|
130
|
+
except typer.Exit:
|
|
131
|
+
raise
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
error(f"Graph build failed: {exc}")
|
|
134
|
+
raise typer.Exit(code=1) from exc
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Wrapper commands: forge claude | codex | cursor | opencode | commandcode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from forgecli.runtime.wrappers import launch_wrapper
|
|
10
|
+
|
|
11
|
+
_WRAPPER_SETTINGS = {
|
|
12
|
+
"allow_extra_args": True,
|
|
13
|
+
"allow_interspersed_args": True,
|
|
14
|
+
"ignore_unknown_options": True,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def claude_cmd(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
path: str = typer.Option(".", "--path", "-p", help="Project root."),
|
|
21
|
+
refresh: bool = typer.Option(False, "--refresh", help="Bypass cached Forge context."),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Launch Claude Code with Forge prompt + token optimization."""
|
|
24
|
+
launch_wrapper("claude", list(ctx.args), path=Path(path), force_prepare=refresh)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def codex_cmd(
|
|
28
|
+
ctx: typer.Context,
|
|
29
|
+
path: str = typer.Option(".", "--path", "-p", help="Project root."),
|
|
30
|
+
refresh: bool = typer.Option(False, "--refresh", help="Bypass cached Forge context."),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Launch Codex CLI with Forge prompt + token optimization."""
|
|
33
|
+
launch_wrapper("codex", list(ctx.args), path=Path(path), force_prepare=refresh)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cursor_cmd(
|
|
37
|
+
ctx: typer.Context,
|
|
38
|
+
path: str = typer.Option(".", "--path", "-p", help="Project root."),
|
|
39
|
+
refresh: bool = typer.Option(False, "--refresh", help="Bypass cached Forge context."),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Launch Cursor CLI with Forge prompt + token optimization."""
|
|
42
|
+
launch_wrapper("cursor", list(ctx.args), path=Path(path), force_prepare=refresh)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def opencode_cmd(
|
|
46
|
+
ctx: typer.Context,
|
|
47
|
+
path: str = typer.Option(".", "--path", "-p", help="Project root."),
|
|
48
|
+
refresh: bool = typer.Option(False, "--refresh", help="Bypass cached Forge context."),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Launch OpenCode CLI with Forge prompt + token optimization."""
|
|
51
|
+
launch_wrapper("opencode", list(ctx.args), path=Path(path), force_prepare=refresh)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def commandcode_cmd(
|
|
55
|
+
ctx: typer.Context,
|
|
56
|
+
path: str = typer.Option(".", "--path", "-p", help="Project root."),
|
|
57
|
+
refresh: bool = typer.Option(False, "--refresh", help="Bypass cached Forge context."),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Launch CommandCode CLI with Forge prompt + token optimization."""
|
|
60
|
+
launch_wrapper("commandcode", list(ctx.args), path=Path(path), force_prepare=refresh)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["_WRAPPER_SETTINGS", "claude_cmd", "codex_cmd", "commandcode_cmd", "cursor_cmd", "opencode_cmd"]
|
forgecli/cli/main.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
|
|
58
|
+
|
|
59
|
+
def _version_callback(value: bool) -> None:
|
|
60
|
+
if value:
|
|
61
|
+
get_console().print(f"{__app_name__} [muted]v{__version__}[/muted]")
|
|
62
|
+
raise typer.Exit()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.callback(invoke_without_command=True)
|
|
66
|
+
def main(
|
|
67
|
+
ctx: typer.Context,
|
|
68
|
+
config: Path | None = typer.Option(
|
|
69
|
+
None,
|
|
70
|
+
"--config",
|
|
71
|
+
"-c",
|
|
72
|
+
help="Path to a forgecli.toml file.",
|
|
73
|
+
),
|
|
74
|
+
verbose: bool = typer.Option(False, "--verbose", help="Enable debug logging."),
|
|
75
|
+
version: bool = typer.Option(
|
|
76
|
+
False,
|
|
77
|
+
"--version",
|
|
78
|
+
callback=_version_callback,
|
|
79
|
+
is_eager=True,
|
|
80
|
+
help="Show the version and exit.",
|
|
81
|
+
),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Forge global entry point."""
|
|
84
|
+
bootstrap_context(config_path=config, extras={"verbose": verbose})
|
|
85
|
+
if ctx.invoked_subcommand is not None:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
console = get_console()
|
|
89
|
+
console.print()
|
|
90
|
+
console.print(
|
|
91
|
+
f" [bold cyan]Forge[/bold cyan] [dim]v{__version__}[/dim] • "
|
|
92
|
+
"[bold white]AI Optimization Runtime[/bold white]"
|
|
93
|
+
)
|
|
94
|
+
console.print(
|
|
95
|
+
" [dim]Prompt + token optimization for AI coding CLIs.[/dim]\n"
|
|
96
|
+
)
|
|
97
|
+
console.print(
|
|
98
|
+
" [bold]Commands[/bold]\n"
|
|
99
|
+
" [cyan]forge claude[/cyan] Launch Claude Code with optimized context\n"
|
|
100
|
+
" [cyan]forge codex[/cyan] Launch Codex CLI with optimized context\n"
|
|
101
|
+
" [cyan]forge cursor[/cyan] Launch Cursor CLI with optimized context\n"
|
|
102
|
+
" [cyan]forge opencode[/cyan] Launch OpenCode CLI with optimized context\n"
|
|
103
|
+
" [cyan]forge commandcode[/cyan] Launch CommandCode CLI with optimized context\n"
|
|
104
|
+
" [cyan]forge graph build[/cyan] Build a full knowledge graph (optional)\n"
|
|
105
|
+
" [cyan]forge --help[/cyan] Show all options\n"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run() -> None:
|
|
110
|
+
"""Run the Typer app and translate ForgeCLIError to clean exit codes."""
|
|
111
|
+
try:
|
|
112
|
+
app()
|
|
113
|
+
except ForgeCLIError as exc:
|
|
114
|
+
error(str(exc))
|
|
115
|
+
raise SystemExit(2) from exc
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
_run()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__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)
|