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
forgecli/sdk/version.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Version parsing + comparison + dependency resolution.
|
|
2
|
+
|
|
3
|
+
The SDK uses a pragmatic PEP-440-ish semver triple with optional
|
|
4
|
+
pre-release and build-metadata tags. Compatibility resolution uses
|
|
5
|
+
the same algorithm pip uses for ``~=`` and ``>=``:
|
|
6
|
+
|
|
7
|
+
* ``~=X.Y`` — ``>=X.Y, <(X+1)``
|
|
8
|
+
* ``~=X.Y.Z`` — ``>=X.Y.Z, <X.(Y+1)``
|
|
9
|
+
* ``X.Y.Z`` — exact match
|
|
10
|
+
* ``X.Y`` — any patch of X.Y
|
|
11
|
+
* ``>=X.Y.Z`` — at least X.Y.Z
|
|
12
|
+
* ``>X.Y.Z`` — strictly greater
|
|
13
|
+
* ``*`` — wildcard
|
|
14
|
+
|
|
15
|
+
Missing version specifier means "any version".
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Iterable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Version parsing
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class VersionParseError(ValueError):
|
|
32
|
+
"""Raised when a version string is malformed."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, order=True)
|
|
36
|
+
class Version:
|
|
37
|
+
"""A parsed semantic-style version."""
|
|
38
|
+
|
|
39
|
+
major: int
|
|
40
|
+
minor: int
|
|
41
|
+
patch: int
|
|
42
|
+
pre: tuple[tuple[str, int], ...] = ()
|
|
43
|
+
build: str = ""
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def parse(cls, value: str) -> Version:
|
|
47
|
+
if not value or not isinstance(value, str):
|
|
48
|
+
raise VersionParseError(f"invalid version: {value!r}")
|
|
49
|
+
# Accept both ``1.2.3a1`` (PEP-440-ish) and ``1.2.3-a1``
|
|
50
|
+
# (semver-style). We normalise to the second form so the
|
|
51
|
+
# pre-release parsing below sees a single canonical shape.
|
|
52
|
+
normalised = value.strip()
|
|
53
|
+
m = re.match(
|
|
54
|
+
r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?"
|
|
55
|
+
r"(?:(-[0-9A-Za-z-.]+)|([0-9]+[A-Za-z]+[0-9A-Za-z-]*))?"
|
|
56
|
+
r"(?:\+([0-9A-Za-z-.]+))?$",
|
|
57
|
+
normalised,
|
|
58
|
+
)
|
|
59
|
+
if not m:
|
|
60
|
+
raise VersionParseError(f"invalid version: {value!r}")
|
|
61
|
+
major, minor, patch, dash_pre, no_dash_pre, build = m.groups()
|
|
62
|
+
pre_tuple: tuple[tuple[str, int], ...] = ()
|
|
63
|
+
# ``dash_pre`` is the canonical ``-foo.bar`` form. ``no_dash_pre``
|
|
64
|
+
# is the bare ``1.2.3a1`` form; we prefix a dash so the
|
|
65
|
+
# downstream splitter sees a single shape.
|
|
66
|
+
pre_str = dash_pre or ("-" + no_dash_pre if no_dash_pre else "")
|
|
67
|
+
if pre_str:
|
|
68
|
+
for chunk in pre_str.lstrip("-").split("."):
|
|
69
|
+
match = re.match(r"^([0-9A-Za-z]+)(\d*)$", chunk)
|
|
70
|
+
if not match:
|
|
71
|
+
raise VersionParseError(f"invalid pre-release: {pre_str!r}")
|
|
72
|
+
ident, num = match.groups()
|
|
73
|
+
pre_tuple = (*pre_tuple, (ident, int(num) if num else -1))
|
|
74
|
+
return cls(
|
|
75
|
+
major=int(major),
|
|
76
|
+
minor=int(minor or 0),
|
|
77
|
+
patch=int(patch or 0),
|
|
78
|
+
pre=pre_tuple,
|
|
79
|
+
build=build or "",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def __str__(self) -> str:
|
|
83
|
+
base = f"{self.major}.{self.minor}.{self.patch}"
|
|
84
|
+
if self.pre:
|
|
85
|
+
base += "-" + ".".join(
|
|
86
|
+
f"{ident}{num}" if num >= 0 else ident
|
|
87
|
+
for ident, num in self.pre
|
|
88
|
+
)
|
|
89
|
+
if self.build:
|
|
90
|
+
base += f"+{self.build}"
|
|
91
|
+
return base
|
|
92
|
+
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
return f"Version({self})"
|
|
95
|
+
|
|
96
|
+
def is_prerelease(self) -> bool:
|
|
97
|
+
return bool(self.pre)
|
|
98
|
+
|
|
99
|
+
def without_prerelease(self) -> Version:
|
|
100
|
+
return Version(self.major, self.minor, self.patch)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Spec parsing
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Op(str, Enum):
|
|
109
|
+
EQ = "=="
|
|
110
|
+
NEQ = "!="
|
|
111
|
+
GT = ">"
|
|
112
|
+
GE = ">="
|
|
113
|
+
LT = "<"
|
|
114
|
+
LE = "<="
|
|
115
|
+
CARET = "^" # NOT USED; kept for documentation
|
|
116
|
+
TILDE = "~="
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Spec:
|
|
121
|
+
"""A single dependency specifier like ``>=1.2,<2.0``."""
|
|
122
|
+
|
|
123
|
+
op: Op
|
|
124
|
+
version: Version
|
|
125
|
+
|
|
126
|
+
def matches(self, other: Version) -> bool:
|
|
127
|
+
if self.op is Op.EQ:
|
|
128
|
+
return other == self.version
|
|
129
|
+
if self.op is Op.NEQ:
|
|
130
|
+
return other != self.version
|
|
131
|
+
if self.op is Op.GT:
|
|
132
|
+
return other > self.version
|
|
133
|
+
if self.op is Op.GE:
|
|
134
|
+
return other >= self.version
|
|
135
|
+
if self.op is Op.LT:
|
|
136
|
+
return other < self.version
|
|
137
|
+
if self.op is Op.LE:
|
|
138
|
+
return other <= self.version
|
|
139
|
+
if self.op is Op.TILDE:
|
|
140
|
+
return other >= self.version
|
|
141
|
+
raise ValueError(f"unsupported op: {self.op}")
|
|
142
|
+
|
|
143
|
+
def __str__(self) -> str:
|
|
144
|
+
return f"{self.op}{self.version}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# A spec string like ``"^1.0 ; extras: graphify"`` is a comma-separated
|
|
148
|
+
# set of Spec clauses plus an optional ``;extras:...`` annotation.
|
|
149
|
+
_SPEC_SEP = ";"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class Requirement:
|
|
154
|
+
"""A full requirement: name + set of version specs + optional extras."""
|
|
155
|
+
|
|
156
|
+
name: str
|
|
157
|
+
specs: tuple[Any, ...] = ()
|
|
158
|
+
extras: tuple[str, ...] = ()
|
|
159
|
+
|
|
160
|
+
def matches(self, version: Version) -> bool:
|
|
161
|
+
if not self.specs:
|
|
162
|
+
return True
|
|
163
|
+
return all(spec.matches(version) for spec in self.specs)
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def parse(cls, name: str, spec_string: str = "") -> Requirement:
|
|
167
|
+
if not name:
|
|
168
|
+
raise ValueError("requirement name must be non-empty")
|
|
169
|
+
specs: list[Any] = []
|
|
170
|
+
extras: tuple[str, ...] = ()
|
|
171
|
+
body, sep, tail = spec_string.partition(_SPEC_SEP)
|
|
172
|
+
if sep:
|
|
173
|
+
extras_str = tail.strip()
|
|
174
|
+
if extras_str.startswith("extras:"):
|
|
175
|
+
extras = tuple(
|
|
176
|
+
chunk.strip()
|
|
177
|
+
for chunk in extras_str[len("extras:") :].split(",")
|
|
178
|
+
if chunk.strip()
|
|
179
|
+
)
|
|
180
|
+
for raw in body.split(","):
|
|
181
|
+
raw = raw.strip()
|
|
182
|
+
if not raw or raw == "*":
|
|
183
|
+
continue
|
|
184
|
+
for op_token in ("==", "!=", ">=", "<=", "~=", ">", "<"):
|
|
185
|
+
if raw.startswith(op_token):
|
|
186
|
+
version_str = raw[len(op_token) :].strip()
|
|
187
|
+
if op_token == "~=":
|
|
188
|
+
specs.append(_tilde(version_str))
|
|
189
|
+
else:
|
|
190
|
+
try:
|
|
191
|
+
specs.append(Spec(Op(op_token), Version.parse(version_str)))
|
|
192
|
+
except VersionParseError:
|
|
193
|
+
continue
|
|
194
|
+
break
|
|
195
|
+
else:
|
|
196
|
+
# Bare version string == exact match.
|
|
197
|
+
try:
|
|
198
|
+
specs.append(Spec(Op.EQ, Version.parse(raw)))
|
|
199
|
+
except VersionParseError:
|
|
200
|
+
continue
|
|
201
|
+
return cls(name=name, specs=tuple(specs), extras=tuple(extras))
|
|
202
|
+
|
|
203
|
+
def __str__(self) -> str:
|
|
204
|
+
base = self.name
|
|
205
|
+
if self.specs:
|
|
206
|
+
base += " " + ",".join(str(s) for s in self.specs)
|
|
207
|
+
if self.extras:
|
|
208
|
+
base += f" ; extras: {','.join(self.extras)}"
|
|
209
|
+
return base
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _tilde(version_str: str) -> Any:
|
|
213
|
+
"""Implement the ``~=X.Y`` and ``~=X.Y.Z`` operators."""
|
|
214
|
+
parts = version_str.split(".")
|
|
215
|
+
if len(parts) == 1:
|
|
216
|
+
# ~=2 is invalid; treat as ==
|
|
217
|
+
return Spec(Op.EQ, Version.parse(version_str))
|
|
218
|
+
if len(parts) == 2:
|
|
219
|
+
major, minor = parts
|
|
220
|
+
low = Version.parse(version_str)
|
|
221
|
+
high = Version.parse(f"{int(major) + 1}.0")
|
|
222
|
+
else:
|
|
223
|
+
major, minor, _patch = parts
|
|
224
|
+
low = Version.parse(version_str)
|
|
225
|
+
high = Version.parse(f"{major}.{int(minor) + 1}")
|
|
226
|
+
# ~="X.Y" means ">=X.Y, <(X+1).0"; the high is exclusive.
|
|
227
|
+
# We return a synthetic GE spec; the LT spec is folded in below.
|
|
228
|
+
return _TildeSpec(ge=low, lt=high)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class _TildeSpec:
|
|
233
|
+
ge: Version
|
|
234
|
+
lt: Version
|
|
235
|
+
|
|
236
|
+
def matches(self, other: Version) -> bool:
|
|
237
|
+
return self.ge <= other < self.lt
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# Replace the simple Spec with one that also supports tilde.
|
|
241
|
+
_Spec = Spec # type alias retained for clarity; not used below
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Dependency resolution
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class DependencyCycleError(RuntimeError):
|
|
250
|
+
"""Raised when dependency resolution hits a cycle."""
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class UnsatisfiableRequirementError(RuntimeError):
|
|
254
|
+
"""Raised when two requirements for the same name disagree."""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def resolve(
|
|
258
|
+
requirements: Iterable[Requirement],
|
|
259
|
+
candidates: dict[str, tuple[Version, ...]],
|
|
260
|
+
) -> dict[str, Version]:
|
|
261
|
+
"""Pick a single version for every named requirement.
|
|
262
|
+
|
|
263
|
+
``requirements`` is the union of constraints (multiple
|
|
264
|
+
:class:`Requirement` objects with the same name are intersected).
|
|
265
|
+
``candidates`` maps a name to the available versions on PyPI /
|
|
266
|
+
a local index / etc. The resolver picks the *highest* version
|
|
267
|
+
that satisfies every constraint, and raises on cycles or
|
|
268
|
+
contradictions.
|
|
269
|
+
"""
|
|
270
|
+
constraints: dict[str, list[Requirement]] = {}
|
|
271
|
+
for req in requirements:
|
|
272
|
+
constraints.setdefault(req.name, []).append(req)
|
|
273
|
+
|
|
274
|
+
chosen: dict[str, Version] = {}
|
|
275
|
+
while constraints:
|
|
276
|
+
progress = False
|
|
277
|
+
for name, reqs in list(constraints.items()):
|
|
278
|
+
if name in chosen:
|
|
279
|
+
continue
|
|
280
|
+
available = candidates.get(name, ())
|
|
281
|
+
for version in sorted(available, reverse=True):
|
|
282
|
+
if all(req.matches(version) for req in reqs):
|
|
283
|
+
chosen[name] = version
|
|
284
|
+
del constraints[name]
|
|
285
|
+
progress = True
|
|
286
|
+
break
|
|
287
|
+
else:
|
|
288
|
+
# Try partial: did we have any candidate at all?
|
|
289
|
+
if not available:
|
|
290
|
+
# Soft skip: caller may not have provided a local
|
|
291
|
+
# index for this plugin. We drop the constraint
|
|
292
|
+
# silently.
|
|
293
|
+
del constraints[name]
|
|
294
|
+
progress = True
|
|
295
|
+
continue
|
|
296
|
+
raise UnsatisfiableRequirementError(
|
|
297
|
+
f"no version of {name!r} satisfies {', '.join(map(str, reqs))}"
|
|
298
|
+
)
|
|
299
|
+
if not progress:
|
|
300
|
+
# Cycle or stuck.
|
|
301
|
+
raise DependencyCycleError(
|
|
302
|
+
f"could not resolve: {sorted(constraints)}"
|
|
303
|
+
)
|
|
304
|
+
return chosen
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
__all__ = [
|
|
308
|
+
"DependencyCycleError",
|
|
309
|
+
"Op",
|
|
310
|
+
"Requirement",
|
|
311
|
+
"Spec",
|
|
312
|
+
"UnsatisfiableRequirementError",
|
|
313
|
+
"Version",
|
|
314
|
+
"VersionParseError",
|
|
315
|
+
"resolve",
|
|
316
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Render reusable project/workflow templates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from forgecli.core.service import Service
|
|
9
|
+
from forgecli.utils.fs import atomic_write
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TemplateEngine(Service):
|
|
13
|
+
"""Render template strings and materialize them to disk."""
|
|
14
|
+
|
|
15
|
+
name = "templates.engine"
|
|
16
|
+
|
|
17
|
+
def __init__(self, *, renderer=None) -> None:
|
|
18
|
+
super().__init__()
|
|
19
|
+
# Lazy import to keep the templates module usable in tests.
|
|
20
|
+
from forgecli.prompts.renderer import PromptRenderer
|
|
21
|
+
|
|
22
|
+
self._renderer = renderer or PromptRenderer()
|
|
23
|
+
|
|
24
|
+
def render(self, template: str, **variables: object) -> str:
|
|
25
|
+
"""Render ``template`` with ``variables`` using the prompt renderer."""
|
|
26
|
+
return self._renderer.render(template, **variables)
|
|
27
|
+
|
|
28
|
+
def materialize(
|
|
29
|
+
self,
|
|
30
|
+
target: Path,
|
|
31
|
+
template: str,
|
|
32
|
+
*,
|
|
33
|
+
variables: Mapping[str, object] | None = None,
|
|
34
|
+
) -> Path:
|
|
35
|
+
"""Render ``template`` and atomically write it to ``target``."""
|
|
36
|
+
content = self.render(template, **(dict(variables) if variables else {}))
|
|
37
|
+
return atomic_write(target, content)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""In-memory registry of named templates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from forgecli.core.service import Service
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TemplateRegistry(Service):
|
|
11
|
+
"""Stores named templates; higher-level loaders can hydrate it from disk."""
|
|
12
|
+
|
|
13
|
+
name = "templates.registry"
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._templates: dict[str, str] = {}
|
|
18
|
+
|
|
19
|
+
def register(self, name: str, template: str) -> None:
|
|
20
|
+
self._templates[name] = template
|
|
21
|
+
|
|
22
|
+
def get(self, name: str) -> str:
|
|
23
|
+
try:
|
|
24
|
+
return self._templates[name]
|
|
25
|
+
except KeyError as exc:
|
|
26
|
+
raise KeyError(f"Template not registered: {name!r}") from exc
|
|
27
|
+
|
|
28
|
+
def names(self) -> list[str]:
|
|
29
|
+
return sorted(self._templates)
|
|
30
|
+
|
|
31
|
+
def items(self) -> Iterable[tuple[str, str]]:
|
|
32
|
+
return self._templates.items()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared utility helpers."""
|
|
2
|
+
|
|
3
|
+
from forgecli.utils.fs import atomic_write, ensure_dir, read_text, write_text
|
|
4
|
+
from forgecli.utils.ids import new_id
|
|
5
|
+
from forgecli.utils.io import aio_read_text, aio_write_text
|
|
6
|
+
from forgecli.utils.paths import ProjectPaths
|
|
7
|
+
from forgecli.utils.timing import Timer
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ProjectPaths",
|
|
11
|
+
"Timer",
|
|
12
|
+
"aio_read_text",
|
|
13
|
+
"aio_write_text",
|
|
14
|
+
"atomic_write",
|
|
15
|
+
"ensure_dir",
|
|
16
|
+
"new_id",
|
|
17
|
+
"read_text",
|
|
18
|
+
"write_text",
|
|
19
|
+
]
|
forgecli/utils/fs.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Filesystem helpers (sync)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ensure_dir(path: os.PathLike[str] | str) -> Path:
|
|
13
|
+
"""Create ``path`` (and parents) if missing; return the resolved Path."""
|
|
14
|
+
p = Path(path).expanduser()
|
|
15
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
return p
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_text(path: os.PathLike[str] | str, *, encoding: str = "utf-8") -> str:
|
|
20
|
+
"""Read text from ``path`` and return it as a string."""
|
|
21
|
+
return Path(path).read_text(encoding=encoding)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_text(
|
|
25
|
+
path: os.PathLike[str] | str,
|
|
26
|
+
content: str,
|
|
27
|
+
*,
|
|
28
|
+
encoding: str = "utf-8",
|
|
29
|
+
) -> Path:
|
|
30
|
+
"""Write ``content`` to ``path``, creating parent directories as needed."""
|
|
31
|
+
p = Path(path)
|
|
32
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
p.write_text(content, encoding=encoding)
|
|
34
|
+
return p
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def atomic_write(
|
|
38
|
+
path: os.PathLike[str] | str,
|
|
39
|
+
content: str,
|
|
40
|
+
*,
|
|
41
|
+
encoding: str = "utf-8",
|
|
42
|
+
) -> Path:
|
|
43
|
+
"""Atomically write ``content`` to ``path`` via a temporary file."""
|
|
44
|
+
target = Path(path)
|
|
45
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
47
|
+
prefix=target.name + ".",
|
|
48
|
+
dir=str(target.parent),
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
with os.fdopen(fd, "w", encoding=encoding) as fh:
|
|
52
|
+
fh.write(content)
|
|
53
|
+
fh.flush()
|
|
54
|
+
os.fsync(fh.fileno())
|
|
55
|
+
os.replace(tmp_name, target)
|
|
56
|
+
except Exception:
|
|
57
|
+
with contextlib.suppress(OSError):
|
|
58
|
+
os.unlink(tmp_name)
|
|
59
|
+
raise
|
|
60
|
+
return target
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def iter_files(root: os.PathLike[str] | str, patterns: Iterable[str]) -> Iterable[Path]:
|
|
64
|
+
"""Yield files under ``root`` matching any of the given glob ``patterns``."""
|
|
65
|
+
base = Path(root)
|
|
66
|
+
for pattern in patterns:
|
|
67
|
+
yield from base.glob(pattern)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def has_supported_source_files(path: Path) -> bool:
|
|
71
|
+
supported_exts = {
|
|
72
|
+
'.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs', '.c', '.cpp', '.cc', '.h', '.hpp',
|
|
73
|
+
'.java', '.rb', '.php', '.cs', '.html', '.css', '.sh', '.md', '.toml', '.json', '.yaml', '.yml'
|
|
74
|
+
}
|
|
75
|
+
ignored_dirs = {
|
|
76
|
+
'.git', '.venv', '__pycache__', 'node_modules', 'dist', 'build', '.forge',
|
|
77
|
+
'graphify-out', '.mypy_cache', '.pytest_cache', '.ruff_cache'
|
|
78
|
+
}
|
|
79
|
+
if not path.exists() or not path.is_dir():
|
|
80
|
+
return False
|
|
81
|
+
for p in path.rglob('*'):
|
|
82
|
+
try:
|
|
83
|
+
parts = p.relative_to(path).parts
|
|
84
|
+
if any(part.startswith('.') or part in ignored_dirs for part in parts[:-1]):
|
|
85
|
+
continue
|
|
86
|
+
if p.is_file() and not p.name.startswith('.') and p.suffix.lower() in supported_exts:
|
|
87
|
+
return True
|
|
88
|
+
except ValueError:
|
|
89
|
+
continue
|
|
90
|
+
return False
|
|
91
|
+
|
forgecli/utils/ids.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Identifier generation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def new_id(prefix: str = "id", *, length: int = 12) -> str:
|
|
9
|
+
"""Return a short URL-safe unique identifier, optionally prefixed."""
|
|
10
|
+
body = secrets.token_hex(length)
|
|
11
|
+
return f"{prefix}_{body}" if prefix else body
|
forgecli/utils/io.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Async filesystem helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import aiofiles # type: ignore[import-untyped]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def aio_read_text(path: os.PathLike[str] | str, *, encoding: str = "utf-8") -> str:
|
|
12
|
+
"""Asynchronously read text from ``path``."""
|
|
13
|
+
async with aiofiles.open(path, encoding=encoding) as fh:
|
|
14
|
+
return await fh.read()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def aio_write_text(
|
|
18
|
+
path: os.PathLike[str] | str,
|
|
19
|
+
content: str,
|
|
20
|
+
*,
|
|
21
|
+
encoding: str = "utf-8",
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Asynchronously write text to ``path``."""
|
|
24
|
+
target = Path(path)
|
|
25
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
async with aiofiles.open(target, mode="w", encoding=encoding) as fh:
|
|
27
|
+
await fh.write(content)
|
forgecli/utils/paths.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Resolve common filesystem locations used by ForgeCLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ProjectPaths:
|
|
12
|
+
"""Bundle of well-known paths used across the application."""
|
|
13
|
+
|
|
14
|
+
cwd: Path
|
|
15
|
+
config_dir: Path
|
|
16
|
+
data_dir: Path
|
|
17
|
+
cache_dir: Path
|
|
18
|
+
logs_dir: Path
|
|
19
|
+
prompts_dir: Path
|
|
20
|
+
plugins_dir: Path
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_env(cls, *, cwd: os.PathLike[str] | str | None = None) -> ProjectPaths:
|
|
24
|
+
"""Compute the default project paths using ``platformdirs`` semantics.
|
|
25
|
+
|
|
26
|
+
``FORGECLI_DATA_DIR`` and ``FORGECLI_CONFIG_DIR`` may be set to
|
|
27
|
+
override the per-OS defaults (useful for tests and CI).
|
|
28
|
+
"""
|
|
29
|
+
from platformdirs import PlatformDirs # local import to keep deps light
|
|
30
|
+
|
|
31
|
+
dirs = PlatformDirs("forgecli", appauthor=False, version=None)
|
|
32
|
+
cwd_path = Path(cwd) if cwd is not None else Path.cwd()
|
|
33
|
+
data_dir = Path(os.environ["FORGECLI_DATA_DIR"]) if os.environ.get("FORGECLI_DATA_DIR") else Path(dirs.user_data_dir)
|
|
34
|
+
config_dir = Path(os.environ["FORGECLI_CONFIG_DIR"]) if os.environ.get("FORGECLI_CONFIG_DIR") else Path(dirs.user_config_dir)
|
|
35
|
+
return cls(
|
|
36
|
+
cwd=cwd_path,
|
|
37
|
+
config_dir=config_dir,
|
|
38
|
+
data_dir=data_dir,
|
|
39
|
+
cache_dir=Path(dirs.user_cache_dir),
|
|
40
|
+
logs_dir=Path(dirs.user_log_dir),
|
|
41
|
+
prompts_dir=config_dir / "prompts",
|
|
42
|
+
plugins_dir=config_dir / "plugins",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def ensure(self) -> ProjectPaths:
|
|
46
|
+
"""Create all directories in place; returns ``self`` for chaining."""
|
|
47
|
+
for attr in (
|
|
48
|
+
"config_dir",
|
|
49
|
+
"data_dir",
|
|
50
|
+
"cache_dir",
|
|
51
|
+
"logs_dir",
|
|
52
|
+
"prompts_dir",
|
|
53
|
+
"plugins_dir",
|
|
54
|
+
):
|
|
55
|
+
getattr(self, attr).mkdir(parents=True, exist_ok=True)
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def to_privacy_path(p: Path | str | None) -> str:
|
|
60
|
+
"""Convert an absolute path to a home-relative path with ~ for privacy."""
|
|
61
|
+
if p is None:
|
|
62
|
+
return ""
|
|
63
|
+
try:
|
|
64
|
+
path_obj = Path(p).resolve()
|
|
65
|
+
home = Path.home().resolve()
|
|
66
|
+
if home in path_obj.parents or path_obj == home:
|
|
67
|
+
return f"~/{path_obj.relative_to(home)}"
|
|
68
|
+
return str(path_obj)
|
|
69
|
+
except Exception:
|
|
70
|
+
return str(p)
|
forgecli/utils/timing.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Simple timing utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@contextmanager
|
|
12
|
+
def Timer(label: str = "elapsed") -> Iterator[dict[str, Any]]:
|
|
13
|
+
"""Context manager that measures wall-clock duration.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
with Timer() as t:
|
|
17
|
+
...
|
|
18
|
+
print(t["seconds"])
|
|
19
|
+
"""
|
|
20
|
+
result: dict[str, Any] = {"label": label, "seconds": 0.0}
|
|
21
|
+
started = time.perf_counter()
|
|
22
|
+
try:
|
|
23
|
+
yield result
|
|
24
|
+
finally:
|
|
25
|
+
result["seconds"] = time.perf_counter() - started
|