forgeoptimizer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) 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 +149 -0
  19. forgecli/cli/commands_wrappers.py +80 -0
  20. forgecli/cli/daemon.py +744 -0
  21. forgecli/cli/main.py +234 -0
  22. forgecli/cli/ui.py +56 -0
  23. forgecli/config/__init__.py +28 -0
  24. forgecli/config/loader.py +85 -0
  25. forgecli/config/settings.py +206 -0
  26. forgecli/config/writer.py +90 -0
  27. forgecli/core/__init__.py +35 -0
  28. forgecli/core/container.py +68 -0
  29. forgecli/core/context.py +54 -0
  30. forgecli/core/credentials.py +114 -0
  31. forgecli/core/errors.py +27 -0
  32. forgecli/core/events.py +67 -0
  33. forgecli/core/logging.py +47 -0
  34. forgecli/core/models.py +214 -0
  35. forgecli/core/plugins.py +54 -0
  36. forgecli/core/service.py +22 -0
  37. forgecli/docs/__init__.py +5 -0
  38. forgecli/docs/generator.py +115 -0
  39. forgecli/engine/__init__.py +105 -0
  40. forgecli/engine/context.py +163 -0
  41. forgecli/engine/defaults.py +89 -0
  42. forgecli/engine/events.py +185 -0
  43. forgecli/engine/execution.py +519 -0
  44. forgecli/engine/plugins.py +145 -0
  45. forgecli/engine/runner.py +158 -0
  46. forgecli/engine/stages/__init__.py +33 -0
  47. forgecli/engine/stages/caveman_optimizer.py +47 -0
  48. forgecli/engine/stages/context_optimizer.py +44 -0
  49. forgecli/engine/stages/execution_engine_stage.py +102 -0
  50. forgecli/engine/stages/git_engine.py +30 -0
  51. forgecli/engine/stages/intent_analyzer.py +38 -0
  52. forgecli/engine/stages/model_router.py +65 -0
  53. forgecli/engine/stages/planning_engine.py +45 -0
  54. forgecli/engine/stages/repository_analyzer.py +54 -0
  55. forgecli/engine/stages/validation_engine.py +87 -0
  56. forgecli/git/__init__.py +9 -0
  57. forgecli/git/repo.py +55 -0
  58. forgecli/git/service.py +45 -0
  59. forgecli/graph/__init__.py +56 -0
  60. forgecli/graph/backend_graphify.py +448 -0
  61. forgecli/graph/edge.py +19 -0
  62. forgecli/graph/graph.py +85 -0
  63. forgecli/graph/graphify.py +405 -0
  64. forgecli/graph/indexer.py +82 -0
  65. forgecli/graph/node.py +47 -0
  66. forgecli/graph/repository.py +164 -0
  67. forgecli/memory/__init__.py +12 -0
  68. forgecli/memory/cache.py +61 -0
  69. forgecli/memory/history.py +136 -0
  70. forgecli/memory/store.py +85 -0
  71. forgecli/optimizer/__init__.py +13 -0
  72. forgecli/optimizer/caveman/__init__.py +169 -0
  73. forgecli/optimizer/caveman/cli.py +62 -0
  74. forgecli/optimizer/caveman/decorator.py +67 -0
  75. forgecli/optimizer/caveman/factory.py +51 -0
  76. forgecli/optimizer/caveman/ruleset.py +156 -0
  77. forgecli/optimizer/caveman/state.py +50 -0
  78. forgecli/optimizer/chunker.py +85 -0
  79. forgecli/optimizer/optimizer.py +81 -0
  80. forgecli/optimizer/ponytail/__init__.py +181 -0
  81. forgecli/optimizer/ponytail/cli.py +159 -0
  82. forgecli/optimizer/ponytail/decorator.py +70 -0
  83. forgecli/optimizer/ponytail/factory.py +51 -0
  84. forgecli/optimizer/ponytail/ruleset.py +168 -0
  85. forgecli/optimizer/ponytail/state.py +51 -0
  86. forgecli/optimizer/ranker.py +37 -0
  87. forgecli/optimizer/summarizer.py +44 -0
  88. forgecli/orchestrator/__init__.py +706 -0
  89. forgecli/planner/__init__.py +56 -0
  90. forgecli/planner/agent.py +61 -0
  91. forgecli/planner/plan.py +65 -0
  92. forgecli/planner/planner.py +17 -0
  93. forgecli/planner/render.py +267 -0
  94. forgecli/planner/serialize.py +104 -0
  95. forgecli/planner/software.py +832 -0
  96. forgecli/platform/__init__.py +91 -0
  97. forgecli/platform/core.py +201 -0
  98. forgecli/platform/deps.py +361 -0
  99. forgecli/platform/paths.py +234 -0
  100. forgecli/platform/shell.py +176 -0
  101. forgecli/platform/update.py +253 -0
  102. forgecli/plugins/__init__.py +249 -0
  103. forgecli/prompts/__init__.py +11 -0
  104. forgecli/prompts/loader.py +28 -0
  105. forgecli/prompts/registry.py +32 -0
  106. forgecli/prompts/renderer.py +23 -0
  107. forgecli/providers/__init__.py +39 -0
  108. forgecli/providers/anthropic.py +207 -0
  109. forgecli/providers/base.py +204 -0
  110. forgecli/providers/builtin.py +26 -0
  111. forgecli/providers/conversation.py +74 -0
  112. forgecli/providers/google.py +290 -0
  113. forgecli/providers/http_base.py +207 -0
  114. forgecli/providers/mock.py +111 -0
  115. forgecli/providers/openai.py +206 -0
  116. forgecli/providers/openai_compatible.py +787 -0
  117. forgecli/providers/router.py +340 -0
  118. forgecli/providers/router_state.py +89 -0
  119. forgecli/review/__init__.py +45 -0
  120. forgecli/review/analyzer.py +124 -0
  121. forgecli/review/analyzers/__init__.py +1 -0
  122. forgecli/review/analyzers/architecture.py +255 -0
  123. forgecli/review/analyzers/complexity.py +162 -0
  124. forgecli/review/analyzers/dead_code.py +255 -0
  125. forgecli/review/analyzers/duplicates.py +161 -0
  126. forgecli/review/analyzers/performance.py +161 -0
  127. forgecli/review/analyzers/security.py +244 -0
  128. forgecli/review/finding.py +68 -0
  129. forgecli/review/report.py +321 -0
  130. forgecli/review/repository.py +130 -0
  131. forgecli/review/suggestions.py +98 -0
  132. forgecli/runtime/__init__.py +6 -0
  133. forgecli/runtime/cache_store.py +75 -0
  134. forgecli/runtime/mcp_config.py +118 -0
  135. forgecli/runtime/prepare.py +203 -0
  136. forgecli/runtime/wrappers.py +150 -0
  137. forgecli/sdk/__init__.py +132 -0
  138. forgecli/sdk/events.py +206 -0
  139. forgecli/sdk/interfaces.py +282 -0
  140. forgecli/sdk/loader.py +239 -0
  141. forgecli/sdk/manager.py +678 -0
  142. forgecli/sdk/manifest.py +395 -0
  143. forgecli/sdk/sandbox.py +247 -0
  144. forgecli/sdk/version.py +313 -0
  145. forgecli/templates/__init__.py +9 -0
  146. forgecli/templates/engine.py +37 -0
  147. forgecli/templates/registry.py +32 -0
  148. forgecli/utils/__init__.py +19 -0
  149. forgecli/utils/fs.py +121 -0
  150. forgecli/utils/ids.py +11 -0
  151. forgecli/utils/io.py +27 -0
  152. forgecli/utils/paths.py +78 -0
  153. forgecli/utils/stats.py +179 -0
  154. forgecli/utils/timing.py +25 -0
  155. forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
  156. forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
  157. forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
  158. forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
  159. forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,678 @@
