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,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,9 @@
1
+ """Reusable workflow and project templates."""
2
+
3
+ from forgecli.templates.engine import TemplateEngine
4
+ from forgecli.templates.registry import TemplateRegistry
5
+
6
+ __all__ = [
7
+ "TemplateEngine",
8
+ "TemplateRegistry",
9
+ ]
@@ -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)
@@ -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)
@@ -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