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,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
+ ]