1
+ """The :class:`PluginManager` — single source of truth for plugin state.
2
+
3
+ The manager owns:
4
+
5
+ * the :class:`LoadedPlugin` cache (filesystem + entry-point);
6
+ * the persistent **enabled-set** (which plugin names are turned on);
7
+ * the per-plugin **config** dict (validated against the
8
+ manifest's ``config_schema`` if any);
9
+ * the registration channels that plugins use to contribute
10
+ providers, analyzers, optimizers, etc.
11
+
12
+ The manager's lifecycle is::
13
+
14
+ load → install → enable → (configure) → (run) → disable → uninstall
15
+
16
+ All mutations go through the manager so the SDK can fire lifecycle
17
+ events on the :class:`PluginEventBus` and call the appropriate
18
+ :func:`sandbox` for plugin callbacks.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import json
25
+ import logging
26
+ import shutil
27
+ import subprocess
28
+ from collections.abc import Callable
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from forgecli.platform.core import (
34
+ current_platform,
35
+ python_version,
36
+ )
37
+ from forgecli.platform.paths import config_dir, data_dir
38
+ from forgecli.sdk.events import (
39
+ HookManager,
40
+ PluginEvent,
41
+ PluginEventBus,
42
+ PluginEventKind,
43
+ )
44
+ from forgecli.sdk.interfaces import HealthIssue, HealthReport
45
+ from forgecli.sdk.loader import (
46
+ LoadedPlugin,
47
+ discover_entry_points,
48
+ discover_filesystem,
49
+ load_filesystem,
50
+ )
51
+ from forgecli.sdk.manifest import (
52
+ Compatibility,
53
+ EntryPointKind,
54
+ PluginManifest,
55
+ is_valid_plugin_name,
56
+ )
57
+ from forgecli.sdk.sandbox import Sandbox
58
+ from forgecli.sdk.version import (
59
+ Requirement,
60
+ Version,
61
+ VersionParseError,
62
+ resolve,
63
+ )
64
+
65
+ _logger = logging.getLogger("forgecli.sdk.manager")
66
+ _STATE_FILENAME = "plugins.json"
67
+ _REGISTRY_FILENAME = "registry.json"
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Errors
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ class PluginError(Exception):
76
+ """Base class for plugin-manager errors."""
77
+
78
+
79
+ class PluginNotFoundError(PluginError, LookupError):
80
+ pass
81
+
82
+
83
+ class PluginAlreadyInstalledError(PluginError):
84
+ pass
85
+
86
+
87
+ class PluginCompatibilityError(PluginError):
88
+ pass
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Persistent state
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ @dataclass
97
+ class PluginState:
98
+ """The persisted state of an installed plugin."""
99
+
100
+ name: str
101
+ version: str
102
+ enabled: bool
103
+ source: str # "filesystem" | "entry-point" | "git"
104
+ install_path: str | None = None
105
+ config: dict[str, Any] = field(default_factory=dict)
106
+ installed_at: str = ""
107
+ enabled_at: str | None = None
108
+
109
+
110
+ @dataclass
111
+ class PluginRegistryState:
112
+ """Top-level persisted state of the SDK."""
113
+
114
+ plugins: dict[str, PluginState] = field(default_factory=dict)
115
+ schema_version: int = 1
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Manager
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ class PluginManager:
124
+ """The SDK's central façade."""
125
+
126
+ def __init__(
127
+ self,
128
+ *,
129
+ config_root: Path | None = None,
130
+ data_root: Path | None = None,
131
+ ) -> None:
132
+ self._config_root = config_root or config_dir()
133
+ self._data_root = data_root or data_dir()
134
+ self._plugins_dir = self._config_root / "plugins"
135
+ self._plugins_dir.mkdir(parents=True, exist_ok=True)
136
+ self._state_file = self._data_root / _STATE_FILENAME
137
+ self._bus = PluginEventBus()
138
+ self._hooks = HookManager()
139
+ self._loaded: dict[str, LoadedPlugin] = {}
140
+ self._state = self._load_state()
141
+ # Registration channels
142
+ self.providers: dict[str, type] = {}
143
+ self.optimizers: dict[str, type] = {}
144
+ self.analyzers: list[type] = []
145
+ self.workflows: list = []
146
+ self.classifiers: list = []
147
+ self.test_runners: dict[str, Callable[..., Any]] = {}
148
+ self.docs_generators: list[Callable[..., Any]] = []
149
+ self.deployment_providers: list[Callable[..., Any]] = []
150
+ self.observability_providers: list[Callable[..., Any]] = []
151
+ self.notification_providers: dict[str, Callable[..., Any]] = {}
152
+ self.git_service: Any = None
153
+
154
+ # ------------------------------------------------------------------
155
+ # Accessors
156
+ # ------------------------------------------------------------------
157
+
158
+ @property
159
+ def state(self) -> PluginRegistryState:
160
+ return self._state
161
+
162
+ @property
163
+ def bus(self) -> PluginEventBus:
164
+ return self._bus
165
+
166
+ @property
167
+ def hooks(self) -> HookManager:
168
+ return self._hooks
169
+
170
+ @property
171
+ def plugins_dir(self) -> Path:
172
+ return self._plugins_dir
173
+
174
+ # ------------------------------------------------------------------
175
+ # Discovery
176
+ # ------------------------------------------------------------------
177
+
178
+ def discover(self, *, use_cache: bool = True) -> list[LoadedPlugin]:
179
+ """Return every plugin available on disk or via entry points."""
180
+ if use_cache and self._loaded:
181
+ return list(self._loaded.values())
182
+ self._loaded = {}
183
+ for plugin in discover_filesystem(self._plugins_dir):
184
+ self._loaded[plugin.name] = plugin
185
+ for plugin in discover_entry_points():
186
+ # On-disk overrides entry-point with the same name.
187
+ self._loaded.setdefault(plugin.name, plugin)
188
+ return list(self._loaded.values())
189
+
190
+ def get(self, name: str) -> LoadedPlugin:
191
+ if name not in self._loaded:
192
+ self.discover()
193
+ if name not in self._loaded:
194
+ raise PluginNotFoundError(name)
195
+ return self._loaded[name]
196
+
197
+ # ------------------------------------------------------------------
198
+ # Lifecycle
199
+ # ------------------------------------------------------------------
200
+
201
+ def install(
202
+ self,
203
+ source: str,
204
+ *,
205
+ from_path: Path | None = None,
206
+ from_git: str | None = None,
207
+ ) -> LoadedPlugin:
208
+ """Install a plugin from a local path or a git URL."""
209
+ if from_path is not None:
210
+ return self._install_from_path(from_path)
211
+ if from_git is not None:
212
+ return self._install_from_git(from_git)
213
+ # Treat ``source`` as a plugin name or path.
214
+ candidate = Path(source)
215
+ if candidate.exists():
216
+ return self._install_from_path(candidate)
217
+ raise PluginError(f"could not resolve plugin source: {source!r}")
218
+
219
+ def _install_from_path(self, source: Path) -> LoadedPlugin:
220
+ if not source.is_dir():
221
+ raise PluginError(f"{source} is not a directory")
222
+ manifest = PluginManifest.load(source / "forgecli-plugin.toml")
223
+ self._validate(manifest)
224
+ target = self._plugins_dir / manifest.name
225
+ if target.exists():
226
+ raise PluginAlreadyInstalledError(manifest.name)
227
+ shutil.copytree(source, target)
228
+ loaded = load_filesystem(target)
229
+ self._loaded[loaded.name] = loaded
230
+ self._state.plugins[loaded.name] = PluginState(
231
+ name=loaded.name,
232
+ version=str(loaded.version),
233
+ enabled=False,
234
+ source="filesystem",
235
+ install_path=str(target),
236
+ installed_at=_now_iso(),
237
+ )
238
+ self._save_state()
239
+ self._bus.publish(
240
+ PluginEvent(
241
+ kind=PluginEventKind.INSTALLED,
242
+ plugin_name=loaded.name,
243
+ )
244
+ )
245
+ return loaded
246
+
247
+ def _install_from_git(self, url: str) -> LoadedPlugin:
248
+ target = self._plugins_dir / _slugify(url)
249
+ if target.exists():
250
+ shutil.rmtree(target)
251
+ self._run_git_clone(url, target)
252
+ return self._install_from_path(target)
253
+
254
+ @staticmethod
255
+ def _run_git_clone(url: str, target: Path) -> None:
256
+ import tempfile
257
+
258
+ with tempfile.TemporaryDirectory() as tmp:
259
+ result = subprocess.run(
260
+ ["git", "clone", "--depth=1", url, str(target)],
261
+ cwd=tmp,
262
+ capture_output=True,
263
+ text=True,
264
+ check=False,
265
+ )
266
+ if result.returncode != 0:
267
+ raise PluginError(f"git clone {url!r} failed: {result.stderr.strip()}")
268
+
269
+ def uninstall(self, name: str, *, remove_files: bool = True) -> None:
270
+ plugin = self._state.plugins.get(name)
271
+ if plugin is None:
272
+ raise PluginNotFoundError(name)
273
+ if plugin.enabled:
274
+ self.disable(name)
275
+ if remove_files and plugin.install_path:
276
+ path = Path(plugin.install_path)
277
+ if path.exists() and path.is_relative_to(self._plugins_dir):
278
+ shutil.rmtree(path, ignore_errors=True)
279
+ self._state.plugins.pop(name, None)
280
+ self._loaded.pop(name, None)
281
+ self._save_state()
282
+ self._bus.publish(PluginEvent(kind=PluginEventKind.UNINSTALLED, plugin_name=name))
283
+
284
+ def enable(self, name: str) -> None:
285
+ plugin_state = self._state.plugins.get(name)
286
+ if plugin_state is None:
287
+ raise PluginNotFoundError(name)
288
+ if plugin_state.enabled:
289
+ return
290
+ plugin = self._load_for(name, plugin_state)
291
+ self._validate(plugin.manifest)
292
+ self._invoke_entry_points(plugin, enabled=True)
293
+ plugin_state.enabled = True
294
+ plugin_state.enabled_at = _now_iso()
295
+ self._save_state()
296
+ self._bus.publish(PluginEvent(kind=PluginEventKind.ENABLED, plugin_name=name))
297
+
298
+ def disable(self, name: str) -> None:
299
+ plugin_state = self._state.plugins.get(name)
300
+ if plugin_state is None or not plugin_state.enabled:
301
+ return
302
+ plugin = self._load_for(name, plugin_state)
303
+ self._invoke_entry_points(plugin, enabled=False)
304
+ plugin_state.enabled = False
305
+ plugin_state.enabled_at = None
306
+ self._save_state()
307
+ self._bus.publish(PluginEvent(kind=PluginEventKind.DISABLED, plugin_name=name))
308
+
309
+ def update(self, name: str, *, source: str | None = None) -> LoadedPlugin:
310
+ plugin_state = self._state.plugins.get(name)
311
+ if plugin_state is None:
312
+ raise PluginNotFoundError(name)
313
+ if plugin_state.source == "filesystem" and source is None:
314
+ raise PluginError(
315
+ f"plugin {name!r} was installed from a path; pass --source to re-pull it"
316
+ )
317
+ if source is None and plugin_state.source == "git":
318
+ # Re-clone from the original URL.
319
+ assert plugin_state.install_path is not None
320
+ url = self._git_origin(Path(plugin_state.install_path))
321
+ return self._install_from_git(url)
322
+ if source is None and plugin_state.source == "entry-point":
323
+ raise PluginError(
324
+ f"plugin {name!r} is an entry-point plugin; "
325
+ "upgrade the underlying distribution instead"
326
+ )
327
+ return self.install(source) # type: ignore[arg-type]
328
+
329
+ # ------------------------------------------------------------------
330
+ # Configuration
331
+ # ------------------------------------------------------------------
332
+
333
+ def configure(self, name: str, **values: Any) -> None:
334
+ state = self._state.plugins.get(name)
335
+ if state is None:
336
+ raise PluginNotFoundError(name)
337
+ merged = dict(state.config)
338
+ merged.update(values)
339
+ state.config = merged
340
+ self._save_state()
341
+ self._bus.publish(
342
+ PluginEvent(
343
+ kind=PluginEventKind.CONFIG_CHANGED,
344
+ plugin_name=name,
345
+ payload={"config": merged},
346
+ )
347
+ )
348
+
349
+ def get_config(self, name: str) -> dict[str, Any]:
350
+ state = self._state.plugins.get(name)
351
+ if state is None:
352
+ raise PluginNotFoundError(name)
353
+ return dict(state.config)
354
+
355
+ # ------------------------------------------------------------------
356
+ # Health
357
+ # ------------------------------------------------------------------
358
+
359
+ def doctor(self) -> list[HealthReport]:
360
+ """Run a health check across every installed plugin."""
361
+ reports: list[HealthReport] = []
362
+ for name, state in sorted(self._state.plugins.items()):
363
+ issues: list[HealthIssue] = []
364
+ try:
365
+ plugin = self._load_for(name, state)
366
+ except Exception as exc:
367
+ issues.append(HealthIssue("error", f"could not load: {exc}"))
368
+ reports.append(HealthReport(name, tuple(issues), healthy=False))
369
+ continue
370
+ issues.extend(_check_compatibility(plugin.manifest))
371
+ issues.extend(_check_dependencies(plugin.manifest))
372
+ if state.enabled and _has_health_check(plugin):
373
+ with Sandbox(plugin_permissions=plugin.manifest.permissions):
374
+ try:
375
+ reported = list(
376
+ plugin.entry_point_factories[ # type: ignore[attr-defined]
377
+ (EntryPointKind.OBSERVABILITY.value, "health")
378
+ ]()
379
+ )
380
+ except Exception:
381
+ reported = []
382
+ for issue in reported:
383
+ issues.append(
384
+ HealthIssue(
385
+ severity=str(issue.get("severity", "warn")),
386
+ message=str(issue.get("message", "")),
387
+ suggestion=issue.get("suggestion"),
388
+ )
389
+ )
390
+ healthy = not any(i.severity == "error" for i in issues)
391
+ reports.append(HealthReport(name, tuple(issues), healthy=healthy))
392
+ return reports
393
+
394
+ # ------------------------------------------------------------------
395
+ # Listing
396
+ # ------------------------------------------------------------------
397
+
398
+ def list(self) -> list[tuple[PluginState, LoadedPlugin | None]]:
399
+ """Return ``(state, loaded_or_none)`` for every installed plugin."""
400
+ result: list[tuple[PluginState, LoadedPlugin | None]] = []
401
+ for state in self._state.plugins.values():
402
+ try:
403
+ loaded = self._load_for(state.name, state)
404
+ except Exception:
405
+ loaded = None
406
+ result.append((state, loaded))
407
+ return result
408
+
409
+ # ------------------------------------------------------------------
410
+ # Registration channels
411
+ # ------------------------------------------------------------------
412
+
413
+ def register_provider(self, name: str, provider_cls: type) -> None:
414
+ self.providers[name] = provider_cls
415
+
416
+ def register_optimizer(self, name: str, optimizer_cls: type) -> None:
417
+ self.optimizers[name] = optimizer_cls
418
+
419
+ def register_repository_analyzer(self, analyzer_cls: type) -> None:
420
+ self.analyzers.append(analyzer_cls)
421
+
422
+ def register_workflow(self, workflow: Any) -> None:
423
+ self.workflows.append(workflow)
424
+
425
+ def register_classifier(self, classifier: Any) -> None:
426
+ self.classifiers.append(classifier)
427
+
428
+ def register_test_runner(self, name: str, callback: Callable[..., Any]) -> None:
429
+ self.test_runners[name] = callback
430
+
431
+ def register_docs_generator(self, callback: Callable[..., Any]) -> None:
432
+ self.docs_generators.append(callback)
433
+
434
+ def register_deployment_provider(self, callback: Callable[..., Any]) -> None:
435
+ self.deployment_providers.append(callback)
436
+
437
+ def register_observability_provider(self, callback: Callable[..., Any]) -> None:
438
+ self.observability_providers.append(callback)
439
+
440
+ def register_notification_provider(self, name: str, callback: Callable[..., Any]) -> None:
441
+ self.notification_providers[name] = callback
442
+
443
+ # ------------------------------------------------------------------
444
+ # Internals
445
+ # ------------------------------------------------------------------
446
+
447
+ def _validate(self, manifest: PluginManifest) -> None:
448
+ if not is_valid_plugin_name(manifest.name):
449
+ raise PluginError(f"invalid plugin name: {manifest.name!r}")
450
+ sdk = Version.parse(_sdk_version())
451
+ if not manifest.compatibility.matches_host(
452
+ sdk, python_version(), current_platform().os.value
453
+ ):
454
+ raise PluginCompatibilityError(
455
+ f"plugin {manifest.name!r} {manifest.version} is not "
456
+ f"compatible with the current host "
457
+ f"(forgecli-sdk={sdk}, python={python_version()}, "
458
+ f"os={current_platform().os.value})"
459
+ )
460
+ # Resolve dependencies against the SDK's version + the
461
+ # currently enabled plugins. Missing transitive deps are
462
+ # surfaced as a warning only.
463
+ requirements: list[Requirement] = list(manifest.dependencies)
464
+ candidates: dict[str, tuple[Version, ...]] = {"forgecli-sdk": (sdk,)}
465
+ for name in self._state.plugins:
466
+ try:
467
+ candidates[name] = (Version.parse(self._state.plugins[name].version),)
468
+ except VersionParseError:
469
+ continue
470
+ try:
471
+ resolve(requirements, candidates)
472
+ except Exception as exc:
473
+ _logger.warning("dependency resolution failed for %s: %s", manifest.name, exc)
474
+
475
+ def _load_for(self, name: str, state: PluginState) -> LoadedPlugin:
476
+ if name in self._loaded:
477
+ return self._loaded[name]
478
+ if (state.source == "filesystem" and state.install_path) or (
479
+ state.source == "git" and state.install_path
480
+ ):
481
+ loaded = load_filesystem(Path(state.install_path))
482
+ else:
483
+ # Entry-point plugin; fall back to discovery.
484
+ for ep in discover_entry_points():
485
+ if ep.name == name:
486
+ loaded = ep
487
+ break
488
+ else:
489
+ raise PluginNotFoundError(name)
490
+ self._loaded[name] = loaded
491
+ return loaded
492
+
493
+ def _invoke_entry_points(self, plugin: LoadedPlugin, *, enabled: bool) -> None:
494
+ """Run each entry-point's factory with the sandbox active."""
495
+ for (kind_name, name), factory in plugin.entry_point_factories.items():
496
+ try:
497
+ kind = EntryPointKind(kind_name)
498
+ except ValueError:
499
+ continue
500
+ with Sandbox(plugin_permissions=plugin.manifest.permissions):
501
+ try:
502
+ if enabled:
503
+ factory(self)
504
+ except Exception:
505
+ _logger.exception("plugin %s: %s.%s failed", plugin.name, kind.value, name)
506
+
507
+ # ------------------------------------------------------------------
508
+ # Persistence
509
+ # ------------------------------------------------------------------
510
+
511
+ def _load_state(self) -> PluginRegistryState:
512
+ if not self._state_file.exists():
513
+ return PluginRegistryState()
514
+ try:
515
+ raw = json.loads(self._state_file.read_text(encoding="utf-8"))
516
+ except (OSError, json.JSONDecodeError):
517
+ return PluginRegistryState()
518
+ plugins: dict[str, PluginState] = {}
519
+ for name, data in raw.get("plugins", {}).items():
520
+ try:
521
+ plugins[name] = PluginState(
522
+ name=name,
523
+ version=str(data.get("version", "0.0.0")),
524
+ enabled=bool(data.get("enabled", False)),
525
+ source=str(data.get("source", "filesystem")),
526
+ install_path=data.get("install_path"),
527
+ config=dict(data.get("config") or {}),
528
+ installed_at=str(data.get("installed_at", "")),
529
+ enabled_at=data.get("enabled_at"),
530
+ )
531
+ except Exception:
532
+ continue
533
+ return PluginRegistryState(plugins=plugins, schema_version=raw.get("schema_version", 1))
534
+
535
+ def _save_state(self) -> None:
536
+ self._state_file.parent.mkdir(parents=True, exist_ok=True)
537
+ payload = {
538
+ "schema_version": 1,
539
+ "plugins": {
540
+ name: {
541
+ "version": state.version,
542
+ "enabled": state.enabled,
543
+ "source": state.source,
544
+ "install_path": state.install_path,
545
+ "config": state.config,
546
+ "installed_at": state.installed_at,
547
+ "enabled_at": state.enabled_at,
548
+ }
549
+ for name, state in self._state.plugins.items()
550
+ },
551
+ }
552
+ with contextlib.suppress(OSError):
553
+ self._state_file.write_text(json.dumps(payload, indent=2), encoding="utf-8")
554
+
555
+ def _git_origin(self, path: Path) -> str:
556
+ result = subprocess.run(
557
+ ["git", "config", "--get", "remote.origin.url"],
558
+ cwd=path,
559
+ capture_output=True,
560
+ text=True,
561
+ check=False,
562
+ )
563
+ if result.returncode != 0:
564
+ raise PluginError(f"could not read origin URL from {path}")
565
+ return result.stdout.strip()
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # Helpers
570
+ # ---------------------------------------------------------------------------
571
+
572
+
573
+ def _now_iso() -> str:
574
+ from datetime import UTC, datetime
575
+
576
+ return datetime.now(UTC).isoformat(timespec="seconds")
577
+
578
+
579
+ def _slugify(value: str) -> str:
580
+ import re
581
+
582
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-").lower()
583
+ return slug or "plugin"
584
+
585
+
586
+ def _has_health_check(plugin: LoadedPlugin) -> bool:
587
+ """Return True if the plugin has a registered health entry point."""
588
+ from forgecli.sdk.manifest import EntryPointKind
589
+
590
+ return (EntryPointKind.OBSERVABILITY.value, "health") in plugin.entry_point_factories
591
+
592
+
593
+ def _check_compatibility(manifest: PluginManifest) -> list[HealthIssue]:
594
+ sdk = Version.parse(_sdk_version())
595
+ issues: list[HealthIssue] = []
596
+ if manifest.compatibility.min_sdk and sdk < manifest.compatibility.min_sdk:
597
+ issues.append(
598
+ HealthIssue(
599
+ severity="error",
600
+ message=(f"requires forgecli-sdk>={manifest.compatibility.min_sdk}, have {sdk}"),
601
+ suggestion="upgrade ForgeCLI",
602
+ )
603
+ )
604
+ if manifest.compatibility.max_sdk and sdk > manifest.compatibility.max_sdk:
605
+ issues.append(
606
+ HealthIssue(
607
+ severity="warn",
608
+ message=(
609
+ f"tested up to forgecli-sdk<={manifest.compatibility.max_sdk}, have {sdk}"
610
+ ),
611
+ )
612
+ )
613
+ if (
614
+ manifest.compatibility.os_targets
615
+ and current_platform().os.value not in manifest.compatibility.os_targets
616
+ ):
617
+ issues.append(
618
+ HealthIssue(
619
+ severity="warn",
620
+ message=(
621
+ f"targets {manifest.compatibility.os_targets}, "
622
+ f"current is {current_platform().os.value}"
623
+ ),
624
+ )
625
+ )
626
+ return issues
627
+
628
+
629
+ def _check_dependencies(manifest: PluginManifest) -> list[HealthIssue]:
630
+ issues: list[HealthIssue] = []
631
+ for req in manifest.dependencies:
632
+ if req.name == "python":
633
+ try:
634
+ if not req.matches(Version.parse(python_version())):
635
+ issues.append(
636
+ HealthIssue(
637
+ severity="error",
638
+ message=f"python {req} but have {python_version()}",
639
+ )
640
+ )
641
+ except VersionParseError:
642
+ pass
643
+ continue
644
+ # External plugin / library — best-effort probe.
645
+ if not _sdk_version_known(req.name):
646
+ issues.append(
647
+ HealthIssue(
648
+ severity="info",
649
+ message=f"depends on {req} (not validated locally)",
650
+ )
651
+ )
652
+ return issues
653
+
654
+
655
+ def _sdk_version() -> str:
656
+ from forgecli import __version__ as v
657
+
658
+ return v
659
+
660
+
661
+ def _sdk_version_known(_name: str) -> bool:
662
+ return False
663
+
664
+
665
+ __all__ = [
666
+ "Compatibility",
667
+ "PluginAlreadyInstalledError",
668
+ "PluginCompatibilityError",
669
+ "PluginError",
670
+ "PluginManager",
671
+ "PluginNotFoundError",
672
+ "PluginRegistryState",
673
+ "PluginState",
674
+ ]
675
+
676
+
677
+ # Silence the unused-import warnings for symbols only used in some
678
+ # branches of the public surface.