forgeoptimizer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +149 -0
  19. forgecli/cli/commands_wrappers.py +80 -0
  20. forgecli/cli/daemon.py +744 -0
  21. forgecli/cli/main.py +234 -0
  22. forgecli/cli/ui.py +56 -0
  23. forgecli/config/__init__.py +28 -0
  24. forgecli/config/loader.py +85 -0
  25. forgecli/config/settings.py +206 -0
  26. forgecli/config/writer.py +90 -0
  27. forgecli/core/__init__.py +35 -0
  28. forgecli/core/container.py +68 -0
  29. forgecli/core/context.py +54 -0
  30. forgecli/core/credentials.py +114 -0
  31. forgecli/core/errors.py +27 -0
  32. forgecli/core/events.py +67 -0
  33. forgecli/core/logging.py +47 -0
  34. forgecli/core/models.py +214 -0
  35. forgecli/core/plugins.py +54 -0
  36. forgecli/core/service.py +22 -0
  37. forgecli/docs/__init__.py +5 -0
  38. forgecli/docs/generator.py +115 -0
  39. forgecli/engine/__init__.py +105 -0
  40. forgecli/engine/context.py +163 -0
  41. forgecli/engine/defaults.py +89 -0
  42. forgecli/engine/events.py +185 -0
  43. forgecli/engine/execution.py +519 -0
  44. forgecli/engine/plugins.py +145 -0
  45. forgecli/engine/runner.py +158 -0
  46. forgecli/engine/stages/__init__.py +33 -0
  47. forgecli/engine/stages/caveman_optimizer.py +47 -0
  48. forgecli/engine/stages/context_optimizer.py +44 -0
  49. forgecli/engine/stages/execution_engine_stage.py +102 -0
  50. forgecli/engine/stages/git_engine.py +30 -0
  51. forgecli/engine/stages/intent_analyzer.py +38 -0
  52. forgecli/engine/stages/model_router.py +65 -0
  53. forgecli/engine/stages/planning_engine.py +45 -0
  54. forgecli/engine/stages/repository_analyzer.py +54 -0
  55. forgecli/engine/stages/validation_engine.py +87 -0
  56. forgecli/git/__init__.py +9 -0
  57. forgecli/git/repo.py +55 -0
  58. forgecli/git/service.py +45 -0
  59. forgecli/graph/__init__.py +56 -0
  60. forgecli/graph/backend_graphify.py +448 -0
  61. forgecli/graph/edge.py +19 -0
  62. forgecli/graph/graph.py +85 -0
  63. forgecli/graph/graphify.py +405 -0
  64. forgecli/graph/indexer.py +82 -0
  65. forgecli/graph/node.py +47 -0
  66. forgecli/graph/repository.py +164 -0
  67. forgecli/memory/__init__.py +12 -0
  68. forgecli/memory/cache.py +61 -0
  69. forgecli/memory/history.py +136 -0
  70. forgecli/memory/store.py +85 -0
  71. forgecli/optimizer/__init__.py +13 -0
  72. forgecli/optimizer/caveman/__init__.py +169 -0
  73. forgecli/optimizer/caveman/cli.py +62 -0
  74. forgecli/optimizer/caveman/decorator.py +67 -0
  75. forgecli/optimizer/caveman/factory.py +51 -0
  76. forgecli/optimizer/caveman/ruleset.py +156 -0
  77. forgecli/optimizer/caveman/state.py +50 -0
  78. forgecli/optimizer/chunker.py +85 -0
  79. forgecli/optimizer/optimizer.py +81 -0
  80. forgecli/optimizer/ponytail/__init__.py +181 -0
  81. forgecli/optimizer/ponytail/cli.py +159 -0
  82. forgecli/optimizer/ponytail/decorator.py +70 -0
  83. forgecli/optimizer/ponytail/factory.py +51 -0
  84. forgecli/optimizer/ponytail/ruleset.py +168 -0
  85. forgecli/optimizer/ponytail/state.py +51 -0
  86. forgecli/optimizer/ranker.py +37 -0
  87. forgecli/optimizer/summarizer.py +44 -0
  88. forgecli/orchestrator/__init__.py +706 -0
  89. forgecli/planner/__init__.py +56 -0
  90. forgecli/planner/agent.py +61 -0
  91. forgecli/planner/plan.py +65 -0
  92. forgecli/planner/planner.py +17 -0
  93. forgecli/planner/render.py +267 -0
  94. forgecli/planner/serialize.py +104 -0
  95. forgecli/planner/software.py +832 -0
  96. forgecli/platform/__init__.py +91 -0
  97. forgecli/platform/core.py +201 -0
  98. forgecli/platform/deps.py +361 -0
  99. forgecli/platform/paths.py +234 -0
  100. forgecli/platform/shell.py +176 -0
  101. forgecli/platform/update.py +253 -0
  102. forgecli/plugins/__init__.py +249 -0
  103. forgecli/prompts/__init__.py +11 -0
  104. forgecli/prompts/loader.py +28 -0
  105. forgecli/prompts/registry.py +32 -0
  106. forgecli/prompts/renderer.py +23 -0
  107. forgecli/providers/__init__.py +39 -0
  108. forgecli/providers/anthropic.py +207 -0
  109. forgecli/providers/base.py +204 -0
  110. forgecli/providers/builtin.py +26 -0
  111. forgecli/providers/conversation.py +74 -0
  112. forgecli/providers/google.py +290 -0
  113. forgecli/providers/http_base.py +207 -0
  114. forgecli/providers/mock.py +111 -0
  115. forgecli/providers/openai.py +206 -0
  116. forgecli/providers/openai_compatible.py +787 -0
  117. forgecli/providers/router.py +340 -0
  118. forgecli/providers/router_state.py +89 -0
  119. forgecli/review/__init__.py +45 -0
  120. forgecli/review/analyzer.py +124 -0
  121. forgecli/review/analyzers/__init__.py +1 -0
  122. forgecli/review/analyzers/architecture.py +255 -0
  123. forgecli/review/analyzers/complexity.py +162 -0
  124. forgecli/review/analyzers/dead_code.py +255 -0
  125. forgecli/review/analyzers/duplicates.py +161 -0
  126. forgecli/review/analyzers/performance.py +161 -0
  127. forgecli/review/analyzers/security.py +244 -0
  128. forgecli/review/finding.py +68 -0
  129. forgecli/review/report.py +321 -0
  130. forgecli/review/repository.py +130 -0
  131. forgecli/review/suggestions.py +98 -0
  132. forgecli/runtime/__init__.py +6 -0
  133. forgecli/runtime/cache_store.py +75 -0
  134. forgecli/runtime/mcp_config.py +118 -0
  135. forgecli/runtime/prepare.py +203 -0
  136. forgecli/runtime/wrappers.py +150 -0
  137. forgecli/sdk/__init__.py +132 -0
  138. forgecli/sdk/events.py +206 -0
  139. forgecli/sdk/interfaces.py +282 -0
  140. forgecli/sdk/loader.py +239 -0
  141. forgecli/sdk/manager.py +678 -0
  142. forgecli/sdk/manifest.py +395 -0
  143. forgecli/sdk/sandbox.py +247 -0
  144. forgecli/sdk/version.py +313 -0
  145. forgecli/templates/__init__.py +9 -0
  146. forgecli/templates/engine.py +37 -0
  147. forgecli/templates/registry.py +32 -0
  148. forgecli/utils/__init__.py +19 -0
  149. forgecli/utils/fs.py +121 -0
  150. forgecli/utils/ids.py +11 -0
  151. forgecli/utils/io.py +27 -0
  152. forgecli/utils/paths.py +78 -0
  153. forgecli/utils/stats.py +179 -0
  154. forgecli/utils/timing.py +25 -0
  155. forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
  156. forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
  157. forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
  158. forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
  159. forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,313 @@
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 for ident, num in self.pre
87
+ )
88
+ if self.build:
89
+ base += f"+{self.build}"
90
+ return base
91
+
92
+ def __repr__(self) -> str:
93
+ return f"Version({self})"
94
+
95
+ def is_prerelease(self) -> bool:
96
+ return bool(self.pre)
97
+
98
+ def without_prerelease(self) -> Version:
99
+ return Version(self.major, self.minor, self.patch)
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Spec parsing
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ class Op(str, Enum):
108
+ EQ = "=="
109
+ NEQ = "!="
110
+ GT = ">"
111
+ GE = ">="
112
+ LT = "<"
113
+ LE = "<="
114
+ CARET = "^" # NOT USED; kept for documentation
115
+ TILDE = "~="
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class Spec:
120
+ """A single dependency specifier like ``>=1.2,<2.0``."""
121
+
122
+ op: Op
123
+ version: Version
124
+
125
+ def matches(self, other: Version) -> bool:
126
+ if self.op is Op.EQ:
127
+ return other == self.version
128
+ if self.op is Op.NEQ:
129
+ return other != self.version
130
+ if self.op is Op.GT:
131
+ return other > self.version
132
+ if self.op is Op.GE:
133
+ return other >= self.version
134
+ if self.op is Op.LT:
135
+ return other < self.version
136
+ if self.op is Op.LE:
137
+ return other <= self.version
138
+ if self.op is Op.TILDE:
139
+ return other >= self.version
140
+ raise ValueError(f"unsupported op: {self.op}")
141
+
142
+ def __str__(self) -> str:
143
+ return f"{self.op}{self.version}"
144
+
145
+
146
+ # A spec string like ``"^1.0 ; extras: graphify"`` is a comma-separated
147
+ # set of Spec clauses plus an optional ``;extras:...`` annotation.
148
+ _SPEC_SEP = ";"
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class Requirement:
153
+ """A full requirement: name + set of version specs + optional extras."""
154
+
155
+ name: str
156
+ specs: tuple[Any, ...] = ()
157
+ extras: tuple[str, ...] = ()
158
+
159
+ def matches(self, version: Version) -> bool:
160
+ if not self.specs:
161
+ return True
162
+ return all(spec.matches(version) for spec in self.specs)
163
+
164
+ @classmethod
165
+ def parse(cls, name: str, spec_string: str = "") -> Requirement:
166
+ if not name:
167
+ raise ValueError("requirement name must be non-empty")
168
+ specs: list[Any] = []
169
+ extras: tuple[str, ...] = ()
170
+ body, sep, tail = spec_string.partition(_SPEC_SEP)
171
+ if sep:
172
+ extras_str = tail.strip()
173
+ if extras_str.startswith("extras:"):
174
+ extras = tuple(
175
+ chunk.strip()
176
+ for chunk in extras_str[len("extras:") :].split(",")
177
+ if chunk.strip()
178
+ )
179
+ for raw in body.split(","):
180
+ raw = raw.strip()
181
+ if not raw or raw == "*":
182
+ continue
183
+ for op_token in ("==", "!=", ">=", "<=", "~=", ">", "<"):
184
+ if raw.startswith(op_token):
185
+ version_str = raw[len(op_token) :].strip()
186
+ if op_token == "~=":
187
+ specs.append(_tilde(version_str))
188
+ else:
189
+ try:
190
+ specs.append(Spec(Op(op_token), Version.parse(version_str)))
191
+ except VersionParseError:
192
+ continue
193
+ break
194
+ else:
195
+ # Bare version string == exact match.
196
+ try:
197
+ specs.append(Spec(Op.EQ, Version.parse(raw)))
198
+ except VersionParseError:
199
+ continue
200
+ return cls(name=name, specs=tuple(specs), extras=tuple(extras))
201
+
202
+ def __str__(self) -> str:
203
+ base = self.name
204
+ if self.specs:
205
+ base += " " + ",".join(str(s) for s in self.specs)
206
+ if self.extras:
207
+ base += f" ; extras: {','.join(self.extras)}"
208
+ return base
209
+
210
+
211
+ def _tilde(version_str: str) -> Any:
212
+ """Implement the ``~=X.Y`` and ``~=X.Y.Z`` operators."""
213
+ parts = version_str.split(".")
214
+ if len(parts) == 1:
215
+ # ~=2 is invalid; treat as ==
216
+ return Spec(Op.EQ, Version.parse(version_str))
217
+ if len(parts) == 2:
218
+ major, minor = parts
219
+ low = Version.parse(version_str)
220
+ high = Version.parse(f"{int(major) + 1}.0")
221
+ else:
222
+ major, minor, _patch = parts
223
+ low = Version.parse(version_str)
224
+ high = Version.parse(f"{major}.{int(minor) + 1}")
225
+ # ~="X.Y" means ">=X.Y, <(X+1).0"; the high is exclusive.
226
+ # We return a synthetic GE spec; the LT spec is folded in below.
227
+ return _TildeSpec(ge=low, lt=high)
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class _TildeSpec:
232
+ ge: Version
233
+ lt: Version
234
+
235
+ def matches(self, other: Version) -> bool:
236
+ return self.ge <= other < self.lt
237
+
238
+
239
+ # Replace the simple Spec with one that also supports tilde.
240
+ _Spec = Spec # type alias retained for clarity; not used below
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # Dependency resolution
245
+ # ---------------------------------------------------------------------------
246
+
247
+
248
+ class DependencyCycleError(RuntimeError):
249
+ """Raised when dependency resolution hits a cycle."""
250
+
251
+
252
+ class UnsatisfiableRequirementError(RuntimeError):
253
+ """Raised when two requirements for the same name disagree."""
254
+
255
+
256
+ def resolve(
257
+ requirements: Iterable[Requirement],
258
+ candidates: dict[str, tuple[Version, ...]],
259
+ ) -> dict[str, Version]:
260
+ """Pick a single version for every named requirement.
261
+
262
+ ``requirements`` is the union of constraints (multiple
263
+ :class:`Requirement` objects with the same name are intersected).
264
+ ``candidates`` maps a name to the available versions on PyPI /
265
+ a local index / etc. The resolver picks the *highest* version
266
+ that satisfies every constraint, and raises on cycles or
267
+ contradictions.
268
+ """
269
+ constraints: dict[str, list[Requirement]] = {}
270
+ for req in requirements:
271
+ constraints.setdefault(req.name, []).append(req)
272
+
273
+ chosen: dict[str, Version] = {}
274
+ while constraints:
275
+ progress = False
276
+ for name, reqs in list(constraints.items()):
277
+ if name in chosen:
278
+ continue
279
+ available = candidates.get(name, ())
280
+ for version in sorted(available, reverse=True):
281
+ if all(req.matches(version) for req in reqs):
282
+ chosen[name] = version
283
+ del constraints[name]
284
+ progress = True
285
+ break
286
+ else:
287
+ # Try partial: did we have any candidate at all?
288
+ if not available:
289
+ # Soft skip: caller may not have provided a local
290
+ # index for this plugin. We drop the constraint
291
+ # silently.
292
+ del constraints[name]
293
+ progress = True
294
+ continue
295
+ raise UnsatisfiableRequirementError(
296
+ f"no version of {name!r} satisfies {', '.join(map(str, reqs))}"
297
+ )
298
+ if not progress:
299
+ # Cycle or stuck.
300
+ raise DependencyCycleError(f"could not resolve: {sorted(constraints)}")
301
+ return chosen
302
+
303
+
304
+ __all__ = [
305
+ "DependencyCycleError",
306
+ "Op",
307
+ "Requirement",
308
+ "Spec",
309
+ "UnsatisfiableRequirementError",
310
+ "Version",
311
+ "VersionParseError",
312
+ "resolve",
313
+ ]
@@ -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,121 @@
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",
73
+ ".js",
74
+ ".ts",
75
+ ".jsx",
76
+ ".tsx",
77
+ ".go",
78
+ ".rs",
79
+ ".c",
80
+ ".cpp",
81
+ ".cc",
82
+ ".h",
83
+ ".hpp",
84
+ ".java",
85
+ ".rb",
86
+ ".php",
87
+ ".cs",
88
+ ".html",
89
+ ".css",
90
+ ".sh",
91
+ ".md",
92
+ ".toml",
93
+ ".json",
94
+ ".yaml",
95
+ ".yml",
96
+ }
97
+ ignored_dirs = {
98
+ ".git",
99
+ ".venv",
100
+ "__pycache__",
101
+ "node_modules",
102
+ "dist",
103
+ "build",
104
+ ".forge",
105
+ "graphify-out",
106
+ ".mypy_cache",
107
+ ".pytest_cache",
108
+ ".ruff_cache",
109
+ }
110
+ if not path.exists() or not path.is_dir():
111
+ return False
112
+ for p in path.rglob("*"):
113
+ try:
114
+ parts = p.relative_to(path).parts
115
+ if any(part.startswith(".") or part in ignored_dirs for part in parts[:-1]):
116
+ continue
117
+ if p.is_file() and not p.name.startswith(".") and p.suffix.lower() in supported_exts:
118
+ return True
119
+ except ValueError:
120
+ continue
121
+ return False
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,78 @@
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 = (
34
+ Path(os.environ["FORGECLI_DATA_DIR"])
35
+ if os.environ.get("FORGECLI_DATA_DIR")
36
+ else Path(dirs.user_data_dir)
37
+ )
38
+ config_dir = (
39
+ Path(os.environ["FORGECLI_CONFIG_DIR"])
40
+ if os.environ.get("FORGECLI_CONFIG_DIR")
41
+ else Path(dirs.user_config_dir)
42
+ )
43
+ return cls(
44
+ cwd=cwd_path,
45
+ config_dir=config_dir,
46
+ data_dir=data_dir,
47
+ cache_dir=Path(dirs.user_cache_dir),
48
+ logs_dir=Path(dirs.user_log_dir),
49
+ prompts_dir=config_dir / "prompts",
50
+ plugins_dir=config_dir / "plugins",
51
+ )
52
+
53
+ def ensure(self) -> ProjectPaths:
54
+ """Create all directories in place; returns ``self`` for chaining."""
55
+ for attr in (
56
+ "config_dir",
57
+ "data_dir",
58
+ "cache_dir",
59
+ "logs_dir",
60
+ "prompts_dir",
61
+ "plugins_dir",
62
+ ):
63
+ getattr(self, attr).mkdir(parents=True, exist_ok=True)
64
+ return self
65
+
66
+
67
+ def to_privacy_path(p: Path | str | None) -> str:
68
+ """Convert an absolute path to a home-relative path with ~ for privacy."""
69
+ if p is None:
70
+ return ""
71
+ try:
72
+ path_obj = Path(p).resolve()
73
+ home = Path.home().resolve()
74
+ if home in path_obj.parents or path_obj == home:
75
+ return f"~/{path_obj.relative_to(home)}"
76
+ return str(path_obj)
77
+ except Exception:
78
+ return str(p)