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,282 @@
|
|
|
1
|
+
"""The 10 canonical plugin extension points.
|
|
2
|
+
|
|
3
|
+
Every ForgeCLI plugin declares one or more of these. Each interface
|
|
4
|
+
is a :class:`typing.Protocol` with a single async or sync ``register``
|
|
5
|
+
method; the SDK calls the method when the plugin is enabled.
|
|
6
|
+
|
|
7
|
+
Adding a new plugin category is intentionally hard: the SDK exposes
|
|
8
|
+
exactly these ten categories and the engine only looks for them by
|
|
9
|
+
name. Plugin authors who want a new category should add a new
|
|
10
|
+
:class:`EntryPointKind` to :mod:`forgecli.sdk.manifest`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
# Re-export the SDK manager for forward references below. The import
|
|
18
|
+
# is intentionally non-binding (TYPE_CHECKING) because the actual
|
|
19
|
+
# PluginManager class lives in :mod:`forgecli.sdk.manager` and would
|
|
20
|
+
# create a circular import at runtime.
|
|
21
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING: # pragma: no cover - type hints only
|
|
24
|
+
from forgecli.sdk.manager import PluginManager
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Configuration protocol
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class PluginConfigurable(Protocol):
|
|
33
|
+
"""A plugin that exposes user-configurable values.
|
|
34
|
+
|
|
35
|
+
Plugins implementing this protocol may return a dict from
|
|
36
|
+
:meth:`default_config`; the SDK merges that into the persisted
|
|
37
|
+
configuration under ``plugin.<name>``. The dict may contain any
|
|
38
|
+
JSON-serialisable values.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def default_config(self) -> dict[str, Any]: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Health protocol
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class PluginHealthCheck(Protocol):
|
|
51
|
+
"""A plugin that exposes a synchronous health probe.
|
|
52
|
+
|
|
53
|
+
The SDK calls :meth:`health` whenever ``forge plugin doctor``
|
|
54
|
+
is invoked; the returned list of :class:`HealthIssue` items is
|
|
55
|
+
surfaced in the doctor report.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def health(self) -> list[HealthIssue]: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# 1. AI Provider
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@runtime_checkable
|
|
67
|
+
class AIProviderPlugin(Protocol):
|
|
68
|
+
"""Register a custom AI provider with the router.
|
|
69
|
+
|
|
70
|
+
The plugin's :meth:`register` is called with the
|
|
71
|
+
:class:`~forgecli.plugins.PluginRegistry`; the plugin calls
|
|
72
|
+
:meth:`PluginRegistry.register_provider` to add itself.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
|
|
77
|
+
def register(self, manager: PluginManager) -> None: ...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# 2. Repository Analyzer
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@runtime_checkable
|
|
86
|
+
class RepositoryAnalyzerPlugin(Protocol):
|
|
87
|
+
"""Register a :class:`RepositoryAnalyzer` with the review engine.
|
|
88
|
+
|
|
89
|
+
The plugin's :meth:`register` is called with the manager; the
|
|
90
|
+
plugin calls :meth:`PluginManager.register_repository_analyzer`.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
name: str
|
|
94
|
+
|
|
95
|
+
def register(self, manager: PluginManager) -> None: ...
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# 3. Context Optimizer
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@runtime_checkable
|
|
104
|
+
class ContextOptimizerPlugin(Protocol):
|
|
105
|
+
"""Register a :class:`PromptOptimizer` with the engine."""
|
|
106
|
+
|
|
107
|
+
name: str
|
|
108
|
+
|
|
109
|
+
def register(self, manager: PluginManager) -> None: ...
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# 4. Code Generator
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@runtime_checkable
|
|
118
|
+
class CodeGeneratorPlugin(Protocol):
|
|
119
|
+
"""Register a code generator (e.g. a custom executor)."""
|
|
120
|
+
|
|
121
|
+
name: str
|
|
122
|
+
|
|
123
|
+
def register(self, manager: PluginManager) -> None: ...
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# 5. Test Runner
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@runtime_checkable
|
|
132
|
+
class TestRunnerPlugin(Protocol):
|
|
133
|
+
"""Register a custom test runner.
|
|
134
|
+
|
|
135
|
+
The plugin's :meth:`register` is called with the manager; the
|
|
136
|
+
plugin can use ``manager.register_test_runner(name, callback)``
|
|
137
|
+
to register a callable that the engine will use to run tests
|
|
138
|
+
on a project.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
name: str
|
|
142
|
+
|
|
143
|
+
def register(self, manager: PluginManager) -> None: ...
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# 6. Git Provider
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@runtime_checkable
|
|
152
|
+
class GitProviderPlugin(Protocol):
|
|
153
|
+
"""Register a custom Git provider.
|
|
154
|
+
|
|
155
|
+
The plugin's :meth:`register` is called with the manager; the
|
|
156
|
+
plugin can replace or extend ``manager.git_service`` with its
|
|
157
|
+
own implementation.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
name: str
|
|
161
|
+
|
|
162
|
+
def register(self, manager: PluginManager) -> None: ...
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# 7. Documentation Generator
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@runtime_checkable
|
|
171
|
+
class DocumentationGeneratorPlugin(Protocol):
|
|
172
|
+
"""Register a documentation generator.
|
|
173
|
+
|
|
174
|
+
The plugin's :meth:`register` is called with the manager; the
|
|
175
|
+
plugin calls :meth:`PluginManager.register_docs_generator`.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
name: str
|
|
179
|
+
|
|
180
|
+
def register(self, manager: PluginManager) -> None: ...
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# 8. Deployment Provider
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@runtime_checkable
|
|
189
|
+
class DeploymentProviderPlugin(Protocol):
|
|
190
|
+
"""Register a deployment provider (CI, cloud, package manager)."""
|
|
191
|
+
|
|
192
|
+
name: str
|
|
193
|
+
|
|
194
|
+
def register(self, manager: PluginManager) -> None: ...
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# 9. Observability Provider
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@runtime_checkable
|
|
203
|
+
class ObservabilityProviderPlugin(Protocol):
|
|
204
|
+
"""Register an observability provider (metrics, tracing, logs)."""
|
|
205
|
+
|
|
206
|
+
name: str
|
|
207
|
+
|
|
208
|
+
def register(self, manager: PluginManager) -> None: ...
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# 10. Notification Provider
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@runtime_checkable
|
|
217
|
+
class NotificationProviderPlugin(Protocol):
|
|
218
|
+
"""Register a notification sink (Slack, email, webhook)."""
|
|
219
|
+
|
|
220
|
+
name: str
|
|
221
|
+
|
|
222
|
+
def register(self, manager: PluginManager) -> None: ...
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Health issue + summary types
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass(frozen=True)
|
|
231
|
+
class HealthIssue:
|
|
232
|
+
"""A single result from a plugin's health probe."""
|
|
233
|
+
|
|
234
|
+
severity: str # "info" | "warn" | "error"
|
|
235
|
+
message: str
|
|
236
|
+
suggestion: str | None = None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class HealthReport:
|
|
241
|
+
"""The aggregated health report for a single plugin."""
|
|
242
|
+
|
|
243
|
+
plugin_name: str
|
|
244
|
+
issues: tuple[HealthIssue, ...] = ()
|
|
245
|
+
healthy: bool = True
|
|
246
|
+
|
|
247
|
+
def to_dict(self) -> dict[str, object]:
|
|
248
|
+
return {
|
|
249
|
+
"plugin": self.plugin_name,
|
|
250
|
+
"healthy": self.healthy,
|
|
251
|
+
"issues": [
|
|
252
|
+
{
|
|
253
|
+
"severity": issue.severity,
|
|
254
|
+
"message": issue.message,
|
|
255
|
+
"suggestion": issue.suggestion,
|
|
256
|
+
}
|
|
257
|
+
for issue in self.issues
|
|
258
|
+
],
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# Forward import for type hints only.
|
|
263
|
+
if False: # pragma: no cover - type hints only
|
|
264
|
+
from forgecli.sdk.manager import PluginManager
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
__all__ = [
|
|
268
|
+
"AIProviderPlugin",
|
|
269
|
+
"CodeGeneratorPlugin",
|
|
270
|
+
"ContextOptimizerPlugin",
|
|
271
|
+
"DeploymentProviderPlugin",
|
|
272
|
+
"DocumentationGeneratorPlugin",
|
|
273
|
+
"GitProviderPlugin",
|
|
274
|
+
"HealthIssue",
|
|
275
|
+
"HealthReport",
|
|
276
|
+
"NotificationProviderPlugin",
|
|
277
|
+
"ObservabilityProviderPlugin",
|
|
278
|
+
"PluginConfigurable",
|
|
279
|
+
"PluginHealthCheck",
|
|
280
|
+
"RepositoryAnalyzerPlugin",
|
|
281
|
+
"TestRunnerPlugin",
|
|
282
|
+
]
|
forgecli/sdk/loader.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Plugin discovery and on-disk loading.
|
|
2
|
+
|
|
3
|
+
The SDK supports two complementary sources:
|
|
4
|
+
|
|
5
|
+
* **Filesystem-based** plugins. A directory under
|
|
6
|
+
``$config_dir/plugins/<name>`` containing
|
|
7
|
+
``forgecli-plugin.toml`` and a Python package. The loader reads
|
|
8
|
+
the manifest, then imports the entry-point callables on enable.
|
|
9
|
+
* **Entry-point-based** plugins. A regular Python package with a
|
|
10
|
+
``[project.entry-points."forgecli.plugins"]`` table. The loader
|
|
11
|
+
walks the active distribution set via :mod:`importlib.metadata`.
|
|
12
|
+
|
|
13
|
+
Both sources return a :class:`LoadedPlugin` (manifest + the
|
|
14
|
+
resolved entry-point callables + source path). The
|
|
15
|
+
:class:`PluginManager` consumes these.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import importlib
|
|
21
|
+
import importlib.metadata as importlib_metadata
|
|
22
|
+
import importlib.util
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from types import ModuleType
|
|
28
|
+
|
|
29
|
+
from forgecli.platform.paths import config_dir
|
|
30
|
+
from forgecli.sdk.manifest import PluginManifest
|
|
31
|
+
from forgecli.sdk.version import Version
|
|
32
|
+
|
|
33
|
+
_MANIFEST_FILENAME = "forgecli-plugin.toml"
|
|
34
|
+
_PLUGINS_DIRNAME = "plugins"
|
|
35
|
+
_ENTRY_POINT_GROUP = "forgecli.plugins"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class LoadedPlugin:
|
|
40
|
+
"""The result of loading a single plugin from disk or PyPI."""
|
|
41
|
+
|
|
42
|
+
manifest: PluginManifest
|
|
43
|
+
entry_point_factories: dict
|
|
44
|
+
"""Map of (kind, name) -> configure(manager) callable.
|
|
45
|
+
|
|
46
|
+
Each callable is invoked when the plugin is enabled; the
|
|
47
|
+
callable uses the manager to register workflows, providers,
|
|
48
|
+
analyzers, etc.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
source: Path | None = None # None for entry-point-only plugins
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def name(self) -> str:
|
|
55
|
+
return self.manifest.name
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def version(self) -> Version:
|
|
59
|
+
return self.manifest.version
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PluginManifestNotFoundError(LookupError):
|
|
63
|
+
"""Raised when a plugin's manifest is missing or malformed."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Backward-compatible alias for the legacy name.
|
|
67
|
+
PluginManifestNotFound = PluginManifestNotFoundError
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def default_plugins_dir() -> Path:
|
|
71
|
+
"""Return the directory where on-disk plugins live."""
|
|
72
|
+
path = config_dir() / _PLUGINS_DIRNAME
|
|
73
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def discover_filesystem(root: Path | None = None) -> list[LoadedPlugin]:
|
|
78
|
+
"""Find every plugin under ``root`` (default: ``$config_dir/plugins``)."""
|
|
79
|
+
base = root or default_plugins_dir()
|
|
80
|
+
if not base.exists():
|
|
81
|
+
return []
|
|
82
|
+
plugins: list[LoadedPlugin] = []
|
|
83
|
+
for child in sorted(base.iterdir()):
|
|
84
|
+
if not child.is_dir():
|
|
85
|
+
continue
|
|
86
|
+
manifest_path = child / _MANIFEST_FILENAME
|
|
87
|
+
if not manifest_path.exists():
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
plugins.append(load_filesystem(child))
|
|
91
|
+
except PluginManifestNotFound:
|
|
92
|
+
continue
|
|
93
|
+
return plugins
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_filesystem(plugin_dir: Path) -> LoadedPlugin:
|
|
97
|
+
"""Load a single plugin from a directory on disk."""
|
|
98
|
+
manifest_path = plugin_dir / _MANIFEST_FILENAME
|
|
99
|
+
manifest = PluginManifest.load(manifest_path)
|
|
100
|
+
factories = _load_entry_point_factories(manifest, plugin_dir)
|
|
101
|
+
return LoadedPlugin(
|
|
102
|
+
manifest=manifest,
|
|
103
|
+
entry_point_factories=factories,
|
|
104
|
+
source=plugin_dir,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def discover_entry_points() -> list[LoadedPlugin]:
|
|
109
|
+
"""Find every plugin registered via the ``forgecli.plugins`` entry point group."""
|
|
110
|
+
plugins: list[LoadedPlugin] = []
|
|
111
|
+
try:
|
|
112
|
+
entries = importlib_metadata.entry_points(group=_ENTRY_POINT_GROUP)
|
|
113
|
+
except Exception:
|
|
114
|
+
return plugins
|
|
115
|
+
# Group by distribution to load one synthetic manifest per
|
|
116
|
+
# distribution. This is a pragmatic choice that lets users ship
|
|
117
|
+
# multiple plugins in a single distribution.
|
|
118
|
+
by_distribution: dict[str | None, list] = {}
|
|
119
|
+
for ep in entries:
|
|
120
|
+
dist = getattr(ep, "dist", None)
|
|
121
|
+
dist_name = dist.name if dist is not None else "unknown"
|
|
122
|
+
by_distribution.setdefault(dist_name, []).append(ep)
|
|
123
|
+
for dist_name, ep_list in by_distribution.items():
|
|
124
|
+
try:
|
|
125
|
+
plugins.append(_load_entry_point_distribution(dist_name, ep_list))
|
|
126
|
+
except PluginManifestNotFound:
|
|
127
|
+
continue
|
|
128
|
+
except Exception:
|
|
129
|
+
continue
|
|
130
|
+
return plugins
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _load_entry_point_distribution(
|
|
134
|
+
dist_name: str | None, entry_points: list
|
|
135
|
+
) -> LoadedPlugin:
|
|
136
|
+
"""Load a distribution's entry-points as a synthetic plugin."""
|
|
137
|
+
if dist_name is None:
|
|
138
|
+
raise PluginManifestNotFound("entry point has no distribution")
|
|
139
|
+
try:
|
|
140
|
+
dist = importlib_metadata.distribution(dist_name)
|
|
141
|
+
except importlib_metadata.PackageNotFoundError as exc:
|
|
142
|
+
raise PluginManifestNotFound(dist_name) from exc
|
|
143
|
+
# Use the distribution's metadata to build a synthetic manifest.
|
|
144
|
+
meta = dist.metadata
|
|
145
|
+
name = _coerce_name(meta.get("Name") or dist_name, dist_name)
|
|
146
|
+
version = _coerce_version(meta.get("Version"))
|
|
147
|
+
if version is None:
|
|
148
|
+
raise PluginManifestNotFound(f"{dist_name}: no version")
|
|
149
|
+
summary = meta.get("Summary", "").strip()
|
|
150
|
+
summary = summary.splitlines()[0] if summary else f"{name} plugin"
|
|
151
|
+
manifest = PluginManifest(
|
|
152
|
+
name=name,
|
|
153
|
+
version=version,
|
|
154
|
+
summary=summary,
|
|
155
|
+
description=meta.get("Description", "") or "",
|
|
156
|
+
authors=tuple(_parse_authors(meta.get("Author"))),
|
|
157
|
+
license=meta.get("License", "") or "",
|
|
158
|
+
homepage=meta.get("Home-page", "") or "",
|
|
159
|
+
)
|
|
160
|
+
factories: dict[tuple[str, str], Callable] = {}
|
|
161
|
+
for ep in entry_points:
|
|
162
|
+
try:
|
|
163
|
+
callback = ep.load()
|
|
164
|
+
except Exception:
|
|
165
|
+
continue
|
|
166
|
+
from forgecli.sdk.manifest import EntryPointKind as _Kind
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
kind = _Kind(ep.group.replace(f"{_ENTRY_POINT_GROUP}.", ""))
|
|
170
|
+
except ValueError:
|
|
171
|
+
continue
|
|
172
|
+
factories[(kind.value, ep.name)] = callback
|
|
173
|
+
return LoadedPlugin(
|
|
174
|
+
manifest=manifest,
|
|
175
|
+
entry_point_factories=factories,
|
|
176
|
+
source=None,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _load_entry_point_factories(
|
|
181
|
+
manifest: PluginManifest, plugin_dir: Path
|
|
182
|
+
) -> dict:
|
|
183
|
+
"""Import each ``module:attr`` reference declared in the manifest."""
|
|
184
|
+
factories: dict = {}
|
|
185
|
+
for ep in manifest.entry_points:
|
|
186
|
+
module_name, _, attr = ep.reference.partition(":")
|
|
187
|
+
if not module_name or not attr:
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
module = _import_module_from_dir(module_name, plugin_dir)
|
|
191
|
+
except Exception:
|
|
192
|
+
continue
|
|
193
|
+
callback = getattr(module, attr, None)
|
|
194
|
+
if not callable(callback):
|
|
195
|
+
continue
|
|
196
|
+
factories[(ep.kind.value, ep.name)] = callback
|
|
197
|
+
return factories
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _import_module_from_dir(name: str, plugin_dir: Path) -> ModuleType:
|
|
201
|
+
"""Import ``name`` from a directory under ``plugin_dir``.
|
|
202
|
+
|
|
203
|
+
We add the plugin dir to ``sys.path`` and then ``importlib.import_module``
|
|
204
|
+
so the import works for both regular packages and loose modules.
|
|
205
|
+
"""
|
|
206
|
+
if str(plugin_dir) not in sys.path:
|
|
207
|
+
sys.path.insert(0, str(plugin_dir))
|
|
208
|
+
return importlib.import_module(name)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _coerce_name(value: str | None, fallback: str) -> str:
|
|
212
|
+
if not value:
|
|
213
|
+
return fallback.lower().replace("_", "-").replace(" ", "-")
|
|
214
|
+
return value.lower().replace("_", "-")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _coerce_version(value: str | None) -> Version | None:
|
|
218
|
+
if not value:
|
|
219
|
+
return None
|
|
220
|
+
from forgecli.sdk.version import Version, VersionParseError
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
return Version.parse(value)
|
|
224
|
+
except VersionParseError:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _parse_authors(value: str | None) -> list[str]:
|
|
229
|
+
if not value:
|
|
230
|
+
return []
|
|
231
|
+
return [a.strip() for a in value.split(",") if a.strip()]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# Silence unused-import warnings.
|
|
235
|
+
|
|
236
|
+
__all__ = [
|
|
237
|
+
"LoadedPlugin",
|
|
238
|
+
"PluginManifestNotFound",
|
|
239
|
+
"default_plugins_dir",
|
|
240
|
+
"discover_entry_points",
|
|
241
|
+
"discover_filesystem",
|
|
242
|
+
"load_filesystem",
|
|
243
|
+
]
|