ggcode 1.1.1__tar.gz → 1.1.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ggcode
3
- Version: 1.1.1
3
+ Version: 1.1.3
4
4
  Summary: Thin Python wrapper that installs the ggcode GitHub Release binary
5
5
  Author: topcheer
6
6
  License: MIT
@@ -12,10 +12,16 @@ Description-Content-Type: text/markdown
12
12
 
13
13
  # ggcode Python wrapper
14
14
 
15
- `ggcode` on PyPI is a thin Python wrapper for the `ggcode` terminal agent.
15
+ `ggcode` on PyPI bootstraps the native `ggcode` terminal agent from GitHub Releases.
16
16
 
17
- It does not ship the platform binary inside the wheel. Instead, the wrapper downloads the latest
18
- matching `ggcode` GitHub Release on first run, caches it locally, and then launches it.
17
+ When the bootstrap runs, it installs the real binary into a stable CLI location instead of keeping
18
+ it in a wrapper-managed cache:
19
+
20
+ - macOS / Linux: prefers `/usr/local/bin`, falls back to `~/.local/bin`
21
+ - Windows: prefers `%USERPROFILE%\\AppData\\Local\\Programs\\ggcode\\bin`, falls back to `%USERPROFILE%\\.local\\bin`
22
+
23
+ If that directory is not already on `PATH`, the bootstrap updates your PATH configuration and asks
24
+ you to reopen the terminal so future `ggcode` launches resolve directly to the native binary.
19
25
 
20
26
  ## Install
21
27
 
@@ -29,12 +35,19 @@ Then run:
29
35
  ggcode
30
36
  ```
31
37
 
38
+ If you ever need to rerun the bootstrap flow explicitly, you can also use:
39
+
40
+ ```bash
41
+ ggcode-bootstrap
42
+ ```
43
+
32
44
  ## What it does
33
45
 
34
46
  - Detects your operating system and CPU architecture
35
47
  - Downloads the latest matching `ggcode` archive from GitHub Releases
36
48
  - Verifies the archive against `checksums.txt`
37
- - Extracts and caches the binary for future runs
49
+ - Installs the real binary into a stable PATH location
50
+ - Updates PATH so future `ggcode` launches bypass the Python wrapper
38
51
 
39
52
  ## Pin a specific ggcode release
40
53
 
ggcode-1.1.3/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # ggcode Python wrapper
2
+
3
+ `ggcode` on PyPI bootstraps the native `ggcode` terminal agent from GitHub Releases.
4
+
5
+ When the bootstrap runs, it installs the real binary into a stable CLI location instead of keeping
6
+ it in a wrapper-managed cache:
7
+
8
+ - macOS / Linux: prefers `/usr/local/bin`, falls back to `~/.local/bin`
9
+ - Windows: prefers `%USERPROFILE%\\AppData\\Local\\Programs\\ggcode\\bin`, falls back to `%USERPROFILE%\\.local\\bin`
10
+
11
+ If that directory is not already on `PATH`, the bootstrap updates your PATH configuration and asks
12
+ you to reopen the terminal so future `ggcode` launches resolve directly to the native binary.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install ggcode
18
+ ```
19
+
20
+ Then run:
21
+
22
+ ```bash
23
+ ggcode
24
+ ```
25
+
26
+ If you ever need to rerun the bootstrap flow explicitly, you can also use:
27
+
28
+ ```bash
29
+ ggcode-bootstrap
30
+ ```
31
+
32
+ ## What it does
33
+
34
+ - Detects your operating system and CPU architecture
35
+ - Downloads the latest matching `ggcode` archive from GitHub Releases
36
+ - Verifies the archive against `checksums.txt`
37
+ - Installs the real binary into a stable PATH location
38
+ - Updates PATH so future `ggcode` launches bypass the Python wrapper
39
+
40
+ ## Pin a specific ggcode release
41
+
42
+ By default, the wrapper always resolves the latest `ggcode` release.
43
+
44
+ To force a specific release, set `GGCODE_INSTALL_VERSION`:
45
+
46
+ ```bash
47
+ GGCODE_INSTALL_VERSION=vX.Y.Z ggcode
48
+ ```
49
+
50
+ or:
51
+
52
+ ```bash
53
+ GGCODE_INSTALL_VERSION=X.Y.Z ggcode
54
+ ```
55
+
56
+ ## Supported platforms
57
+
58
+ - macOS
59
+ - Linux
60
+ - Windows
61
+
62
+ Supported architectures:
63
+
64
+ - x86_64 / amd64
65
+ - arm64
66
+
67
+ ## Project links
68
+
69
+ - Repository: https://github.com/topcheer/ggcode
70
+ - Issues: https://github.com/topcheer/ggcode/issues
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ggcode
3
- Version: 1.1.1
3
+ Version: 1.1.3
4
4
  Summary: Thin Python wrapper that installs the ggcode GitHub Release binary
