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,91 @@
1
+ """Cross-platform support layer for ForgeCLI.
2
+
3
+ ForgeCLI must run identically on Windows 10/11, macOS (Intel and
4
+ Apple Silicon) and Linux (Ubuntu, Fedora, Debian, Arch, Kali,
5
+ openSUSE, etc.). This package is the *only* place where platform
6
+ detection, dependency discovery, environment handling, and shell
7
+ adaption live.
8
+
9
+ The package is split into focused modules:
10
+
11
+ * :mod:`forgecli.platform.core` — OS detection + env helpers
12
+ * :mod:`forgecli.platform.paths` — config / data / cache dirs
13
+ * :mod:`forgecli.platform.shell` — shell adapter (no POSIX-only
14
+ hardcoding)
15
+ * :mod:`forgecli.platform.deps` — git / graphify / ponytail / python /
16
+ node / package-manager detection
17
+ * :mod:`forgecli.platform.update` — PyPI update check + version
18
+
19
+ Downstream code must import from this package rather than from
20
+ :mod:`sys` / :mod:`os` / :mod:`platform` directly.
21
+ """
22
+
23
+ from forgecli.platform.core import (
24
+ OS,
25
+ Platform,
26
+ current_platform,
27
+ detect_os,
28
+ is_linux,
29
+ is_macos,
30
+ is_windows,
31
+ python_version,
32
+ )
33
+ from forgecli.platform.deps import (
34
+ DependencyReport,
35
+ DependencyStatus,
36
+ check_dependencies,
37
+ find_executable,
38
+ has_git,
39
+ has_graphify,
40
+ has_node,
41
+ has_ponytail,
42
+ has_python,
43
+ install_hint,
44
+ )
45
+ from forgecli.platform.paths import (
46
+ ProjectPaths,
47
+ config_dir,
48
+ data_dir,
49
+ load_dotenv,
50
+ state_dir,
51
+ )
52
+ from forgecli.platform.shell import ShellResult, run, shell_quote
53
+ from forgecli.platform.update import (
54
+ UpdateInfo,
55
+ check_for_update,
56
+ current_version,
57
+ upgrade_command,
58
+ )
59
+
60
+ __all__ = [
61
+ "OS",
62
+ "DependencyReport",
63
+ "DependencyStatus",
64
+ "Platform",
65
+ "ProjectPaths",
66
+ "ShellResult",
67
+ "UpdateInfo",
68
+ "check_dependencies",
69
+ "check_for_update",
70
+ "config_dir",
71
+ "current_platform",
72
+ "current_version",
73
+ "data_dir",
74
+ "detect_os",
75
+ "find_executable",
76
+ "has_git",
77
+ "has_graphify",
78
+ "has_node",
79
+ "has_ponytail",
80
+ "has_python",
81
+ "install_hint",
82
+ "is_linux",
83
+ "is_macos",
84
+ "is_windows",
85
+ "load_dotenv",
86
+ "python_version",
87
+ "run",
88
+ "shell_quote",
89
+ "state_dir",
90
+ "upgrade_command",
91
+ ]
@@ -0,0 +1,201 @@
1
+ """OS detection + small environment helpers.
2
+
3
+ All detection is a single :func:`detect_os` call, cached at module
4
+ import time, and exposed via convenient predicates (:func:`is_linux`,
5
+ :func:`is_macos`, :func:`is_windows`). The :data:`Platform` namedtuple
6
+ also exposes ``arch`` (machine type), ``release`` (kernel/OS
7
+ release), and a stable :data:`OS` enum.
8
+
9
+ This module is the *only* place that imports :mod:`sys.platform` /
10
+ :mod:`platform.machine` — everything else goes through
11
+ :func:`current_platform`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import platform as _platform
18
+ import sys
19
+ from dataclasses import dataclass
20
+ from enum import Enum
21
+ from typing import Final
22
+
23
+
24
+ class OS(str, Enum):
25
+ """Stable OS identifier.
26
+
27
+ We deliberately avoid :data:`sys.platform` strings in caller code;
28
+ only this module reads them.
29
+ """
30
+
31
+ LINUX = "linux"
32
+ MACOS = "macos"
33
+ WINDOWS = "windows"
34
+ OTHER = "other"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Platform:
39
+ """A snapshot of the runtime platform."""
40
+
41
+ os: OS
42
+ arch: str # "x86_64" | "arm64" | "aarch64" | …
43
+ release: str # kernel / OS release string
44
+ python: str # "3.12.3"
45
+ is_wsl: bool = False
46
+
47
+ @property
48
+ def is_macos(self) -> bool:
49
+ return self.os is OS.MACOS
50
+
51
+ @property
52
+ def is_linux(self) -> bool:
53
+ return self.os is OS.LINUX
54
+
55
+ @property
56
+ def is_windows(self) -> bool:
57
+ return self.os is OS.WINDOWS
58
+
59
+ @property
60
+ def is_apple_silicon(self) -> bool:
61
+ return self.os is OS.MACOS and self.arch == "arm64"
62
+
63
+
64
+ # A single cached snapshot taken at import time. Cheap; safe to use
65
+ # in tight loops. The call below is intentionally *after* the function
66
+ # definition so the module body executes top-to-bottom.
67
+ def _build_platform() -> Platform:
68
+ sys_platform = sys.platform
69
+ if sys_platform.startswith("linux"):
70
+ os_value = OS.LINUX
71
+ elif sys_platform == "darwin":
72
+ os_value = OS.MACOS
73
+ elif sys_platform in {"win32", "cygwin"}:
74
+ os_value = OS.WINDOWS
75
+ else:
76
+ os_value = OS.OTHER
77
+
78
+ arch = _platform.machine() or "unknown"
79
+ # Normalise ARM naming on macOS.
80
+ if os_value is OS.MACOS and arch == "aarch64":
81
+ arch = "arm64"
82
+
83
+ release = ""
84
+ try:
85
+ if os_value is OS.WINDOWS:
86
+ release = _platform.win32_ver()[1] or _platform.release()
87
+ elif os_value is OS.MACOS:
88
+ release = _platform.mac_ver()[0] or _platform.release()
89
+ else:
90
+ release = _platform.release()
91
+ except Exception:
92
+ release = ""
93
+
94
+ is_wsl = False
95
+ if os_value is OS.LINUX:
96
+ try:
97
+ with open("/proc/version", encoding="utf-8", errors="replace") as fh:
98
+ head = fh.read(200).lower()
99
+ is_wsl = "microsoft" in head or "wsl" in head
100
+ except OSError:
101
+ is_wsl = False
102
+
103
+ return Platform(
104
+ os=os_value,
105
+ arch=arch,
106
+ release=release,
107
+ python=_platform.python_version(),
108
+ is_wsl=is_wsl,
109
+ )
110
+
111
+
112
+ # A single cached snapshot taken at import time. Cheap; safe to use
113
+ # in tight loops. The call below is intentionally *after* the function
114
+ # definition so the module body executes top-to-bottom.
115
+ _PLATFORM: Final[Platform] = _build_platform()
116
+
117
+
118
+ def detect_os() -> OS:
119
+ """Return the current :class:`OS` value.
120
+
121
+ Equivalent to ``current_platform().os`` but slightly cheaper.
122
+ """
123
+ return _PLATFORM.os
124
+
125
+
126
+ def current_platform() -> Platform:
127
+ """Return the cached :class:`Platform` snapshot."""
128
+ return _PLATFORM
129
+
130
+
131
+ def is_windows() -> bool:
132
+ return _PLATFORM.os is OS.WINDOWS
133
+
134
+
135
+ def is_macos() -> bool:
136
+ return _PLATFORM.os is OS.MACOS
137
+
138
+
139
+ def is_linux() -> bool:
140
+ return _PLATFORM.os is OS.LINUX
141
+
142
+
143
+ def python_version() -> str:
144
+ """Return the running Python version (``'3.12.3'``)."""
145
+ return _PLATFORM.python
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Environment helpers
150
+ # ---------------------------------------------------------------------------
151
+
152
+
153
+ def getenv(name: str, default: str | None = None) -> str | None:
154
+ """Read an environment variable, returning ``default`` if missing."""
155
+ return os.environ.get(name, default)
156
+
157
+
158
+ def getenv_bool(name: str, default: bool = False) -> bool:
159
+ """Parse a boolean environment variable.
160
+
161
+ Truthy: ``"1"``, ``"true"``, ``"yes"``, ``"on"`` (case-insensitive).
162
+ Falsy: ``"0"``, ``"false"``, ``"no"``, ``"off"``, ``""``.
163
+ """
164
+ raw = os.environ.get(name)
165
+ if raw is None:
166
+ return default
167
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
168
+
169
+
170
+ def getenv_int(name: str, default: int) -> int:
171
+ raw = os.environ.get(name)
172
+ if raw is None or raw.strip() == "":
173
+ return default
174
+ try:
175
+ return int(raw)
176
+ except ValueError:
177
+ return default
178
+
179
+
180
+ def getenv_list(name: str, default: tuple[str, ...] = (), *, sep: str = ",") -> tuple[str, ...]:
181
+ """Parse a delimited environment variable as a tuple of strings."""
182
+ raw = os.environ.get(name)
183
+ if raw is None:
184
+ return default
185
+ return tuple(part.strip() for part in raw.split(sep) if part.strip())
186
+
187
+
188
+ __all__ = [
189
+ "OS",
190
+ "Platform",
191
+ "current_platform",
192
+ "detect_os",
193
+ "getenv",
194
+ "getenv_bool",
195
+ "getenv_int",
196
+ "getenv_list",
197
+ "is_linux",
198
+ "is_macos",
199
+ "is_windows",
200
+ "python_version",
201
+ ]
@@ -0,0 +1,361 @@
1
+ """Dependency detection + install guidance.
2
+
3
+ The :class:`DependencyReport` aggregates the status of every
4
+ optional / required external tool ForgeCLI integrates with:
5
+
6
+ * ``git`` — required (we have a ``git`` adapter regardless)
7
+ * ``python`` — the running interpreter
8
+ * ``ponytail`` — **built-in**; the ruleset optimizer ships with
9
+ ForgeCLI — no external binary required
10
+ * ``graphify`` — optional; powers the knowledge graph
11
+ (install via ``uv tool install graphifyy``)
12
+ * ``node`` — optional; needed by some LLM providers
13
+ * ``pip`` / ``uv`` / ``brew`` / ``scoop`` / ``winget`` — package
14
+ managers used to render install hints.
15
+
16
+ Each dependency is :class:`DependencyStatus` (found / missing /
17
+ version). :func:`check_dependencies` returns a report; callers can
18
+ serialize it for ``forge doctor`` output.
19
+
20
+ Install hints are platform-aware: a missing ``graphify`` on macOS
21
+ gets a ``brew install graphify`` line; on Windows, a
22
+ ``winget install Graphify.Graphify`` line; on Ubuntu, an
23
+ ``apt``/``snap`` line. We never suggest a Linux command when
24
+ ``is_windows()`` is True.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import shutil
30
+ from collections.abc import Callable
31
+ from dataclasses import dataclass, field
32
+ from enum import Enum
33
+ from typing import Final
34
+
35
+ from forgecli.platform.core import (
36
+ OS,
37
+ current_platform,
38
+ is_macos,
39
+ is_windows,
40
+ python_version,
41
+ )
42
+
43
+
44
+ class DependencyStatus(str, Enum):
45
+ """The result of probing a single external tool."""
46
+
47
+ FOUND = "found"
48
+ MISSING = "missing"
49
+ UNAVAILABLE = "unavailable" # not supported on this platform
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Dependency:
54
+ """One external tool that ForgeCLI may need."""
55
+
56
+ name: str
57
+ status: DependencyStatus
58
+ path: str | None = None
59
+ version: str | None = None
60
+ required: bool = False
61
+ note: str | None = None
62
+
63
+ def to_dict(self) -> dict[str, object]:
64
+ return {
65
+ "name": self.name,
66
+ "status": self.status.value,
67
+ "path": self.path,
68
+ "version": self.version,
69
+ "required": self.required,
70
+ "note": self.note,
71
+ }
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class DependencyReport:
76
+ """A snapshot of every dependency ForgeCLI cares about."""
77
+
78
+ dependencies: tuple[Dependency, ...] = field(default_factory=tuple)
79
+
80
+ @property
81
+ def missing(self) -> tuple[Dependency, ...]:
82
+ return tuple(d for d in self.dependencies if d.status is DependencyStatus.MISSING)
83
+
84
+ @property
85
+ def missing_required(self) -> tuple[Dependency, ...]:
86
+ return tuple(
87
+ d for d in self.dependencies if d.status is DependencyStatus.MISSING and d.required
88
+ )
89
+
90
+ def to_dict(self) -> dict[str, object]:
91
+ return {
92
+ "platform": current_platform().os.value,
93
+ "arch": current_platform().arch,
94
+ "python": python_version(),
95
+ "dependencies": [d.to_dict() for d in self.dependencies],
96
+ "missing": [d.name for d in self.missing],
97
+ "missing_required": [d.name for d in self.missing_required],
98
+ }
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Public helpers
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ def find_executable(name: str) -> str | None:
107
+ """Return the absolute path of ``name`` if it's on PATH, else None."""
108
+ return shutil.which(name)
109
+
110
+
111
+ def has_git() -> bool:
112
+ return find_executable("git") is not None
113
+
114
+
115
+ def has_python() -> bool:
116
+ """Always True (we're running inside Python), but exposed for symmetry."""
117
+ return True
118
+
119
+
120
+ def has_graphify() -> bool:
121
+ return find_executable("graphify") is not None
122
+
123
+
124
+ def has_ponytail() -> bool:
125
+ return find_executable("ponytail") is not None
126
+
127
+
128
+ def has_node() -> bool:
129
+ return find_executable("node") is not None
130
+
131
+
132
+ def has_pip() -> bool:
133
+ return find_executable("pip") is not None or find_executable("pip3") is not None
134
+
135
+
136
+ def has_uv() -> bool:
137
+ return find_executable("uv") is not None
138
+
139
+
140
+ def has_homebrew() -> bool:
141
+ return find_executable("brew") is not None
142
+
143
+
144
+ def has_scoop() -> bool:
145
+ return find_executable("scoop") is not None
146
+
147
+
148
+ def has_winget() -> bool:
149
+ return find_executable("winget") is not None
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Version probe
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ def _run_version(executable: str, args: tuple[str, ...] = ("--version",)) -> str | None:
158
+ """Run ``executable --version`` and return the captured stdout."""
159
+ path = find_executable(executable)
160
+ if path is None:
161
+ return None
162
+ try:
163
+ completed = __import__("subprocess").run(
164
+ [path, *args],
165
+ capture_output=True,
166
+ text=True,
167
+ timeout=10,
168
+ check=False,
169
+ )
170
+ except (OSError, __import__("subprocess").TimeoutExpired):
171
+ return None
172
+ out = (completed.stdout or completed.stderr or "").strip()
173
+ # Trim to first line; strip noise like "git version 2.39.0".
174
+ return out.splitlines()[0] if out else None
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Reporting
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ def check_dependencies() -> DependencyReport:
183
+ """Return a structured :class:`DependencyReport` for the current host."""
184
+ deps: list[Dependency] = []
185
+
186
+ deps.append(_probe_required("git", has_git, _version_for("git")))
187
+ deps.append(_probe_required("python", has_python, lambda: python_version()))
188
+ # Ponytail is built-in — the PonytailRulesetOptimizer ships with
189
+ # ForgeCLI and requires no external binary.
190
+ deps.append(
191
+ Dependency(
192
+ name="ponytail",
193
+ status=DependencyStatus.FOUND,
194
+ version="built-in",
195
+ required=False,
196
+ note="Ruleset optimizer ships with ForgeCLI (no external binary needed)",
197
+ )
198
+ )
199
+ deps.append(
200
+ _probe_optional(
201
+ "graphify",
202
+ has_graphify,
203
+ _version_for("graphify"),
204
+ note="Optional. Install with: uv tool install graphifyy",
205
+ )
206
+ )
207
+ deps.append(_probe_optional("node", has_node, _version_for("node")))
208
+ deps.append(_probe_optional("pip", has_pip, _version_for("pip")))
209
+ deps.append(_probe_optional("uv", has_uv, _version_for("uv")))
210
+
211
+ # Package managers (platform-specific).
212
+ if is_windows():
213
+ deps.append(_probe_optional("scoop", has_scoop, _version_for("scoop")))
214
+ deps.append(_probe_optional("winget", has_winget, _version_for("winget")))
215
+ elif is_macos():
216
+ deps.append(_probe_optional("brew", has_homebrew, _version_for("brew")))
217
+
218
+ return DependencyReport(dependencies=tuple(deps))
219
+
220
+
221
+ def _version_for(executable: str):
222
+ """Return a zero-arg callable that fetches the version of ``executable``."""
223
+
224
+ def _callable() -> str | None:
225
+ return _run_version(executable)
226
+
227
+ return _callable
228
+
229
+
230
+ def _probe_required(
231
+ name: str,
232
+ has: Callable[[], bool],
233
+ version: Callable[[], str | None],
234
+ *,
235
+ note: str | None = None,
236
+ ) -> Dependency:
237
+ if has():
238
+ return Dependency(
239
+ name=name,
240
+ status=DependencyStatus.FOUND,
241
+ path=find_executable(name),
242
+ version=version(),
243
+ required=True,
244
+ note=note,
245
+ )
246
+ return Dependency(
247
+ name=name,
248
+ status=DependencyStatus.MISSING,
249
+ required=True,
250
+ note=note or "required by ForgeCLI",
251
+ )
252
+
253
+
254
+ def _probe_optional(
255
+ name: str,
256
+ has: Callable[[], bool],
257
+ version: Callable[[], str | None],
258
+ *,
259
+ note: str | None = None,
260
+ ) -> Dependency:
261
+ if has():
262
+ return Dependency(
263
+ name=name,
264
+ status=DependencyStatus.FOUND,
265
+ path=find_executable(name),
266
+ version=version(),
267
+ required=False,
268
+ note=note,
269
+ )
270
+ return Dependency(
271
+ name=name,
272
+ status=DependencyStatus.MISSING,
273
+ required=False,
274
+ note=note,
275
+ )
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # Install hints
280
+ # ---------------------------------------------------------------------------
281
+
282
+
283
+ _HINTS: Final[dict[str, dict[OS, tuple[str, ...]]]] = {
284
+ "graphify": {
285
+ OS.LINUX: (
286
+ "uv tool install graphifyy",
287
+ "or: pipx install graphifyy",
288
+ "or: pip install --user graphifyy",
289
+ ),
290
+ OS.MACOS: (
291
+ "brew install graphifyy",
292
+ "or: uv tool install graphifyy",
293
+ ),
294
+ OS.WINDOWS: (
295
+ "winget install graphifyy",
296
+ "or: scoop install graphifyy",
297
+ "or: uv tool install graphifyy",
298
+ ),
299
+ OS.OTHER: ("uv tool install graphifyy",),
300
+ },
301
+ "ponytail": {
302
+ # Ponytail is built-in; an external CLI is only used when the
303
+ # user explicitly sets backend=cli in forgecli.toml.
304
+ OS.LINUX: ("Built-in — no install needed. External CLI: uv tool install ponytail",),
305
+ OS.MACOS: ("Built-in — no install needed. External CLI: uv tool install ponytail",),
306
+ OS.WINDOWS: ("Built-in — no install needed. External CLI: uv tool install ponytail",),
307
+ OS.OTHER: ("Built-in — no install needed.",),
308
+ },
309
+ "git": {
310
+ OS.LINUX: (
311
+ "sudo apt install git (Debian / Ubuntu)",
312
+ "sudo dnf install git (Fedora)",
313
+ "sudo pacman -S git (Arch)",
314
+ "sudo zypper install git (openSUSE)",
315
+ ),
316
+ OS.MACOS: ("xcode-select --install (or: brew install git)",),
317
+ OS.WINDOWS: ("winget install Git.Git", "or: scoop install git"),
318
+ OS.OTHER: ("install Git from your package manager",),
319
+ },
320
+ "node": {
321
+ OS.LINUX: (
322
+ "sudo apt install nodejs (Debian / Ubuntu)",
323
+ "or: https://nodejs.org/en/download",
324
+ ),
325
+ OS.MACOS: ("brew install node", "or: https://nodejs.org/en/download"),
326
+ OS.WINDOWS: ("winget install OpenJS.NodeJS", "or: scoop install nodejs"),
327
+ OS.OTHER: ("install Node.js from https://nodejs.org",),
328
+ },
329
+ }
330
+
331
+
332
+ def install_hint(tool: str) -> tuple[str, ...]:
333
+ """Return the install lines for ``tool`` on the current platform."""
334
+ key = tool.lower().strip()
335
+ table = _HINTS.get(key, {})
336
+ if not table:
337
+ return (f"see the {tool} project documentation for install instructions",)
338
+ return table.get(current_platform().os, table[OS.OTHER])
339
+
340
+
341
+ __all__ = [
342
+ "Dependency",
343
+ "DependencyReport",
344
+ "DependencyStatus",
345
+ "check_dependencies",
346
+ "find_executable",
347
+ "has_git",
348
+ "has_graphify",
349
+ "has_homebrew",
350
+ "has_node",
351
+ "has_pip",
352
+ "has_ponytail",
353
+ "has_python",
354
+ "has_scoop",
355
+ "has_uv",
356
+ "has_winget",
357
+ "install_hint",
358
+ ]
359
+
360
+
361
+ # Silence unused-import warnings.