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,234 @@
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(cls, *, cwd: Path | str | None = None) -> ProjectPaths:
155
+ """Resolve the default project paths for the current user.
156
+
157
+ Environment variables take precedence: ``FORGECLI_DATA_DIR``,
158
+ ``FORGECLI_CONFIG_DIR``, ``FORGECLI_CACHE_DIR``.
159
+ """
160
+ config = _coerce_path("FORGECLI_CONFIG_DIR") or config_dir()
161
+ data = _coerce_path("FORGECLI_DATA_DIR") or data_dir()
162
+ cache = _coerce_path("FORGECLI_CACHE_DIR") or _cache_dir()
163
+ logs = data / "logs"
164
+ prompts = config / "prompts"
165
+ plugins = config / "plugins"
166
+ for path in (config, data, cache, logs, prompts, plugins):
167
+ path.mkdir(parents=True, exist_ok=True)
168
+ return cls(
169
+ cwd=Path(cwd) if cwd is not None else Path.cwd(),
170
+ config_dir=config,
171
+ data_dir=data,
172
+ cache_dir=cache,
173
+ logs_dir=logs,
174
+ prompts_dir=prompts,
175
+ plugins_dir=plugins,
176
+ )
177
+
178
+
179
+ def _cache_dir() -> Path:
180
+ """Return the cache directory, creating it if needed."""
181
+ if is_windows():
182
+ base = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
183
+ elif is_macos():
184
+ base = Path.home() / "Library" / "Caches"
185
+ else:
186
+ base = _xdg_path("XDG_CACHE_HOME", Path.home() / ".cache")
187
+ path = base / "forgecli"
188
+ path.mkdir(parents=True, exist_ok=True)
189
+ return path
190
+
191
+
192
+ def _coerce_path(env_var: str) -> Path | None:
193
+ raw = os.environ.get(env_var)
194
+ if raw is None or raw.strip() == "":
195
+ return None
196
+ path = Path(raw).expanduser()
197
+ path.mkdir(parents=True, exist_ok=True)
198
+ return path
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Tiny utilities
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ def ensure_directory(path: Path | str) -> Path:
207
+ """Create ``path`` (and parents) if it does not exist; return it."""
208
+ p = Path(path).expanduser()
209
+ p.mkdir(parents=True, exist_ok=True)
210
+ return p
211
+
212
+
213
+ def merge_paths(*paths: Path | str) -> Path:
214
+ """Return ``paths[0] / paths[1] / ... / paths[-1]`` as a :class:`Path`."""
215
+ if not paths:
216
+ raise ValueError("merge_paths requires at least one path")
217
+ out = Path(paths[0])
218
+ for tail in paths[1:]:
219
+ out = out / tail
220
+ return out
221
+
222
+
223
+ __all__ = [
224
+ "ProjectPaths",
225
+ "config_dir",
226
+ "data_dir",
227
+ "ensure_directory",
228
+ "load_dotenv",
229
+ "merge_paths",
230
+ "state_dir",
231
+ ]
232
+
233
+
234
+ # 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,253 @@
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
+
200
+ def _parts(version: str) -> tuple[int, ...]:
201
+ out: list[int] = []
202
+ for chunk in version.split("."):
203
+ digits = ""
204
+ for ch in chunk:
205
+ if ch.isdigit():
206
+ digits += ch
207
+ else:
208
+ break
209
+ if digits:
210
+ out.append(int(digits))
211
+ else:
212
+ out.append(0)
213
+ return tuple(out)
214
+
215
+ return _parts(latest) > _parts(current)
216
+
217
+
218
+ def _default_client():
219
+ """Return a default :class:`httpx.Client`.
220
+
221
+ Honors the ``FORGECLI_NO_PROXY`` and ``HTTP_PROXY`` env vars;
222
+ sets a short timeout so the check never blocks the CLI.
223
+ """
224
+ trust_env = bool(getenv("FORGECLI_TRUST_ENV"))
225
+ return httpx.Client(timeout=HTTP_TIMEOUT_SECONDS, trust_env=trust_env)
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Public utilities
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def should_check_on_startup() -> bool:
234
+ """Return True if the CLI should check for updates at startup."""
235
+ if getenv("FORGECLI_NO_UPDATE_CHECK") == "1":
236
+ return False
237
+ return getenv("FORGECLI_CHECK_UPDATE") == "1"
238
+
239
+
240
+ def upgrade_command() -> str:
241
+ """Return the platform-appropriate upgrade command line."""
242
+ return "uv tool upgrade forgecli (or: pip install --upgrade forgecli)"
243
+
244
+
245
+ __all__ = [
246
+ "DEFAULT_PYPI_URL",
247
+ "DEFAULT_TTL_SECONDS",
248
+ "UpdateInfo",
249
+ "check_for_update",
250
+ "current_version",
251
+ "should_check_on_startup",
252
+ "upgrade_command",
253
+ ]