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,199 @@
1
+ """Fast repository context for AI wrapper commands — no full codebase indexing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from forgecli.optimizer.optimizer import ContextOptimizer
10
+ from forgecli.optimizer.ponytail import Intensity, PonytailRulesetOptimizer
11
+ from forgecli.providers.base import ChatMessage, ChatRequest, Role
12
+ from forgecli.runtime.cache_store import (
13
+ CachedRuntime,
14
+ load_runtime_cache,
15
+ repo_fingerprint,
16
+ save_runtime_cache,
17
+ )
18
+ from forgecli.utils.paths import ProjectPaths
19
+
20
+ _SKIP_DIRS = {
21
+ ".git",
22
+ ".venv",
23
+ "__pycache__",
24
+ "node_modules",
25
+ "dist",
26
+ "build",
27
+ ".forge",
28
+ "graphify-out",
29
+ ".mypy_cache",
30
+ ".pytest_cache",
31
+ ".ruff_cache",
32
+ }
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PreparedRuntime:
37
+ """Optimized repository context ready for a wrapper CLI."""
38
+
39
+ root: Path
40
+ context_summary: str
41
+ context_file: Path
42
+ from_cache: bool
43
+
44
+ @classmethod
45
+ def from_cached(cls, root: Path, cached: CachedRuntime) -> PreparedRuntime:
46
+ return cls(
47
+ root=root,
48
+ context_summary=cached.context_summary,
49
+ context_file=Path(cached.context_file),
50
+ from_cache=True,
51
+ )
52
+
53
+
54
+ def resolve_repo_root(start: Path) -> Path:
55
+ """Return the nearest git repository root, or ``start`` when not in a repo."""
56
+ start = start.resolve()
57
+ for candidate in (start, *start.parents):
58
+ if (candidate / ".git").exists():
59
+ return candidate
60
+ return start
61
+
62
+
63
+ def _git_line(root: Path) -> str | None:
64
+ from forgecli.platform.shell import run
65
+
66
+ branch = run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
67
+ if branch.ok and branch.stdout.strip():
68
+ return f"Branch: {branch.stdout.strip()}"
69
+ return None
70
+
71
+
72
+ def _scan_repo_light(root: Path) -> str:
73
+ """Build a small repo snapshot without indexing the full codebase."""
74
+ lines = [
75
+ f"Repository: {root.name}",
76
+ f"Root: {root}",
77
+ ]
78
+ git_info = _git_line(root)
79
+ if git_info:
80
+ lines.append(git_info)
81
+
82
+ layout: list[str] = []
83
+ try:
84
+ for entry in sorted(root.iterdir(), key=lambda p: p.name.lower())[:36]:
85
+ if entry.name in _SKIP_DIRS or entry.name.startswith("."):
86
+ continue
87
+ if entry.is_dir():
88
+ layout.append(f"{entry.name}/")
89
+ try:
90
+ for child in sorted(entry.iterdir(), key=lambda p: p.name.lower())[:6]:
91
+ if child.name in _SKIP_DIRS or child.name.startswith("."):
92
+ continue
93
+ suffix = "/" if child.is_dir() else ""
94
+ layout.append(f" {child.name}{suffix}")
95
+ except OSError:
96
+ pass
97
+ else:
98
+ layout.append(entry.name)
99
+ except OSError:
100
+ pass
101
+
102
+ if layout:
103
+ lines.extend(["", "Project layout (shallow scan):", *layout[:72]])
104
+
105
+ graph_marker = root / "graphify-out" / "graph.json"
106
+ if graph_marker.is_file():
107
+ lines.append("")
108
+ lines.append(
109
+ "Knowledge graph already on disk at graphify-out/. "
110
+ "Run `forge graph build` separately when you want a full refresh."
111
+ )
112
+
113
+ return "\n".join(lines)
114
+
115
+
116
+ def _optimize_prompt(context: str) -> str:
117
+ import asyncio
118
+
119
+ optimizer = PonytailRulesetOptimizer(intensity=Intensity.LITE)
120
+ request = ChatRequest(
121
+ model="runtime",
122
+ messages=[
123
+ ChatMessage(
124
+ role=Role.SYSTEM,
125
+ content="Prepare concise repository context for an AI coding assistant.",
126
+ ),
127
+ ChatMessage(
128
+ role=Role.USER,
129
+ content=(
130
+ "Optimize this repo snapshot for the next coding task. "
131
+ "Prefer reuse, minimal diffs, and existing patterns.\n\n"
132
+ f"{context}"
133
+ ),
134
+ ),
135
+ ],
136
+ )
137
+ optimized = asyncio.run(optimizer.optimize_chat(request))
138
+ user = next(
139
+ (message.content for message in optimized.request.messages if message.role is Role.USER),
140
+ context,
141
+ )
142
+ return user.strip() or context
143
+
144
+
145
+ def _optimize_tokens(text: str) -> str:
146
+ optimizer = ContextOptimizer(max_context_tokens=8_000)
147
+ result = optimizer.optimize(text)
148
+ if not result.chunks:
149
+ return text[:12_000]
150
+ merged = "\n".join(chunk.text for chunk in result.chunks[:24])
151
+ return merged[:12_000]
152
+
153
+
154
+ def prepare_runtime_sync(
155
+ start: Path,
156
+ *,
157
+ force: bool = False,
158
+ quiet: bool = False,
159
+ ) -> PreparedRuntime:
160
+ """Prepare lightweight optimized context — no full graph build."""
161
+ root = resolve_repo_root(start)
162
+ fingerprint = repo_fingerprint(root)
163
+
164
+ if not force:
165
+ cached = load_runtime_cache(fingerprint)
166
+ if cached is not None:
167
+ return PreparedRuntime.from_cached(root, cached)
168
+
169
+ raw_context = _scan_repo_light(root)
170
+ prompt_optimized = _optimize_prompt(raw_context)
171
+ token_optimized = _optimize_tokens(prompt_optimized)
172
+
173
+ cache_dir = ProjectPaths.from_env().cache_dir / "runtime" / "context"
174
+ cache_dir.mkdir(parents=True, exist_ok=True)
175
+ context_file = cache_dir / f"{fingerprint}.md"
176
+ context_file.write_text(token_optimized, encoding="utf-8")
177
+
178
+ save_runtime_cache(
179
+ fingerprint,
180
+ CachedRuntime(
181
+ context_summary=token_optimized,
182
+ context_file=str(context_file),
183
+ node_count=0,
184
+ edge_count=0,
185
+ created_at=time.time(),
186
+ ),
187
+ )
188
+
189
+ if not quiet:
190
+ from forgecli.cli.ui import info
191
+
192
+ info(f"Optimized context for [accent]{root.name}[/accent] (cached for reuse)")
193
+
194
+ return PreparedRuntime(
195
+ root=root,
196
+ context_summary=token_optimized,
197
+ context_file=context_file,
198
+ from_cache=False,
199
+ )
@@ -0,0 +1,152 @@
1
+ """Launch Claude Code, Codex, Cursor, OpenCode, and CommandCode CLI with Forge-optimized context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+ from forgecli.cli.commands_graph import setup_graphify_credentials
14
+ from forgecli.cli.ui import error, get_console, info
15
+ from forgecli.graph.backend_graphify import GraphifyRepositoryGraph
16
+ from forgecli.platform.shell import which
17
+ from forgecli.runtime.prepare import prepare_runtime_sync, resolve_repo_root
18
+ from forgecli.utils.fs import has_supported_source_files
19
+
20
+ _SKIP_DIRS = {
21
+ ".git",
22
+ ".venv",
23
+ "__pycache__",
24
+ "node_modules",
25
+ "dist",
26
+ "build",
27
+ ".forge",
28
+ "graphify-out",
29
+ ".mypy_cache",
30
+ ".pytest_cache",
31
+ ".ruff_cache",
32
+ }
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class WrapperSpec:
37
+ name: str
38
+ binary: str
39
+ install_hint: str
40
+
41
+
42
+ WRAPPERS: dict[str, WrapperSpec] = {
43
+ "claude": WrapperSpec(
44
+ name="Claude Code",
45
+ binary="claude",
46
+ install_hint="Install Claude Code: https://docs.anthropic.com/en/docs/claude-code",
47
+ ),
48
+ "codex": WrapperSpec(
49
+ name="Codex CLI",
50
+ binary="codex",
51
+ install_hint="Install OpenAI Codex CLI: https://developers.openai.com/codex/cli/",
52
+ ),
53
+ "cursor": WrapperSpec(
54
+ name="Cursor CLI",
55
+ binary="cursor",
56
+ install_hint="Install Cursor CLI: https://cursor.com/docs/cli/overview",
57
+ ),
58
+ "opencode": WrapperSpec(
59
+ name="OpenCode CLI",
60
+ binary="opencode",
61
+ install_hint="Install OpenCode CLI: https://opencode.ai/",
62
+ ),
63
+ "commandcode": WrapperSpec(
64
+ name="CommandCode CLI",
65
+ binary="commandcode",
66
+ install_hint="Install CommandCode CLI: https://commandcode.ai/",
67
+ ),
68
+ }
69
+
70
+
71
+ def launch_wrapper(
72
+ wrapper_id: str,
73
+ extra_args: list[str] | None = None,
74
+ *,
75
+ path: Path | None = None,
76
+ force_prepare: bool = False,
77
+ ) -> None:
78
+ """Prepare lightweight context, build/update graph, and launch the selected AI CLI."""
79
+ spec = WRAPPERS.get(wrapper_id)
80
+ if spec is None:
81
+ error(f"Unknown wrapper: {wrapper_id}")
82
+ raise typer.Exit(code=1)
83
+
84
+ binary_path = which(spec.binary)
85
+ if binary_path is None and wrapper_id == "commandcode":
86
+ # Check alternative binary names for commandcode
87
+ binary_path = which("command-code")
88
+
89
+ if binary_path is None:
90
+ console = get_console()
91
+ console.print(f"[red]✗[/red] {spec.name} is not installed.")
92
+ console.print(spec.install_hint)
93
+ raise typer.Exit(code=1)
94
+
95
+ # 1. Detect current repository
96
+ cwd = (path or Path.cwd()).resolve()
97
+ repo_root = resolve_repo_root(cwd)
98
+
99
+ # 2. Build or update the repository knowledge graph
100
+ backend = GraphifyRepositoryGraph(root=repo_root)
101
+ if asyncio.run(backend.is_available()) and has_supported_source_files(repo_root):
102
+ active_provider = setup_graphify_credentials(repo_root)
103
+ if not active_provider:
104
+ error(
105
+ "No API key configured. Run 'forge auth login' or export "
106
+ "OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY before running 'forge graph build'."
107
+ )
108
+ raise typer.Exit(code=1)
109
+
110
+ graph_json = backend.artifacts.graph_json
111
+ needs_build = not graph_json.exists()
112
+ needs_update = False
113
+
114
+ if graph_json.exists() and not force_prepare:
115
+ graph_mtime = graph_json.stat().st_mtime
116
+ for p in repo_root.rglob('*'):
117
+ try:
118
+ parts = p.relative_to(repo_root).parts
119
+ if any(part.startswith('.') or part in _SKIP_DIRS for part in parts[:-1]):
120
+ continue
121
+ if p.is_file() and not p.name.startswith('.') and p.stat().st_mtime > graph_mtime:
122
+ needs_update = True
123
+ break
124
+ except ValueError:
125
+ continue
126
+
127
+ if force_prepare or needs_build:
128
+ info("Building repository knowledge graph...")
129
+ asyncio.run(backend.build(force=force_prepare))
130
+ elif needs_update:
131
+ info("Updating repository knowledge graph...")
132
+ asyncio.run(backend.update_graph())
133
+
134
+ # 3-6. Optimize context, prompts, and tokens (aggressively reusing cache)
135
+ prepared = prepare_runtime_sync(repo_root, force=force_prepare, quiet=False)
136
+ if prepared.from_cache:
137
+ info("Reusing cached Forge context.")
138
+
139
+ env = os.environ.copy()
140
+ env["FORGE_CONTEXT"] = prepared.context_summary
141
+ env["FORGE_CONTEXT_FILE"] = str(prepared.context_file)
142
+ env["FORGE_REPO_ROOT"] = str(prepared.root)
143
+
144
+ argv = [binary_path, *(extra_args or [])]
145
+ info(f"Launching [accent]{spec.name}[/accent] ...")
146
+
147
+ completed = subprocess.run(
148
+ argv,
149
+ cwd=str(prepared.root),
150
+ env=env,
151
+ )
152
+ raise typer.Exit(code=completed.returncode)
@@ -0,0 +1,132 @@
1
+ """The ForgeCLI Plugin SDK.
2
+
3
+ The SDK is the *contract* third-party developers use to extend
4
+ ForgeCLI. It provides:
5
+
6
+ * :mod:`forgecli.sdk.manifest` — typed plugin metadata (TOML).
7
+ * :mod:`forgecli.sdk.version` — semver parsing + dep resolution.
8
+ * :mod:`forgecli.sdk.loader` — discovery (filesystem + entry-point).
9
+ * :mod:`forgecli.sdk.events` — pub/sub bus + hook manager.
10
+ * :mod:`forgecli.sdk.sandbox` — restricted-exec for plugin callbacks.
11
+ * :mod:`forgecli.sdk.interfaces` — the 10 canonical plugin types.
12
+ * :mod:`forgecli.sdk.manager` — the :class:`PluginManager` façade
13
+ (install / enable / disable / uninstall / update / configure).
14
+
15
+ See :file:`PLUGINS.md` for the end-to-end developer guide.
16
+ """
17
+
18
+ from forgecli.sdk.events import (
19
+ HookManager,
20
+ PluginEvent,
21
+ PluginEventBus,
22
+ PluginEventKind,
23
+ PluginHook,
24
+ )
25
+ from forgecli.sdk.interfaces import (
26
+ AIProviderPlugin,
27
+ CodeGeneratorPlugin,
28
+ ContextOptimizerPlugin,
29
+ DeploymentProviderPlugin,
30
+ DocumentationGeneratorPlugin,
31
+ GitProviderPlugin,
32
+ HealthIssue,
33
+ HealthReport,
34
+ NotificationProviderPlugin,
35
+ ObservabilityProviderPlugin,
36
+ PluginConfigurable,
37
+ PluginHealthCheck,
38
+ RepositoryAnalyzerPlugin,
39
+ TestRunnerPlugin,
40
+ )
41
+ from forgecli.sdk.loader import (
42
+ LoadedPlugin,
43
+ default_plugins_dir,
44
+ discover_entry_points,
45
+ discover_filesystem,
46
+ load_filesystem,
47
+ )
48
+ from forgecli.sdk.loader import (
49
+ PluginManifestNotFoundError as PluginManifestNotFound,
50
+ )
51
+ from forgecli.sdk.manager import (
52
+ PluginAlreadyInstalledError,
53
+ PluginCompatibilityError,
54
+ PluginError,
55
+ PluginManager,
56
+ PluginNotFoundError,
57
+ PluginRegistryState,
58
+ PluginState,
59
+ )
60
+ from forgecli.sdk.manifest import (
61
+ Compatibility,
62
+ EntryPoint,
63
+ EntryPointKind,
64
+ Permission,
65
+ PluginManifest,
66
+ is_valid_plugin_name,
67
+ )
68
+ from forgecli.sdk.sandbox import Sandbox, ScopedBuiltins, run_sandboxed, sandbox
69
+ from forgecli.sdk.version import (
70
+ DependencyCycleError,
71
+ Op,
72
+ Requirement,
73
+ Spec,
74
+ UnsatisfiableRequirementError,
75
+ Version,
76
+ VersionParseError,
77
+ resolve,
78
+ )
79
+
80
+ __all__ = [
81
+ "AIProviderPlugin",
82
+ "CodeGeneratorPlugin",
83
+ "Compatibility",
84
+ "ContextOptimizerPlugin",
85
+ "DependencyCycleError",
86
+ "DeploymentProviderPlugin",
87
+ "DocumentationGeneratorPlugin",
88
+ "EntryPoint",
89
+ "EntryPointKind",
90
+ "GitProviderPlugin",
91
+ "HealthIssue",
92
+ "HealthReport",
93
+ "HookManager",
94
+ "LoadedPlugin",
95
+ "NotificationProviderPlugin",
96
+ "ObservabilityProviderPlugin",
97
+ "Op",
98
+ "Permission",
99
+ "PluginAlreadyInstalledError",
100
+ "PluginCompatibilityError",
101
+ "PluginConfigurable",
102
+ "PluginError",
103
+ "PluginEvent",
104
+ "PluginEventBus",
105
+ "PluginEventKind",
106
+ "PluginHealthCheck",
107
+ "PluginHook",
108
+ "PluginManager",
109
+ "PluginManifest",
110
+ "PluginManifestNotFound",
111
+ "PluginManifestNotFoundError",
112
+ "PluginNotFoundError",
113
+ "PluginRegistryState",
114
+ "PluginState",
115
+ "RepositoryAnalyzerPlugin",
116
+ "Requirement",
117
+ "Sandbox",
118
+ "ScopedBuiltins",
119
+ "Spec",
120
+ "TestRunnerPlugin",
121
+ "UnsatisfiableRequirementError",
122
+ "Version",
123
+ "VersionParseError",
124
+ "default_plugins_dir",
125
+ "discover_entry_points",
126
+ "discover_filesystem",
127
+ "is_valid_plugin_name",
128
+ "load_filesystem",
129
+ "resolve",
130
+ "run_sandboxed",
131
+ "sandbox",
132
+ ]
forgecli/sdk/events.py ADDED
@@ -0,0 +1,206 @@
1
+ """Plugin event bus + hook manager.
2
+
3
+ The SDK exposes:
4
+
5
+ * :class:`PluginEventBus` — a tiny pub/sub bus for plugin
6
+ lifecycle and runtime events. Distinct from the engine's
7
+ :class:`~forgecli.engine.events.EventBus` (which is for the
8
+ eight-stage pipeline). The two are independent so plugins can
9
+ listen to the engine *and* the plugin lifecycle without coupling.
10
+ * :class:`PluginHook` and :class:`HookManager` — synchronous
11
+ before/after hooks the SDK fires when a plugin is installed,
12
+ enabled, disabled, updated, or uninstalled.
13
+
14
+ Plugins subscribe at enable-time; the SDK guarantees that the
15
+ event handlers are called for every registered subscriber, even
16
+ if one raises (failures are logged and isolated).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import contextlib
23
+ import logging
24
+ from collections.abc import Awaitable, Callable
25
+ from dataclasses import dataclass, field
26
+ from datetime import UTC, datetime
27
+ from enum import Enum
28
+ from typing import Any
29
+
30
+ _logger = logging.getLogger("forgecli.sdk.events")
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Event types
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ class PluginEventKind(str, Enum):
39
+ """The high-level events plugins can listen to."""
40
+
41
+ INSTALLED = "installed"
42
+ ENABLED = "enabled"
43
+ DISABLED = "disabled"
44
+ UNINSTALLED = "uninstalled"
45
+ UPDATED = "updated"
46
+ BEFORE_COMMAND = "before_command"
47
+ AFTER_COMMAND = "after_command"
48
+ CONFIG_CHANGED = "config_changed"
49
+ ERROR = "error"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class PluginEvent:
54
+ """A single lifecycle / runtime event."""
55
+
56
+ kind: PluginEventKind
57
+ plugin_name: str | None = None
58
+ payload: dict[str, Any] = field(default_factory=dict)
59
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
60
+
61
+
62
+ EventHandler = Callable[[PluginEvent], "None | Awaitable[None]"]
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Event bus
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ class PluginEventBus:
71
+ """In-process pub/sub bus for plugin events.
72
+
73
+ Async-aware: subscribers may be sync or async. The bus
74
+ serialises fan-out for synchronous subscribers and schedules
75
+ coroutines for async ones.
76
+ """
77
+
78
+ def __init__(self) -> None:
79
+ self._subscribers: dict[PluginEventKind, list[EventHandler]] = {}
80
+
81
+ def subscribe(self, kind: PluginEventKind, handler: EventHandler) -> None:
82
+ self._subscribers.setdefault(kind, []).append(handler)
83
+
84
+ def unsubscribe(self, kind: PluginEventKind, handler: EventHandler) -> None:
85
+ bucket = self._subscribers.get(kind)
86
+ if not bucket:
87
+ return
88
+ with _suppress(ValueError):
89
+ bucket.remove(handler)
90
+
91
+ def publish(self, event: PluginEvent) -> None:
92
+ """Publish synchronously. Async handlers are scheduled."""
93
+ for handler in list(self._subscribers.get(event.kind, ())):
94
+ try:
95
+ result = handler(event)
96
+ except Exception:
97
+ _logger.exception("plugin event handler raised")
98
+ continue
99
+ if asyncio.iscoroutine(result):
100
+ try:
101
+ loop = asyncio.get_running_loop()
102
+ task = loop.create_task(result)
103
+ task.add_done_callback(_discard_task)
104
+ except RuntimeError:
105
+ pass
106
+
107
+ async def publish_and_drain(self, event: PluginEvent) -> None:
108
+ """Publish and await any async handler results."""
109
+ for handler in list(self._subscribers.get(event.kind, ())):
110
+ try:
111
+ result = handler(event)
112
+ except Exception:
113
+ _logger.exception("plugin event handler raised")
114
+ continue
115
+ if asyncio.iscoroutine(result):
116
+ await result
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Hook manager
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class PluginHook:
126
+ """A single registered hook.
127
+
128
+ ``callback`` may be sync or async. The SDK wraps it in
129
+ :func:`asyncio.iscoroutine` before calling so plugins can
130
+ return a coroutine for async work.
131
+ """
132
+
133
+ name: str
134
+ callback: Callable[..., Any]
135
+
136
+
137
+ class HookManager:
138
+ """Synchronous registry of before/after hooks."""
139
+
140
+ def __init__(self) -> None:
141
+ self._before: list[PluginHook] = []
142
+ self._after: list[PluginHook] = []
143
+
144
+ def before(self, hook: PluginHook) -> None:
145
+ self._before.append(hook)
146
+
147
+ def after(self, hook: PluginHook) -> None:
148
+ self._after.append(hook)
149
+
150
+ def fire_before(self, *args: Any, **kwargs: Any) -> None:
151
+ for hook in self._before:
152
+ try:
153
+ hook.callback(*args, **kwargs)
154
+ except Exception:
155
+ _logger.exception("hook %s failed (before)", hook.name)
156
+
157
+ def fire_after(self, *args: Any, **kwargs: Any) -> None:
158
+ for hook in self._after:
159
+ try:
160
+ hook.callback(*args, **kwargs)
161
+ except Exception:
162
+ _logger.exception("hook %s failed (after)", hook.name)
163
+
164
+ async def fire_before_async(self, *args: Any, **kwargs: Any) -> None:
165
+ for hook in self._before:
166
+ try:
167
+ result = hook.callback(*args, **kwargs)
168
+ if asyncio.iscoroutine(result):
169
+ await result
170
+ except Exception:
171
+ _logger.exception("hook %s failed (before)", hook.name)
172
+
173
+ async def fire_after_async(self, *args: Any, **kwargs: Any) -> None:
174
+ for hook in self._after:
175
+ try:
176
+ result = hook.callback(*args, **kwargs)
177
+ if asyncio.iscoroutine(result):
178
+ await result
179
+ except Exception:
180
+ _logger.exception("hook %s failed (after)", hook.name)
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Helpers
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ @contextlib.contextmanager
189
+ def _suppress(*exceptions: type[BaseException]):
190
+ with contextlib.suppress(*exceptions):
191
+ yield
192
+
193
+
194
+ def _discard_task(task: asyncio.Task) -> None:
195
+ """Keep a strong reference to a scheduled task until it completes."""
196
+ return None
197
+
198
+
199
+ __all__ = [
200
+ "EventHandler",
201
+ "HookManager",
202
+ "PluginEvent",
203
+ "PluginEventBus",
204
+ "PluginEventKind",
205
+ "PluginHook",
206
+ ]