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.
Files changed (156) 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 +137 -0
  19. forgecli/cli/commands_wrappers.py +63 -0
  20. forgecli/cli/main.py +122 -0
  21. forgecli/cli/ui.py +56 -0
  22. forgecli/config/__init__.py +28 -0
  23. forgecli/config/loader.py +85 -0
  24. forgecli/config/settings.py +204 -0
  25. forgecli/config/writer.py +90 -0
  26. forgecli/core/__init__.py +35 -0
  27. forgecli/core/container.py +68 -0
  28. forgecli/core/context.py +54 -0
  29. forgecli/core/credentials.py +114 -0
  30. forgecli/core/errors.py +27 -0
  31. forgecli/core/events.py +67 -0
  32. forgecli/core/logging.py +47 -0
  33. forgecli/core/models.py +159 -0
  34. forgecli/core/plugins.py +56 -0
  35. forgecli/core/service.py +22 -0
  36. forgecli/docs/__init__.py +5 -0
  37. forgecli/docs/generator.py +119 -0
  38. forgecli/engine/__init__.py +105 -0
  39. forgecli/engine/context.py +171 -0
  40. forgecli/engine/defaults.py +89 -0
  41. forgecli/engine/events.py +185 -0
  42. forgecli/engine/execution.py +526 -0
  43. forgecli/engine/plugins.py +146 -0
  44. forgecli/engine/runner.py +155 -0
  45. forgecli/engine/stages/__init__.py +33 -0
  46. forgecli/engine/stages/caveman_optimizer.py +47 -0
  47. forgecli/engine/stages/context_optimizer.py +44 -0
  48. forgecli/engine/stages/execution_engine_stage.py +108 -0
  49. forgecli/engine/stages/git_engine.py +30 -0
  50. forgecli/engine/stages/intent_analyzer.py +38 -0
  51. forgecli/engine/stages/model_router.py +66 -0
  52. forgecli/engine/stages/planning_engine.py +45 -0
  53. forgecli/engine/stages/repository_analyzer.py +54 -0
  54. forgecli/engine/stages/validation_engine.py +89 -0
  55. forgecli/git/__init__.py +9 -0
  56. forgecli/git/repo.py +55 -0
  57. forgecli/git/service.py +45 -0
  58. forgecli/graph/__init__.py +56 -0
  59. forgecli/graph/backend_graphify.py +453 -0
  60. forgecli/graph/edge.py +19 -0
  61. forgecli/graph/graph.py +85 -0
  62. forgecli/graph/graphify.py +412 -0
  63. forgecli/graph/indexer.py +82 -0
  64. forgecli/graph/node.py +47 -0
  65. forgecli/graph/repository.py +164 -0
  66. forgecli/memory/__init__.py +12 -0
  67. forgecli/memory/cache.py +61 -0
  68. forgecli/memory/history.py +138 -0
  69. forgecli/memory/store.py +87 -0
  70. forgecli/optimizer/__init__.py +14 -0
  71. forgecli/optimizer/caveman/__init__.py +173 -0
  72. forgecli/optimizer/caveman/cli.py +64 -0
  73. forgecli/optimizer/caveman/decorator.py +67 -0
  74. forgecli/optimizer/caveman/factory.py +51 -0
  75. forgecli/optimizer/caveman/ruleset.py +156 -0
  76. forgecli/optimizer/caveman/state.py +52 -0
  77. forgecli/optimizer/chunker.py +85 -0
  78. forgecli/optimizer/optimizer.py +81 -0
  79. forgecli/optimizer/ponytail/__init__.py +183 -0
  80. forgecli/optimizer/ponytail/cli.py +168 -0
  81. forgecli/optimizer/ponytail/decorator.py +70 -0
  82. forgecli/optimizer/ponytail/factory.py +51 -0
  83. forgecli/optimizer/ponytail/ruleset.py +168 -0
  84. forgecli/optimizer/ponytail/state.py +53 -0
  85. forgecli/optimizer/ranker.py +37 -0
  86. forgecli/optimizer/summarizer.py +44 -0
  87. forgecli/orchestrator/__init__.py +707 -0
  88. forgecli/planner/__init__.py +56 -0
  89. forgecli/planner/agent.py +61 -0
  90. forgecli/planner/plan.py +65 -0
  91. forgecli/planner/planner.py +17 -0
  92. forgecli/planner/render.py +271 -0
  93. forgecli/planner/serialize.py +106 -0
  94. forgecli/planner/software.py +834 -0
  95. forgecli/platform/__init__.py +91 -0
  96. forgecli/platform/core.py +201 -0
  97. forgecli/platform/deps.py +354 -0
  98. forgecli/platform/paths.py +236 -0
  99. forgecli/platform/shell.py +176 -0
  100. forgecli/platform/update.py +252 -0
  101. forgecli/plugins/__init__.py +251 -0
  102. forgecli/prompts/__init__.py +11 -0
  103. forgecli/prompts/loader.py +28 -0
  104. forgecli/prompts/registry.py +32 -0
  105. forgecli/prompts/renderer.py +23 -0
  106. forgecli/providers/__init__.py +39 -0
  107. forgecli/providers/anthropic.py +206 -0
  108. forgecli/providers/base.py +206 -0
  109. forgecli/providers/builtin.py +26 -0
  110. forgecli/providers/conversation.py +78 -0
  111. forgecli/providers/google.py +295 -0
  112. forgecli/providers/http_base.py +211 -0
  113. forgecli/providers/mock.py +113 -0
  114. forgecli/providers/openai.py +202 -0
  115. forgecli/providers/openai_compatible.py +728 -0
  116. forgecli/providers/router.py +345 -0
  117. forgecli/providers/router_state.py +89 -0
  118. forgecli/review/__init__.py +45 -0
  119. forgecli/review/analyzer.py +124 -0
  120. forgecli/review/analyzers/__init__.py +1 -0
  121. forgecli/review/analyzers/architecture.py +258 -0
  122. forgecli/review/analyzers/complexity.py +167 -0
  123. forgecli/review/analyzers/dead_code.py +262 -0
  124. forgecli/review/analyzers/duplicates.py +163 -0
  125. forgecli/review/analyzers/performance.py +165 -0
  126. forgecli/review/analyzers/security.py +251 -0
  127. forgecli/review/finding.py +68 -0
  128. forgecli/review/report.py +311 -0
  129. forgecli/review/repository.py +131 -0
  130. forgecli/review/suggestions.py +98 -0
  131. forgecli/runtime/__init__.py +6 -0
  132. forgecli/runtime/cache_store.py +77 -0
  133. forgecli/runtime/prepare.py +199 -0
  134. forgecli/runtime/wrappers.py +152 -0
  135. forgecli/sdk/__init__.py +132 -0
  136. forgecli/sdk/events.py +206 -0
  137. forgecli/sdk/interfaces.py +282 -0
  138. forgecli/sdk/loader.py +243 -0
  139. forgecli/sdk/manager.py +693 -0
  140. forgecli/sdk/manifest.py +397 -0
  141. forgecli/sdk/sandbox.py +197 -0
  142. forgecli/sdk/version.py +316 -0
  143. forgecli/templates/__init__.py +9 -0
  144. forgecli/templates/engine.py +37 -0
  145. forgecli/templates/registry.py +32 -0
  146. forgecli/utils/__init__.py +19 -0
  147. forgecli/utils/fs.py +91 -0
  148. forgecli/utils/ids.py +11 -0
  149. forgecli/utils/io.py +27 -0
  150. forgecli/utils/paths.py +70 -0
  151. forgecli/utils/timing.py +25 -0
  152. forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
  153. forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
  154. forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
  155. forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
  156. 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]
@@ -0,0 +1,5 @@
1
+ """Command-line interface for ForgeCLI."""
2
+
3
+ from forgecli.cli.main import app
4
+
5
+ __all__ = ["app"]
@@ -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)