forgeoptimizer 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Cross-platform paths, env files, and config directory resolution.
|
|
2
|
+
|
|
3
|
+
* :class:`ProjectPaths` — a typed bundle of well-known paths
|
|
4
|
+
(config, data, cache, log, prompts, plugins) computed from
|
|
5
|
+
environment variables with sensible XDG-style defaults via
|
|
6
|
+
:mod:`platformdirs`.
|
|
7
|
+
* :func:`load_dotenv` — a minimal ``.env`` loader that does **not**
|
|
8
|
+
overwrite existing environment variables.
|
|
9
|
+
* :func:`config_dir` / :func:`data_dir` / :func:`state_dir` — single
|
|
10
|
+
helpers for callers that only need one path.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from forgecli.platform.core import (
|
|
20
|
+
is_macos,
|
|
21
|
+
is_windows,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Directory helpers
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _xdg_path(env_var: str, *fallback: Path) -> Path:
|
|
30
|
+
"""Return ``$env_var`` if set, else the first existing ``fallback``."""
|
|
31
|
+
value = os.environ.get(env_var)
|
|
32
|
+
if value:
|
|
33
|
+
return Path(value).expanduser()
|
|
34
|
+
for candidate in fallback:
|
|
35
|
+
if candidate.exists():
|
|
36
|
+
return candidate
|
|
37
|
+
return fallback[0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def config_dir() -> Path:
|
|
41
|
+
"""Return the per-user config directory for ForgeCLI.
|
|
42
|
+
|
|
43
|
+
Honors ``FORGECLI_CONFIG_DIR`` and the platform-standard
|
|
44
|
+
XDG / Apple / Windows variables (``XDG_CONFIG_HOME`` /
|
|
45
|
+
``HOME`` / ``APPDATA``). Creates the directory if missing.
|
|
46
|
+
"""
|
|
47
|
+
if is_windows():
|
|
48
|
+
base = Path(os.environ.get("APPDATA") or (Path.home() / "AppData" / "Roaming"))
|
|
49
|
+
elif is_macos():
|
|
50
|
+
base = Path.home() / "Library" / "Application Support"
|
|
51
|
+
else:
|
|
52
|
+
base = _xdg_path("XDG_CONFIG_HOME", Path.home() / ".config")
|
|
53
|
+
path = base / "forgecli"
|
|
54
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
return path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def data_dir() -> Path:
|
|
59
|
+
"""Return the per-user data directory for ForgeCLI."""
|
|
60
|
+
override = _coerce_path("FORGECLI_DATA_DIR")
|
|
61
|
+
if override is not None:
|
|
62
|
+
return override
|
|
63
|
+
if is_windows():
|
|
64
|
+
base = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
|
|
65
|
+
elif is_macos():
|
|
66
|
+
base = Path.home() / "Library" / "Application Support"
|
|
67
|
+
else:
|
|
68
|
+
base = _xdg_path("XDG_DATA_HOME", Path.home() / ".local" / "share")
|
|
69
|
+
path = base / "forgecli"
|
|
70
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
return path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def state_dir() -> Path:
|
|
75
|
+
"""Return the per-user state directory (logs, caches)."""
|
|
76
|
+
if is_windows():
|
|
77
|
+
base = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
|
|
78
|
+
elif is_macos():
|
|
79
|
+
base = Path.home() / "Library" / "Application Support"
|
|
80
|
+
else:
|
|
81
|
+
base = _xdg_path("XDG_STATE_HOME", Path.home() / ".local" / "state")
|
|
82
|
+
path = base / "forgecli"
|
|
83
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
return path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# .env loader
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_dotenv(
|
|
93
|
+
path: Path | str | None = None,
|
|
94
|
+
*,
|
|
95
|
+
override: bool = False,
|
|
96
|
+
encoding: str = "utf-8",
|
|
97
|
+
) -> dict[str, str]:
|
|
98
|
+
"""Load a ``.env`` file into :data:`os.environ`.
|
|
99
|
+
|
|
100
|
+
Returns the dict of *new* (or *overridden*) variables. Existing
|
|
101
|
+
environment variables are preserved unless ``override=True``.
|
|
102
|
+
|
|
103
|
+
Comments (``#``) and blank lines are skipped. Quoted values are
|
|
104
|
+
stripped; whitespace around the ``=`` is trimmed.
|
|
105
|
+
"""
|
|
106
|
+
candidate = Path(path) if path is not None else Path.cwd() / ".env"
|
|
107
|
+
if not candidate.exists():
|
|
108
|
+
return {}
|
|
109
|
+
try:
|
|
110
|
+
text = candidate.read_text(encoding=encoding, errors="replace")
|
|
111
|
+
except OSError:
|
|
112
|
+
return {}
|
|
113
|
+
|
|
114
|
+
loaded: dict[str, str] = {}
|
|
115
|
+
for raw in text.splitlines():
|
|
116
|
+
line = raw.strip()
|
|
117
|
+
if not line or line.startswith("#"):
|
|
118
|
+
continue
|
|
119
|
+
if "=" not in line:
|
|
120
|
+
continue
|
|
121
|
+
key, value = line.split("=", 1)
|
|
122
|
+
key = key.strip()
|
|
123
|
+
value = value.strip()
|
|
124
|
+
# Strip a single layer of matching quotes.
|
|
125
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
126
|
+
value = value[1:-1]
|
|
127
|
+
if not key:
|
|
128
|
+
continue
|
|
129
|
+
if key in os.environ and not override:
|
|
130
|
+
continue
|
|
131
|
+
os.environ[key] = value
|
|
132
|
+
loaded[key] = value
|
|
133
|
+
return loaded
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# ProjectPaths
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class ProjectPaths:
|
|
143
|
+
"""All well-known filesystem locations for ForgeCLI."""
|
|
144
|
+
|
|
145
|
+
cwd: Path
|
|
146
|
+
config_dir: Path
|
|
147
|
+
data_dir: Path
|
|
148
|
+
cache_dir: Path
|
|
149
|
+
logs_dir: Path
|
|
150
|
+
prompts_dir: Path
|
|
151
|
+
plugins_dir: Path
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def from_env(
|
|
155
|
+
cls, *, cwd: Path | str | None = None
|
|
156
|
+
) -> ProjectPaths:
|
|
157
|
+
"""Resolve the default project paths for the current user.
|
|
158
|
+
|
|
159
|
+
Environment variables take precedence: ``FORGECLI_DATA_DIR``,
|
|
160
|
+
``FORGECLI_CONFIG_DIR``, ``FORGECLI_CACHE_DIR``.
|
|
161
|
+
"""
|
|
162
|
+
config = _coerce_path("FORGECLI_CONFIG_DIR") or config_dir()
|
|
163
|
+
data = _coerce_path("FORGECLI_DATA_DIR") or data_dir()
|
|
164
|
+
cache = _coerce_path("FORGECLI_CACHE_DIR") or _cache_dir()
|
|
165
|
+
logs = data / "logs"
|
|
166
|
+
prompts = config / "prompts"
|
|
167
|
+
plugins = config / "plugins"
|
|
168
|
+
for path in (config, data, cache, logs, prompts, plugins):
|
|
169
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
return cls(
|
|
171
|
+
cwd=Path(cwd) if cwd is not None else Path.cwd(),
|
|
172
|
+
config_dir=config,
|
|
173
|
+
data_dir=data,
|
|
174
|
+
cache_dir=cache,
|
|
175
|
+
logs_dir=logs,
|
|
176
|
+
prompts_dir=prompts,
|
|
177
|
+
plugins_dir=plugins,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _cache_dir() -> Path:
|
|
182
|
+
"""Return the cache directory, creating it if needed."""
|
|
183
|
+
if is_windows():
|
|
184
|
+
base = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
|
|
185
|
+
elif is_macos():
|
|
186
|
+
base = Path.home() / "Library" / "Caches"
|
|
187
|
+
else:
|
|
188
|
+
base = _xdg_path("XDG_CACHE_HOME", Path.home() / ".cache")
|
|
189
|
+
path = base / "forgecli"
|
|
190
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
return path
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _coerce_path(env_var: str) -> Path | None:
|
|
195
|
+
raw = os.environ.get(env_var)
|
|
196
|
+
if raw is None or raw.strip() == "":
|
|
197
|
+
return None
|
|
198
|
+
path = Path(raw).expanduser()
|
|
199
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
return path
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Tiny utilities
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def ensure_directory(path: Path | str) -> Path:
|
|
209
|
+
"""Create ``path`` (and parents) if it does not exist; return it."""
|
|
210
|
+
p = Path(path).expanduser()
|
|
211
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
return p
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def merge_paths(*paths: Path | str) -> Path:
|
|
216
|
+
"""Return ``paths[0] / paths[1] / ... / paths[-1]`` as a :class:`Path`."""
|
|
217
|
+
if not paths:
|
|
218
|
+
raise ValueError("merge_paths requires at least one path")
|
|
219
|
+
out = Path(paths[0])
|
|
220
|
+
for tail in paths[1:]:
|
|
221
|
+
out = out / tail
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
__all__ = [
|
|
226
|
+
"ProjectPaths",
|
|
227
|
+
"config_dir",
|
|
228
|
+
"data_dir",
|
|
229
|
+
"ensure_directory",
|
|
230
|
+
"load_dotenv",
|
|
231
|
+
"merge_paths",
|
|
232
|
+
"state_dir",
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# Silence unused-import warnings.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Shell adapter — never hardcodes POSIX-only commands.
|
|
2
|
+
|
|
3
|
+
This module is the *only* place that may invoke
|
|
4
|
+
:func:`subprocess.run` / :func:`asyncio.create_subprocess_shell` for
|
|
5
|
+
shell-style invocations. All callers go through :func:`run`, which:
|
|
6
|
+
|
|
7
|
+
* never relies on ``/bin/sh`` parsing on Windows (it uses
|
|
8
|
+
:class:`subprocess.Popen` with ``shell=False`` and a list argv);
|
|
9
|
+
* quotes arguments via :func:`shell_quote` (which understands
|
|
10
|
+
POSIX shells *and* ``cmd.exe`` quoting rules);
|
|
11
|
+
* enforces a timeout;
|
|
12
|
+
* returns a :class:`ShellResult` that captures stdout/stderr
|
|
13
|
+
decoded as UTF-8 (with replacement) — never ``bytes`` leaking
|
|
14
|
+
out of the platform layer.
|
|
15
|
+
|
|
16
|
+
The module also exposes a small ``run_capture`` helper used by
|
|
17
|
+
:mod:`forgecli.graph.client` and friends.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import os
|
|
24
|
+
import shlex
|
|
25
|
+
import subprocess
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from forgecli.platform.core import is_windows
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ShellResult:
|
|
36
|
+
"""The structured output of a shell invocation."""
|
|
37
|
+
|
|
38
|
+
returncode: int
|
|
39
|
+
stdout: str
|
|
40
|
+
stderr: str
|
|
41
|
+
command: tuple[str, ...]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def ok(self) -> bool:
|
|
45
|
+
return self.returncode == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def shell_quote(arg: str) -> str:
|
|
49
|
+
"""Quote ``arg`` for the current platform's shell.
|
|
50
|
+
|
|
51
|
+
On POSIX we use :func:`shlex.quote`; on Windows we use
|
|
52
|
+
:func:`subprocess.list2cmdline` (the same algorithm ``cmd.exe``
|
|
53
|
+
uses internally for argv joining).
|
|
54
|
+
"""
|
|
55
|
+
if is_windows():
|
|
56
|
+
# list2cmdline accepts a list, not a single string. We wrap the
|
|
57
|
+
# single arg in a list.
|
|
58
|
+
return subprocess.list2cmdline([arg])
|
|
59
|
+
return shlex.quote(arg)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run(
|
|
63
|
+
args: Sequence[str] | str,
|
|
64
|
+
*,
|
|
65
|
+
cwd: Path | str | None = None,
|
|
66
|
+
env: dict[str, str] | None = None,
|
|
67
|
+
timeout: float | None = None,
|
|
68
|
+
check: bool = False,
|
|
69
|
+
input: str | bytes | None = None,
|
|
70
|
+
) -> ShellResult:
|
|
71
|
+
"""Run a subprocess and return a :class:`ShellResult`.
|
|
72
|
+
|
|
73
|
+
``args`` may be either a list (preferred — avoids shell parsing
|
|
74
|
+
entirely) or a string (in which case the call goes through
|
|
75
|
+
the platform's default shell).
|
|
76
|
+
|
|
77
|
+
On Windows, ``args`` is always coerced to a list to avoid
|
|
78
|
+
invoking ``cmd.exe``; on POSIX, list args are exec'd directly.
|
|
79
|
+
"""
|
|
80
|
+
argv: tuple[str, ...]
|
|
81
|
+
use_shell: bool
|
|
82
|
+
if isinstance(args, str):
|
|
83
|
+
if is_windows():
|
|
84
|
+
# The user gave us a string; pass it to cmd.exe explicitly
|
|
85
|
+
# so we don't depend on the default shell being sh.
|
|
86
|
+
argv = ("cmd.exe", "/c", args)
|
|
87
|
+
use_shell = False
|
|
88
|
+
else:
|
|
89
|
+
argv = (args,)
|
|
90
|
+
use_shell = True
|
|
91
|
+
else:
|
|
92
|
+
argv = tuple(args)
|
|
93
|
+
use_shell = False
|
|
94
|
+
|
|
95
|
+
merged_env: dict[str, str] | None = None
|
|
96
|
+
if env is not None:
|
|
97
|
+
merged_env = os.environ.copy()
|
|
98
|
+
merged_env.update({k: v for k, v in env.items() if v is not None})
|
|
99
|
+
|
|
100
|
+
completed = subprocess.run(
|
|
101
|
+
list(argv),
|
|
102
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
103
|
+
env=merged_env,
|
|
104
|
+
input=input,
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
timeout=timeout,
|
|
108
|
+
check=check,
|
|
109
|
+
shell=use_shell,
|
|
110
|
+
)
|
|
111
|
+
return ShellResult(
|
|
112
|
+
returncode=completed.returncode,
|
|
113
|
+
stdout=completed.stdout,
|
|
114
|
+
stderr=completed.stderr,
|
|
115
|
+
command=argv,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def run_capture(
|
|
120
|
+
args: Sequence[str] | str,
|
|
121
|
+
*,
|
|
122
|
+
cwd: Path | str | None = None,
|
|
123
|
+
env: dict[str, str] | None = None,
|
|
124
|
+
timeout: float | None = None,
|
|
125
|
+
input: str | bytes | None = None,
|
|
126
|
+
) -> ShellResult:
|
|
127
|
+
"""Async wrapper around :func:`run`."""
|
|
128
|
+
argv: tuple[str, ...]
|
|
129
|
+
if isinstance(args, str):
|
|
130
|
+
argv = ("cmd.exe", "/c", args) if is_windows() else (args,)
|
|
131
|
+
else:
|
|
132
|
+
argv = tuple(args)
|
|
133
|
+
|
|
134
|
+
merged_env: dict[str, str] | None = None
|
|
135
|
+
if env is not None:
|
|
136
|
+
merged_env = os.environ.copy()
|
|
137
|
+
merged_env.update({k: v for k, v in env.items() if v is not None})
|
|
138
|
+
|
|
139
|
+
process = await asyncio.create_subprocess_exec(
|
|
140
|
+
*argv,
|
|
141
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
142
|
+
env=merged_env,
|
|
143
|
+
stdin=asyncio.subprocess.PIPE if input is not None else None,
|
|
144
|
+
stdout=asyncio.subprocess.PIPE,
|
|
145
|
+
stderr=asyncio.subprocess.PIPE,
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
if input is None:
|
|
149
|
+
stdout_b, stderr_b = await process.communicate()
|
|
150
|
+
else:
|
|
151
|
+
stdin_data = input.encode("utf-8") if isinstance(input, str) else input
|
|
152
|
+
stdout_b, stderr_b = await process.communicate(stdin_data)
|
|
153
|
+
except TimeoutError as exc:
|
|
154
|
+
process.kill()
|
|
155
|
+
raise TimeoutError(f"subprocess timed out after {timeout}s") from exc
|
|
156
|
+
return ShellResult(
|
|
157
|
+
returncode=process.returncode or 0,
|
|
158
|
+
stdout=stdout_b.decode("utf-8", errors="replace"),
|
|
159
|
+
stderr=stderr_b.decode("utf-8", errors="replace"),
|
|
160
|
+
command=argv,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def which(executable: str) -> str | None:
|
|
165
|
+
"""Return the absolute path of ``executable`` or None.
|
|
166
|
+
|
|
167
|
+
Thin wrapper around :func:`shutil.which` that swallows the
|
|
168
|
+
``PATHEXT`` quirks on Windows (shutil already handles those).
|
|
169
|
+
"""
|
|
170
|
+
import shutil
|
|
171
|
+
|
|
172
|
+
return shutil.which(executable)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Silence unused-import warnings for symbols only used in some branches.
|
|
176
|
+
_ = Any
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Update check + version reporting.
|
|
2
|
+
|
|
3
|
+
ForgeCLI queries the public PyPI JSON API for the latest release
|
|
4
|
+
of the project and surfaces a friendly "update available" notice.
|
|
5
|
+
|
|
6
|
+
The check is:
|
|
7
|
+
|
|
8
|
+
* **Off by default.** The CLI never hits the network unless the
|
|
9
|
+
user runs ``forge --check-update`` or sets
|
|
10
|
+
``FORGECLI_CHECK_UPDATE=1``.
|
|
11
|
+
* **Cached.** The latest version is cached in
|
|
12
|
+
``$config_dir/update.json`` for ``FORGECLI_UPDATE_CACHE_TTL``
|
|
13
|
+
seconds (default 24h).
|
|
14
|
+
* **Quiet on failure.** Network errors return ``None`` and are
|
|
15
|
+
swallowed; the CLI should still launch when offline.
|
|
16
|
+
* **Configurable.** ``FORGECLI_PYPI_URL`` overrides the registry
|
|
17
|
+
base URL (useful for staging builds and tests).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Final
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
from forgecli import __version__ as _current_version
|
|
33
|
+
from forgecli.platform.core import getenv
|
|
34
|
+
from forgecli.platform.paths import data_dir, ensure_directory
|
|
35
|
+
|
|
36
|
+
DEFAULT_PYPI_URL: Final[str] = "https://pypi.org/pypi/forgecli/json"
|
|
37
|
+
DEFAULT_TTL_SECONDS: Final[int] = 86_400 # 24h
|
|
38
|
+
HTTP_TIMEOUT_SECONDS: Final[float] = 5.0
|
|
39
|
+
CACHE_FILENAME: Final[str] = "update.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class UpdateInfo:
|
|
44
|
+
"""The result of a version check."""
|
|
45
|
+
|
|
46
|
+
current: str
|
|
47
|
+
latest: str | None
|
|
48
|
+
update_available: bool
|
|
49
|
+
checked_at: datetime
|
|
50
|
+
error: str | None = None
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, object]:
|
|
53
|
+
return {
|
|
54
|
+
"current": self.current,
|
|
55
|
+
"latest": self.latest,
|
|
56
|
+
"update_available": self.update_available,
|
|
57
|
+
"checked_at": self.checked_at.isoformat(),
|
|
58
|
+
"error": self.error,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def current_version() -> str:
|
|
63
|
+
"""Return the running ForgeCLI version string."""
|
|
64
|
+
return _current_version
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cache_path() -> Path:
|
|
68
|
+
"""Return the path to the update cache file."""
|
|
69
|
+
directory = data_dir()
|
|
70
|
+
ensure_directory(directory)
|
|
71
|
+
return directory / CACHE_FILENAME
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read_cache(*, ignore_expiry: bool = False) -> dict[str, object] | None:
|
|
75
|
+
"""Return the cached update payload, or None if missing/expired."""
|
|
76
|
+
path = _cache_path()
|
|
77
|
+
if not path.exists():
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
81
|
+
except (OSError, json.JSONDecodeError):
|
|
82
|
+
return None
|
|
83
|
+
if not isinstance(payload, dict):
|
|
84
|
+
return None
|
|
85
|
+
checked_at = payload.get("checked_at")
|
|
86
|
+
if not isinstance(checked_at, (int, float)):
|
|
87
|
+
return None
|
|
88
|
+
if not ignore_expiry and (time.time() - float(checked_at)) > _cache_ttl():
|
|
89
|
+
return None
|
|
90
|
+
return payload
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _write_cache(latest: str) -> None:
|
|
94
|
+
"""Write the latest version to the cache file."""
|
|
95
|
+
with contextlib.suppress(OSError):
|
|
96
|
+
_cache_path().write_text(
|
|
97
|
+
json.dumps(
|
|
98
|
+
{
|
|
99
|
+
"checked_at": time.time(),
|
|
100
|
+
"latest": latest,
|
|
101
|
+
}
|
|
102
|
+
),
|
|
103
|
+
encoding="utf-8",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cache_ttl() -> int:
|
|
108
|
+
raw = getenv("FORGECLI_UPDATE_CACHE_TTL")
|
|
109
|
+
if raw is None:
|
|
110
|
+
return DEFAULT_TTL_SECONDS
|
|
111
|
+
try:
|
|
112
|
+
return max(0, int(raw))
|
|
113
|
+
except ValueError:
|
|
114
|
+
return DEFAULT_TTL_SECONDS
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pypi_url() -> str:
|
|
118
|
+
return getenv("FORGECLI_PYPI_URL") or DEFAULT_PYPI_URL
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_version(payload: dict[str, object]) -> str | None:
|
|
122
|
+
"""Pull the latest version string out of a PyPI JSON payload."""
|
|
123
|
+
info = payload.get("info")
|
|
124
|
+
if not isinstance(info, dict):
|
|
125
|
+
return None
|
|
126
|
+
version = info.get("version")
|
|
127
|
+
return version if isinstance(version, str) else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_for_update(
|
|
131
|
+
*,
|
|
132
|
+
force: bool = False,
|
|
133
|
+
client_factory=None,
|
|
134
|
+
) -> UpdateInfo:
|
|
135
|
+
"""Return an :class:`UpdateInfo` for the current host.
|
|
136
|
+
|
|
137
|
+
Reads the cache first; if the cache is fresh, returns its
|
|
138
|
+
contents. Otherwise queries PyPI and updates the cache.
|
|
139
|
+
"""
|
|
140
|
+
if not force:
|
|
141
|
+
cached = _read_cache()
|
|
142
|
+
if cached is not None:
|
|
143
|
+
latest = cached.get("latest")
|
|
144
|
+
if isinstance(latest, str):
|
|
145
|
+
return _build_info(latest)
|
|
146
|
+
|
|
147
|
+
factory = client_factory or _default_client
|
|
148
|
+
try:
|
|
149
|
+
with factory() as client:
|
|
150
|
+
response = client.get(_pypi_url())
|
|
151
|
+
response.raise_for_status()
|
|
152
|
+
payload = response.json()
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
cached = _read_cache(ignore_expiry=True)
|
|
155
|
+
if cached is not None:
|
|
156
|
+
latest = cached.get("latest")
|
|
157
|
+
if isinstance(latest, str):
|
|
158
|
+
return UpdateInfo(
|
|
159
|
+
current=current_version(),
|
|
160
|
+
latest=latest,
|
|
161
|
+
update_available=_is_newer(latest, current_version()),
|
|
162
|
+
checked_at=datetime.now(UTC),
|
|
163
|
+
error=f"Offline fallback (error: {exc!r})",
|
|
164
|
+
)
|
|
165
|
+
return UpdateInfo(
|
|
166
|
+
current=current_version(),
|
|
167
|
+
latest=None,
|
|
168
|
+
update_available=False,
|
|
169
|
+
checked_at=datetime.now(UTC),
|
|
170
|
+
error=repr(exc),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
latest = _parse_version(payload) if isinstance(payload, dict) else None
|
|
174
|
+
if latest is not None:
|
|
175
|
+
_write_cache(latest)
|
|
176
|
+
return _build_info(latest)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _build_info(latest: str | None) -> UpdateInfo:
|
|
180
|
+
"""Return an :class:`UpdateInfo` for ``latest`` (may be None)."""
|
|
181
|
+
current = current_version()
|
|
182
|
+
update_available = latest is not None and _is_newer(latest, current)
|
|
183
|
+
return UpdateInfo(
|
|
184
|
+
current=current,
|
|
185
|
+
latest=latest,
|
|
186
|
+
update_available=update_available,
|
|
187
|
+
checked_at=datetime.now(UTC),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_newer(latest: str, current: str) -> bool:
|
|
192
|
+
"""Return True when ``latest`` is a newer version than ``current``.
|
|
193
|
+
|
|
194
|
+
Uses a simple tuple comparison on dotted-version segments. We
|
|
195
|
+
don't try to handle pre-releases / post-releases specially —
|
|
196
|
+
those are advisory at best and "newer" is what the user usually
|
|
197
|
+
wants to see.
|
|
198
|
+
"""
|
|
199
|
+
def _parts(version: str) -> tuple[int, ...]:
|
|
200
|
+
out: list[int] = []
|
|
201
|
+
for chunk in version.split("."):
|
|
202
|
+
digits = ""
|
|
203
|
+
for ch in chunk:
|
|
204
|
+
if ch.isdigit():
|
|
205
|
+
digits += ch
|
|
206
|
+
else:
|
|
207
|
+
break
|
|
208
|
+
if digits:
|
|
209
|
+
out.append(int(digits))
|
|
210
|
+
else:
|
|
211
|
+
out.append(0)
|
|
212
|
+
return tuple(out)
|
|
213
|
+
|
|
214
|
+
return _parts(latest) > _parts(current)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _default_client():
|
|
218
|
+
"""Return a default :class:`httpx.Client`.
|
|
219
|
+
|
|
220
|
+
Honors the ``FORGECLI_NO_PROXY`` and ``HTTP_PROXY`` env vars;
|
|
221
|
+
sets a short timeout so the check never blocks the CLI.
|
|
222
|
+
"""
|
|
223
|
+
trust_env = bool(getenv("FORGECLI_TRUST_ENV"))
|
|
224
|
+
return httpx.Client(timeout=HTTP_TIMEOUT_SECONDS, trust_env=trust_env)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Public utilities
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def should_check_on_startup() -> bool:
|
|
233
|
+
"""Return True if the CLI should check for updates at startup."""
|
|
234
|
+
if getenv("FORGECLI_NO_UPDATE_CHECK") == "1":
|
|
235
|
+
return False
|
|
236
|
+
return getenv("FORGECLI_CHECK_UPDATE") == "1"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def upgrade_command() -> str:
|
|
240
|
+
"""Return the platform-appropriate upgrade command line."""
|
|
241
|
+
return "uv tool upgrade forgecli (or: pip install --upgrade forgecli)"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
__all__ = [
|
|
245
|
+
"DEFAULT_PYPI_URL",
|
|
246
|
+
"DEFAULT_TTL_SECONDS",
|
|
247
|
+
"UpdateInfo",
|
|
248
|
+
"check_for_update",
|
|
249
|
+
"current_version",
|
|
250
|
+
"should_check_on_startup",
|
|
251
|
+
"upgrade_command",
|
|
252
|
+
]
|