5
5
  Author: topcheer
6
6
  License: MIT
@@ -12,10 +12,16 @@ Description-Content-Type: text/markdown
12
12
 
13
13
  # ggcode Python wrapper
14
14
 
15
- `ggcode` on PyPI is a thin Python wrapper for the `ggcode` terminal agent.
15
+ `ggcode` on PyPI bootstraps the native `ggcode` terminal agent from GitHub Releases.
16
16
 
17
- It does not ship the platform binary inside the wheel. Instead, the wrapper downloads the latest
18
- matching `ggcode` GitHub Release on first run, caches it locally, and then launches it.
17
+ When the bootstrap runs, it installs the real binary into a stable CLI location instead of keeping
18
+ it in a wrapper-managed cache:
19
+
20
+ - macOS / Linux: prefers `/usr/local/bin`, falls back to `~/.local/bin`
21
+ - Windows: prefers `%USERPROFILE%\\AppData\\Local\\Programs\\ggcode\\bin`, falls back to `%USERPROFILE%\\.local\\bin`
22
+
23
+ If that directory is not already on `PATH`, the bootstrap updates your PATH configuration and asks
24
+ you to reopen the terminal so future `ggcode` launches resolve directly to the native binary.
19
25
 
20
26
  ## Install
21
27
 
@@ -29,12 +35,19 @@ Then run:
29
35
  ggcode
