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,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)
|
forgecli/sdk/__init__.py
ADDED
|
@@ -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
|
+
]
|