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.
- 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 +149 -0
- forgecli/cli/commands_wrappers.py +80 -0
- forgecli/cli/daemon.py +744 -0
- forgecli/cli/main.py +234 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +206 -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 +214 -0
- forgecli/core/plugins.py +54 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +115 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +163 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +519 -0
- forgecli/engine/plugins.py +145 -0
- forgecli/engine/runner.py +158 -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 +102 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +65 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +87 -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 +448 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +405 -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 +136 -0
- forgecli/memory/store.py +85 -0
- forgecli/optimizer/__init__.py +13 -0
- forgecli/optimizer/caveman/__init__.py +169 -0
- forgecli/optimizer/caveman/cli.py +62 -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 +50 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +181 -0
- forgecli/optimizer/ponytail/cli.py +159 -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 +51 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +706 -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 +267 -0
- forgecli/planner/serialize.py +104 -0
- forgecli/planner/software.py +832 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +361 -0
- forgecli/platform/paths.py +234 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +253 -0
- forgecli/plugins/__init__.py +249 -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 +207 -0
- forgecli/providers/base.py +204 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +74 -0
- forgecli/providers/google.py +290 -0
- forgecli/providers/http_base.py +207 -0
- forgecli/providers/mock.py +111 -0
- forgecli/providers/openai.py +206 -0
- forgecli/providers/openai_compatible.py +787 -0
- forgecli/providers/router.py +340 -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 +255 -0
- forgecli/review/analyzers/complexity.py +162 -0
- forgecli/review/analyzers/dead_code.py +255 -0
- forgecli/review/analyzers/duplicates.py +161 -0
- forgecli/review/analyzers/performance.py +161 -0
- forgecli/review/analyzers/security.py +244 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +321 -0
- forgecli/review/repository.py +130 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +75 -0
- forgecli/runtime/mcp_config.py +118 -0
- forgecli/runtime/prepare.py +203 -0
- forgecli/runtime/wrappers.py +150 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +239 -0
- forgecli/sdk/manager.py +678 -0
- forgecli/sdk/manifest.py +395 -0
- forgecli/sdk/sandbox.py +247 -0
- forgecli/sdk/version.py +313 -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 +121 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +78 -0
- forgecli/utils/stats.py +179 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
- forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
- forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
- forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
- 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.
|