30
36
  ```
31
37
 
38
+ If you ever need to rerun the bootstrap flow explicitly, you can also use:
39
+
40
+ ```bash
41
+ ggcode-bootstrap
42
+ ```
43
+
32
44
  ## What it does
33
45
 
34
46
  - Detects your operating system and CPU architecture
35
47
  - Downloads the latest matching `ggcode` archive from GitHub Releases
36
48
  - Verifies the archive against `checksums.txt`
37
- - Extracts and caches the binary for future runs
49
+ - Installs the real binary into a stable PATH location
50
+ - Updates PATH so future `ggcode` launches bypass the Python wrapper
38
51
 
39
52
  ## Pin a specific ggcode release
40
53
 
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ggcode = ggcode_release_installer.cli:main
3
+ ggcode-bootstrap = ggcode_release_installer.cli:bootstrap_main
@@ -0,0 +1 @@
1
+ __version__ = "1.1.3"
@@ -0,0 +1,344 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import platform
8
+ import re
9
+ import shutil
10
+ import stat
11
+ import subprocess
12
+ import sys
13
+ import tarfile
14
+ import tempfile
15
+ import urllib.parse
16
+ import urllib.request
17
+ import zipfile
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ OWNER = "topcheer"
22
+ REPO = "ggcode"
23
+ MARKER_START = "# >>> ggcode PATH >>>"
24
+ MARKER_END = "# <<< ggcode PATH <<<"
25
+ METADATA = ".ggcode-wrapper.json"
26
+
27
+
28
+ @dataclass
29
+ class InstallResult:
30
+ binary_path: Path
31
+ install_dir: Path
32
+ version: str
33
+ path_updated: bool
34
+ installed_now: bool
35
+ needs_restart: bool
36
+ used_fallback: bool
37
+
38
+
39
+ def normalize_version() -> str:
40
+ selected = os.environ.get("GGCODE_INSTALL_VERSION", "").strip()
41
+ if not selected or selected == "latest":
42
+ return "latest"
43
+ if selected.startswith("v"):
44
+ return selected
45
+ return f"v{selected}"
46
+
47
+
48
+ def resolve_target() -> tuple[str, str]:
49
+ system = sys.platform
50
+ if system.startswith("linux"):
51
+ goos = "linux"
52
+ elif system == "darwin":
53
+ goos = "darwin"
54
+ elif system in ("win32", "cygwin"):
55
+ goos = "windows"
56
+ else:
57
+ raise RuntimeError(f"Unsupported platform: {system}")
58
+
59
+ machine = platform.machine().lower() or os.environ.get("PROCESSOR_ARCHITECTURE", "").lower()
60
+ if machine in ("x86_64", "amd64"):
61
+ arch = "x86_64"
62
+ elif machine in ("arm64", "aarch64"):
63
+ arch = "arm64"
64
+ else:
65
+ raise RuntimeError(f"Unsupported architecture: {machine or 'unknown'}")
66
+ return goos, arch
67
+
68
+
69
+ def release_base(version: str) -> str:
70
+ if version == "latest":
71
+ return f"https://github.com/{OWNER}/{REPO}/releases/latest/download"
72
+ return f"https://github.com/{OWNER}/{REPO}/releases/download/{version}"
73
+
74
+
75
+ def preferred_install_dirs() -> list[Path]:
76
+ home = Path.home()
77
+ if os.name == "nt":
78
+ return [
79
+ home / "AppData" / "Local" / "Programs" / "ggcode" / "bin",
80
+ home / ".local" / "bin",
81
+ ]
82
+ return [Path("/usr/local/bin"), home / ".local" / "bin"]
83
+
84
+
85
+ def metadata_path(directory: Path) -> Path:
86
+ return directory / METADATA
87
+
88
+
89
+ def download(url: str) -> bytes:
90
+ with urllib.request.urlopen(url) as response:
91
+ return response.read()
92
+
93
+
94
+ def resolve_release_version(version: str) -> str:
95
+ if version != "latest":
96
+ return version
97
+
98
+ with urllib.request.urlopen(f"https://github.com/{OWNER}/{REPO}/releases/latest") as response:
99
+ final_url = response.geturl()
100
+
101
+ parsed = urllib.parse.urlparse(final_url)
102
+ parts = [part for part in parsed.path.split("/") if part]
103
+ if len(parts) < 4 or parts[-2] != "tag":
104
+ raise RuntimeError(f"Could not resolve latest ggcode release from {final_url}")
105
+ return urllib.parse.unquote(parts[-1])
106
+
107
+
108
+ def parse_checksums(body: str) -> dict[str, str]:
109
+ checksums: dict[str, str] = {}
110
+ for raw_line in body.splitlines():
111
+ parts = raw_line.strip().split()
112
+ if len(parts) >= 2:
113
+ checksums[parts[-1]] = parts[0]
114
+ return checksums
115
+
116
+
117
+ def read_metadata(directory: Path) -> dict[str, str] | None:
118
+ try:
119
+ return json.loads(metadata_path(directory).read_text(encoding="utf-8"))
120
+ except (FileNotFoundError, json.JSONDecodeError):
121
+ return None
122
+
123
+
124
+ def write_metadata(directory: Path, version: str) -> None:
125
+ metadata_path(directory).write_text(json.dumps({"version": version}, indent=2) + "\n", encoding="utf-8")
126
+
127
+
128
+ def existing_install(requested_version: str, binary_name: str) -> InstallResult | None:
129
+ for directory in preferred_install_dirs():
130
+ binary_path = directory / binary_name
131
+ if not binary_path.exists():
132
+ continue
133
+ metadata = read_metadata(directory) or {}
134
+ if requested_version != "latest" and metadata.get("version") != requested_version:
135
+ continue
136
+ path_updated = ensure_installed_path(directory)
137
+ return InstallResult(
138
+ binary_path=binary_path,
139
+ install_dir=directory,
140
+ version=metadata.get("version", "unknown"),
141
+ path_updated=path_updated,
142
+ installed_now=False,
143
+ needs_restart=path_updated,
144
+ used_fallback=directory != preferred_install_dirs()[0],
145
+ )
146
+ return None
147
+
148
+
149
+ def ensure_installed() -> InstallResult:
150
+ requested_version = normalize_version()
151
+ goos, arch = resolve_target()
152
+ archive_ext = ".zip" if goos == "windows" else ".tar.gz"
153
+ archive_name = f"ggcode_{goos}_{arch}{archive_ext}"
154
+ binary_name = "ggcode.exe" if goos == "windows" else "ggcode"
155
+
156
+ existing = existing_install(requested_version, binary_name)
157
+ if existing is not None:
158
+ return existing
159
+
160
+ version = resolve_release_version(requested_version)
161
+ base = release_base(version)
162
+ archive = download(f"{base}/{archive_name}")
163
+ checksums = parse_checksums(download(f"{base}/checksums.txt").decode("utf-8"))
164
+ expected = checksums.get(archive_name)
165
+ if not expected:
166
+ raise RuntimeError(f"Checksum for {archive_name} not found")
167
+ actual = hashlib.sha256(archive).hexdigest()
168
+ if actual.lower() != expected.lower():
169
+ raise RuntimeError(f"Checksum mismatch for {archive_name}")
170
+
171
+ with tempfile.TemporaryDirectory(prefix="ggcode-py-") as temp_dir:
172
+ temp_root = Path(temp_dir)
173
+ archive_path = temp_root / archive_name
174
+ archive_path.write_bytes(archive)
175
+ extract_dir = temp_root / "extract"
176
+ extract_dir.mkdir(parents=True, exist_ok=True)
177
+
178
+ if archive_ext == ".zip":
179
+ with zipfile.ZipFile(archive_path) as zf:
180
+ zf.extractall(extract_dir)
181
+ else:
182
+ with tarfile.open(archive_path, "r:gz") as tf:
183
+ tf.extractall(extract_dir)
184
+
185
+ extracted = next((p for p in extract_dir.rglob(binary_name) if p.is_file()), None)
186
+ if extracted is None:
187
+ raise RuntimeError(f"Could not find {binary_name} in {archive_name}")
188
+
189
+ last_error: Exception | None = None
190
+ preferred = preferred_install_dirs()
191
+ for index, install_dir in enumerate(preferred):
192
+ try:
193
+ binary_path = install_binary(extracted, install_dir / binary_name, version)
194
+ path_updated = ensure_installed_path(install_dir)
195
+ return InstallResult(
196
+ binary_path=binary_path,
197
+ install_dir=install_dir,
198
+ version=version,
199
+ path_updated=path_updated,
200
+ installed_now=True,
201
+ needs_restart=path_updated,
202
+ used_fallback=index > 0,
203
+ )
204
+ except OSError as exc:
205
+ last_error = exc
206
+ if not is_permission_error(exc) or index == len(preferred) - 1:
207
+ break
208
+
209
+ if last_error is not None:
210
+ raise RuntimeError(f"Failed to install ggcode: {last_error}") from last_error
211
+
212
+ raise RuntimeError("Failed to install ggcode")
213
+
214
+
215
+ def install_binary(source: Path, destination: Path, version: str) -> Path:
216
+ destination.parent.mkdir(parents=True, exist_ok=True)
217
+ temp_path = destination.with_name(f"{destination.name}.tmp-{os.getpid()}")
218
+ shutil.copy2(source, temp_path)
219
+ if os.name != "nt":
220
+ current_mode = temp_path.stat().st_mode
221
+ temp_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
222
+ os.replace(temp_path, destination)
223
+ write_metadata(destination.parent, version)
224
+ return destination
225
+
226
+
227
+ def is_permission_error(exc: OSError) -> bool:
228
+ return exc.errno in {errno.EACCES, errno.EPERM}
229
+
230
+
231
+ def ensure_installed_path(directory: Path) -> bool:
232
+ entries = path_entries(os.environ.get("PATH", ""))
233
+ if any(same_path(entry, directory) for entry in entries):
234
+ return False
235
+ if os.name == "nt":
236
+ changed = ensure_windows_user_path(directory)
237
+ else:
238
+ changed = ensure_unix_path(directory)
239
+ os.environ["PATH"] = os.pathsep.join([str(directory), *entries])
240
+ return changed
241
+
242
+
243
+ def path_entries(value: str) -> list[str]:
244
+ return [entry.strip() for entry in value.split(os.pathsep) if entry.strip()]
245
+
246
+
247
+ def same_path(left: str | Path, right: str | Path) -> bool:
248
+ left_path = os.path.abspath(os.fspath(left))
249
+ right_path = os.path.abspath(os.fspath(right))
250
+ if os.name == "nt":
251
+ left_path = left_path.rstrip("\\/").lower()
252
+ right_path = right_path.rstrip("\\/").lower()
253
+ return left_path == right_path
254
+
255
+
256
+ def ensure_windows_user_path(directory: Path) -> bool:
257
+ escaped_dir = str(directory).replace("'", "''")
258
+ script = (
259
+ f"$dir = '{escaped_dir}'; "
260
+ "$current = [Environment]::GetEnvironmentVariable('Path', 'User'); "
261
+ "$parts = @(); "
262
+ "if ($current) { $parts = $current -split ';' | Where-Object { $_ -and $_.Trim() -ne '' } }; "
263
+ "$exists = $parts | Where-Object { $_.TrimEnd('\\\\') -ieq $dir.TrimEnd('\\\\') }; "
264
+ "if (-not $exists) { "
265
+ " $new = @($dir) + $parts; "
266
+ " [Environment]::SetEnvironmentVariable('Path', ($new -join ';'), 'User'); "
267
+ " Write-Output 'updated' "
268
+ "} else { "
269
+ " Write-Output 'unchanged' "
270
+ "}"
271
+ )
272
+ result = subprocess.run(
273
+ ["powershell", "-NoProfile", "-Command", script],
274
+ check=True,
275
+ text=True,
276
+ capture_output=True,
277
+ )
278
+ return result.stdout.strip() == "updated"
279
+
280
+
281
+ def ensure_unix_path(directory: Path) -> bool:
282
+ changed = False
283
+ for target in profile_targets():
284
+ before = target.read_text(encoding="utf-8") if target.exists() else ""
285
+ after = upsert_path_block(before, directory)
286
+ if after != before:
287
+ target.parent.mkdir(parents=True, exist_ok=True)
288
+ target.write_text(after, encoding="utf-8")
289
+ changed = True
290
+ return changed
291
+
292
+
293
+ def profile_targets() -> list[Path]:
294
+ home = Path.home()
295
+ shell = Path(os.environ.get("SHELL", "")).name
296
+ preferred: list[str] = []
297
+ if shell == "zsh":
298
+ preferred.extend([".zshrc", ".zprofile"])
299
+ elif shell == "bash":
300
+ preferred.extend([".bashrc", ".bash_profile"])
301
+ preferred.append(".profile")
302
+
303
+ existing = [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"]
304
+ targets: list[Path] = []
305
+ for name in preferred + [name for name in existing if (home / name).exists()]:
306
+ path = home / name
307
+ if path not in targets:
308
+ targets.append(path)
309
+ return targets
310
+
311
+
312
+ def upsert_path_block(content: str, directory: Path) -> str:
313
+ block = f'{MARKER_START}\nexport PATH="{directory}:$PATH"\n{MARKER_END}\n'
314
+ pattern = re.compile(re.escape(MARKER_START) + r"[\s\S]*?" + re.escape(MARKER_END) + r"\n?", re.MULTILINE)
315
+ if pattern.search(content):
316
+ return pattern.sub(block, content)
317
+ suffix = "\n" if content and not content.endswith("\n") else ""
318
+ return f"{content}{suffix}{block}"
319
+
320
+
321
+ def print_install_message(result: InstallResult) -> None:
322
+ action = "installed" if result.installed_now else "already available"
323
+ print(f"ggcode {result.version} {action} at {result.binary_path}", file=sys.stderr)
324
+ if result.needs_restart:
325
+ print("Reopen your terminal, then run `ggcode` directly.", file=sys.stderr)
326
+ print("If you ever need to rerun the bootstrap flow, use `ggcode-bootstrap`.", file=sys.stderr)
327
+
328
+
329
+ def main() -> int:
330
+ result = ensure_installed()
331
+ if result.installed_now or result.needs_restart:
332
+ print_install_message(result)
333
+ env = dict(os.environ)
334
+ env["GGCODE_WRAPPER_KIND"] = "python"
335
+ completed = subprocess.run([str(result.binary_path), *sys.argv[1:]], env=env)
336
+ return completed.returncode
337
+
338
+
339
+ def bootstrap_main() -> int:
340
+ return main()
341
+
342
+
343
+ if __name__ == "__main__":
344
+ raise SystemExit(main())
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ggcode"
7
- version = "1.1.1"
7
+ version = "1.1.3"
8
8
  description = "Thin Python wrapper that installs the ggcode GitHub Release binary"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -18,6 +18,7 @@ Issues = "https://github.com/topcheer/ggcode/issues"
18
18
 
19
19
  [project.scripts]
20
20
  ggcode = "ggcode_release_installer.cli:main"
21
+ ggcode-bootstrap = "ggcode_release_installer.cli:bootstrap_main"
21
22
 
22
23
  [tool.setuptools]
23
24
  packages = ["ggcode_release_installer"]
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ from ggcode_release_installer import cli
10
+
11
+
12
+ class ResolveTargetTests(unittest.TestCase):
13
+ def test_normalize_version_defaults_to_latest(self) -> None:
14
+ with patch.dict(os.environ, {}, clear=True):
15
+ self.assertEqual(cli.normalize_version(), "latest")
16
+
17
+ def test_normalize_version_uses_explicit_override(self) -> None:
18
+ with patch.dict(os.environ, {"GGCODE_INSTALL_VERSION": "1.2.3"}, clear=True):
19
+ self.assertEqual(cli.normalize_version(), "v1.2.3")
20
+
21
+ def test_resolve_target_uses_platform_module_for_machine(self) -> None:
22
+ with patch.object(cli.sys, "platform", "darwin"):
23
+ with patch.object(cli.platform, "machine", return_value="arm64"):
24
+ self.assertEqual(cli.resolve_target(), ("darwin", "arm64"))
25
+
26
+ def test_resolve_target_falls_back_to_processor_architecture(self) -> None:
27
+ env = dict(os.environ, PROCESSOR_ARCHITECTURE="AMD64")
28
+ with patch.dict(os.environ, env, clear=True):
29
+ with patch.object(cli.sys, "platform", "win32"):
30
+ with patch.object(cli.platform, "machine", return_value=""):
31
+ self.assertEqual(cli.resolve_target(), ("windows", "x86_64"))
32
+
33
+ def test_resolve_release_version_follows_latest_redirect(self) -> None:
34
+ response = unittest.mock.Mock()
35
+ response.geturl.return_value = "https://github.com/topcheer/ggcode/releases/tag/v9.9.9"
36
+ response.__enter__ = unittest.mock.Mock(return_value=response)
37
+ response.__exit__ = unittest.mock.Mock(return_value=False)
38
+ with patch.object(cli.urllib.request, "urlopen", return_value=response):
39
+ self.assertEqual(cli.resolve_release_version("latest"), "v9.9.9")
40
+
41
+
42
+ class PathHandlingTests(unittest.TestCase):
43
+ def test_preferred_install_dirs_unix(self) -> None:
44
+ with patch.object(cli, "os") as fake_os:
45
+ fake_os.name = "posix"
46
+ with patch.object(cli.Path, "home", return_value=Path("/home/tester")):
47
+ dirs = cli.preferred_install_dirs()
48
+ self.assertEqual(dirs, [Path("/usr/local/bin"), Path("/home/tester/.local/bin")])
49
+
50
+ def test_upsert_path_block_appends_marker(self) -> None:
51
+ updated = cli.upsert_path_block("export FOO=bar\n", Path("/tmp/ggcode-bin"))
52
+ self.assertIn(cli.MARKER_START, updated)
53
+ self.assertIn('export PATH="/tmp/ggcode-bin:$PATH"', updated)
54
+
55
+ def test_upsert_path_block_replaces_existing_marker(self) -> None:
56
+ original = (
57
+ f"{cli.MARKER_START}\n"
58
+ 'export PATH="/old/bin:$PATH"\n'
59
+ f"{cli.MARKER_END}\n"
60
+ )
61
+ updated = cli.upsert_path_block(original, Path("/new/bin"))
62
+ self.assertNotIn("/old/bin", updated)
63
+ self.assertIn('export PATH="/new/bin:$PATH"', updated)
64
+
65
+ def test_existing_install_uses_binary_without_resolving_latest(self) -> None:
66
+ with tempfile.TemporaryDirectory() as temp_dir:
67
+ install_dir = Path(temp_dir)
68
+ binary_path = install_dir / "ggcode"
69
+ binary_path.write_text("stub", encoding="utf-8")
70
+ cli.write_metadata(install_dir, "v1.0.21")
71
+
72
+ with patch.object(cli, "preferred_install_dirs", return_value=[install_dir]):
73
+ with patch.object(cli, "ensure_installed_path", return_value=False):
74
+ result = cli.existing_install("latest", "ggcode")
75
+
76
+ self.assertIsNotNone(result)
77
+ assert result is not None
78
+ self.assertEqual(result.binary_path, binary_path)
79
+ self.assertEqual(result.version, "v1.0.21")
80
+
81
+ def test_install_binary_writes_metadata(self) -> None:
82
+ with tempfile.TemporaryDirectory() as temp_dir:
83
+ temp_root = Path(temp_dir)
84
+ source = temp_root / "source"
85
+ source.write_text("ggcode", encoding="utf-8")
86
+ destination = temp_root / "bin" / "ggcode"
87
+
88
+ installed = cli.install_binary(source, destination, "v9.9.9")
89
+
90
+ self.assertEqual(installed, destination)
91
+ self.assertEqual(destination.read_text(encoding="utf-8"), "ggcode")
92
+ self.assertEqual(cli.read_metadata(destination.parent), {"version": "v9.9.9"})
93
+
94
+
95
+ if __name__ == "__main__":
96
+ unittest.main()
ggcode-1.1.1/README.md DELETED
@@ -1,57 +0,0 @@
1
- # ggcode Python wrapper
2
-
3
- `ggcode` on PyPI is a thin Python wrapper for the `ggcode` terminal agent.
4
-
5
- It does not ship the platform binary inside the wheel. Instead, the wrapper downloads the latest
6
- matching `ggcode` GitHub Release on first run, caches it locally, and then launches it.
7
-
8
- ## Install
9
-
10
- ```bash
11
- pip install ggcode
12
- ```
13
-
14
- Then run:
15
-
16
- ```bash
17
- ggcode
18
- ```
19
-
20
- ## What it does
21
-
22
- - Detects your operating system and CPU architecture
23
- - Downloads the latest matching `ggcode` archive from GitHub Releases
24
- - Verifies the archive against `checksums.txt`
25
- - Extracts and caches the binary for future runs
26
-
27
- ## Pin a specific ggcode release
28
-
29
- By default, the wrapper always resolves the latest `ggcode` release.
30
-
31
- To force a specific release, set `GGCODE_INSTALL_VERSION`:
32
-
33
- ```bash
34
- GGCODE_INSTALL_VERSION=vX.Y.Z ggcode
35
- ```
36
-
37
- or:
38
-
39
- ```bash
40
- GGCODE_INSTALL_VERSION=X.Y.Z ggcode
41
- ```
42
-
43
- ## Supported platforms
44
-
45
- - macOS
46
- - Linux
47
- - Windows
48
-
49
- Supported architectures:
50
-
51
- - x86_64 / amd64
52
- - arm64
53
-
54
- ## Project links
55
-
56
- - Repository: https://github.com/topcheer/ggcode
57
- - Issues: https://github.com/topcheer/ggcode/issues
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- ggcode = ggcode_release_installer.cli:main
@@ -1 +0,0 @@
1
- __version__ = "1.1.1"
@@ -1,147 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import hashlib
4
- import os
5
- import platform
6
- import shutil
7
- import stat
8
- import subprocess
9
- import sys
10
- import tarfile
11
- import tempfile
12
- import urllib.parse
13
- import urllib.request
14
- import zipfile
15
- from pathlib import Path
16
-
17
- OWNER = "topcheer"
18
- REPO = "ggcode"
19
-
20
-
21
- def normalize_version() -> str:
22
- selected = os.environ.get("GGCODE_INSTALL_VERSION", "").strip()
23
- if not selected or selected == "latest":
24
- return "latest"
25
- if selected.startswith("v"):
26
- return selected
27
- return f"v{selected}"
28
-
29
-
30
- def resolve_target() -> tuple[str, str]:
31
- system = sys.platform
32
- if system.startswith("linux"):
33
- goos = "linux"
34
- elif system == "darwin":
35
- goos = "darwin"
36
- elif system in ("win32", "cygwin"):
37
- goos = "windows"
38
- else:
39
- raise RuntimeError(f"Unsupported platform: {system}")
40
-
41
- machine = platform.machine().lower() or os.environ.get("PROCESSOR_ARCHITECTURE", "").lower()
42
- if machine in ("x86_64", "amd64"):
43
- arch = "x86_64"
44
- elif machine in ("arm64", "aarch64"):
45
- arch = "arm64"
46
- else:
47
- raise RuntimeError(f"Unsupported architecture: {machine or 'unknown'}")
48
- return goos, arch
49
-
50
-
51
- def release_base(version: str) -> str:
52
- if version == "latest":
53
- return f"https://github.com/{OWNER}/{REPO}/releases/latest/download"
54
- return f"https://github.com/{OWNER}/{REPO}/releases/download/{version}"
55
-
56
-
57
- def cache_root() -> Path:
58
- if os.name == "nt":
59
- base = Path(os.environ.get("LOCALAPPDATA", tempfile.gettempdir()))
60
- return base / "ggcode" / "python"
61
- return Path.home() / ".cache" / "ggcode" / "python"
62
-
63
-
64
- def download(url: str) -> bytes:
65
- with urllib.request.urlopen(url) as response:
66
- return response.read()
67
-
68
-
69
- def resolve_release_version(version: str) -> str:
70
- if version != "latest":
71
- return version
72
-
73
- with urllib.request.urlopen(f"https://github.com/{OWNER}/{REPO}/releases/latest") as response:
74
- final_url = response.geturl()
75
-
76
- parsed = urllib.parse.urlparse(final_url)
77
- parts = [part for part in parsed.path.split("/") if part]
78
- if len(parts) < 4 or parts[-2] != "tag":
79
- raise RuntimeError(f"Could not resolve latest ggcode release from {final_url}")
80
- return urllib.parse.unquote(parts[-1])
81
-
82
-
83
- def parse_checksums(body: str) -> dict[str, str]:
84
- checksums: dict[str, str] = {}
85
- for raw_line in body.splitlines():
86
- parts = raw_line.strip().split()
87
- if len(parts) >= 2:
88
- checksums[parts[-1]] = parts[0]
89
- return checksums
90
-
91
-
92
- def ensure_installed() -> Path:
93
- version = resolve_release_version(normalize_version())
94
- goos, arch = resolve_target()
95
- archive_ext = ".zip" if goos == "windows" else ".tar.gz"
96
- archive_name = f"ggcode_{goos}_{arch}{archive_ext}"
97
- binary_name = "ggcode.exe" if goos == "windows" else "ggcode"
98
- install_dir = cache_root() / version / f"{goos}-{arch}"
99
- binary_path = install_dir / binary_name
100
- if binary_path.exists():
101
- return binary_path
102
-
103
- base = release_base(version)
104
- archive = download(f"{base}/{archive_name}")
105
- checksums = parse_checksums(download(f"{base}/checksums.txt").decode("utf-8"))
106
- expected = checksums.get(archive_name)
107
- if not expected:
108
- raise RuntimeError(f"Checksum for {archive_name} not found")
109
- actual = hashlib.sha256(archive).hexdigest()
110
- if actual.lower() != expected.lower():
111
- raise RuntimeError(f"Checksum mismatch for {archive_name}")
112
-
113
- with tempfile.TemporaryDirectory(prefix="ggcode-py-") as temp_dir:
114
- temp_root = Path(temp_dir)
115
- archive_path = temp_root / archive_name
116
- archive_path.write_bytes(archive)
117
- extract_dir = temp_root / "extract"
118
- extract_dir.mkdir(parents=True, exist_ok=True)
119
-
120
- if archive_ext == ".zip":
121
- with zipfile.ZipFile(archive_path) as zf:
122
- zf.extractall(extract_dir)
123
- else:
124
- with tarfile.open(archive_path, "r:gz") as tf:
125
- tf.extractall(extract_dir)
126
-
127
- extracted = next((p for p in extract_dir.rglob(binary_name) if p.is_file()), None)
128
- if extracted is None:
129
- raise RuntimeError(f"Could not find {binary_name} in {archive_name}")
130
-
131
- install_dir.mkdir(parents=True, exist_ok=True)
132
- shutil.copy2(extracted, binary_path)
133
- if os.name != "nt":
134
- current_mode = binary_path.stat().st_mode
135
- binary_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
136
-
137
- return binary_path
138
-
139
-
140
- def main() -> int:
141
- binary = ensure_installed()
142
- result = subprocess.run([str(binary), *sys.argv[1:]])
143
- return result.returncode
144
-
145
-
146
- if __name__ == "__main__":
147
- raise SystemExit(main())
@@ -1,41 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import unittest
5
- from unittest.mock import patch
6
-
7
- from ggcode_release_installer import cli
8
-
9
-
10
- class ResolveTargetTests(unittest.TestCase):
11
- def test_normalize_version_defaults_to_latest(self) -> None:
12
- with patch.dict(os.environ, {}, clear=True):
13
- self.assertEqual(cli.normalize_version(), "latest")
14
-
15
- def test_normalize_version_uses_explicit_override(self) -> None:
16
- with patch.dict(os.environ, {"GGCODE_INSTALL_VERSION": "1.2.3"}, clear=True):
17
- self.assertEqual(cli.normalize_version(), "v1.2.3")
18
-
19
- def test_resolve_target_uses_platform_module_for_machine(self) -> None:
20
- with patch.object(cli.sys, "platform", "darwin"):
21
- with patch.object(cli.platform, "machine", return_value="arm64"):
22
- self.assertEqual(cli.resolve_target(), ("darwin", "arm64"))
23
-
24
- def test_resolve_target_falls_back_to_processor_architecture(self) -> None:
25
- env = dict(os.environ, PROCESSOR_ARCHITECTURE="AMD64")
26
- with patch.dict(os.environ, env, clear=True):
27
- with patch.object(cli.sys, "platform", "win32"):
28
- with patch.object(cli.platform, "machine", return_value=""):
29
- self.assertEqual(cli.resolve_target(), ("windows", "x86_64"))
30
-
31
- def test_resolve_release_version_follows_latest_redirect(self) -> None:
32
- response = unittest.mock.Mock()
33
- response.geturl.return_value = "https://github.com/topcheer/ggcode/releases/tag/v9.9.9"
34
- response.__enter__ = unittest.mock.Mock(return_value=response)
35
- response.__exit__ = unittest.mock.Mock(return_value=False)
36
- with patch.object(cli.urllib.request, "urlopen", return_value=response):
37
- self.assertEqual(cli.resolve_release_version("latest"), "v9.9.9")
38
-
39
-
40
- if __name__ == "__main__":
41
- unittest.main()
File without changes