shellscripts 0.23.1__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 (45) hide show
  1. shell_scripts/__init__.py +5 -0
  2. shell_scripts/__main__.py +5 -0
  3. shell_scripts/commands/__init__.py +81 -0
  4. shell_scripts/commands/_dc_common.py +138 -0
  5. shell_scripts/commands/ai_install.py +453 -0
  6. shell_scripts/commands/bin_links.py +78 -0
  7. shell_scripts/commands/claude.py +56 -0
  8. shell_scripts/commands/clean.py +98 -0
  9. shell_scripts/commands/codex.py +107 -0
  10. shell_scripts/commands/copilot.py +54 -0
  11. shell_scripts/commands/dicom2jpg.py +98 -0
  12. shell_scripts/commands/dicomviewer.py +83 -0
  13. shell_scripts/commands/diff_cmd.py +50 -0
  14. shell_scripts/commands/doxygen_cmd.py +159 -0
  15. shell_scripts/commands/edit_cmd.py +50 -0
  16. shell_scripts/commands/gemini.py +54 -0
  17. shell_scripts/commands/kiro.py +54 -0
  18. shell_scripts/commands/opencode.py +54 -0
  19. shell_scripts/commands/pdf_crop.py +520 -0
  20. shell_scripts/commands/pdf_merge.py +180 -0
  21. shell_scripts/commands/pdf_split_by_format.py +243 -0
  22. shell_scripts/commands/pdf_split_by_toc.py +197 -0
  23. shell_scripts/commands/pdf_tiler_090.py +61 -0
  24. shell_scripts/commands/pdf_tiler_100.py +61 -0
  25. shell_scripts/commands/pdf_toc_clean.py +175 -0
  26. shell_scripts/commands/pi.py +61 -0
  27. shell_scripts/commands/req_cmd.py +362 -0
  28. shell_scripts/commands/tests_cmd.py +87 -0
  29. shell_scripts/commands/ubuntu_dark_theme.py +64 -0
  30. shell_scripts/commands/venv_cmd.py +82 -0
  31. shell_scripts/commands/video2h264.py +89 -0
  32. shell_scripts/commands/video2h265.py +87 -0
  33. shell_scripts/commands/view_cmd.py +50 -0
  34. shell_scripts/commands/vscode_cmd.py +42 -0
  35. shell_scripts/commands/vsinsider_cmd.py +42 -0
  36. shell_scripts/config.py +328 -0
  37. shell_scripts/core.py +324 -0
  38. shell_scripts/utils.py +455 -0
  39. shell_scripts/version_check.py +323 -0
  40. shellscripts-0.23.1.dist-info/METADATA +310 -0
  41. shellscripts-0.23.1.dist-info/RECORD +45 -0
  42. shellscripts-0.23.1.dist-info/WHEEL +5 -0
  43. shellscripts-0.23.1.dist-info/entry_points.txt +3 -0
  44. shellscripts-0.23.1.dist-info/licenses/LICENSE +674 -0
  45. shellscripts-0.23.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ __version__ = "0.23.1"
2
+
3
+ from .core import main # noqa: F401
4
+
5
+ __all__ = ["__version__", "main"]
@@ -0,0 +1,5 @@
1
+ from .core import main
2
+ import sys
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
@@ -0,0 +1,81 @@
1
+ """@brief Command registry and lazy command module resolver.
2
+
3
+ @details Defines a static command-to-module map consumed by the CLI dispatcher.
4
+ Dictionary lookup enforces deterministic command availability; missing keys
5
+ resolve to `None` to drive unknown-command error handling in the caller.
6
+ @satisfies PRJ-001, PRJ-002, PRJ-003, DES-001, DES-008, REQ-057, REQ-058
7
+ """
8
+
9
+ import importlib
10
+ from types import ModuleType
11
+
12
+ ## @var _COMMAND_MODULES
13
+ # @brief Static map from CLI command names to importable module paths.
14
+ # @details Enables lazy command loading and deterministic command exposure.
15
+ # Removing an entry removes command discoverability and dispatch reachability.
16
+ # @satisfies PRJ-003, DES-001
17
+ _COMMAND_MODULES = {
18
+ "ai-install": "shell_scripts.commands.ai_install",
19
+ "clean": "shell_scripts.commands.clean",
20
+ "claude": "shell_scripts.commands.claude",
21
+ "codex": "shell_scripts.commands.codex",
22
+ "copilot": "shell_scripts.commands.copilot",
23
+ "gemini": "shell_scripts.commands.gemini",
24
+ "kiro": "shell_scripts.commands.kiro",
25
+ "opencode": "shell_scripts.commands.opencode",
26
+ "pi": "shell_scripts.commands.pi",
27
+ "dicom2jpg": "shell_scripts.commands.dicom2jpg",
28
+ "dicomviewer": "shell_scripts.commands.dicomviewer",
29
+ "diff": "shell_scripts.commands.diff_cmd",
30
+ "edit": "shell_scripts.commands.edit_cmd",
31
+ "view": "shell_scripts.commands.view_cmd",
32
+ "doxygen": "shell_scripts.commands.doxygen_cmd",
33
+ "pdf-crop": "shell_scripts.commands.pdf_crop",
34
+ "pdf-merge": "shell_scripts.commands.pdf_merge",
35
+ "pdf-split-by-format": "shell_scripts.commands.pdf_split_by_format",
36
+ "pdf-split-by-toc": "shell_scripts.commands.pdf_split_by_toc",
37
+ "pdf-tiler-090": "shell_scripts.commands.pdf_tiler_090",
38
+ "pdf-tiler-100": "shell_scripts.commands.pdf_tiler_100",
39
+ "pdf-toc-clean": "shell_scripts.commands.pdf_toc_clean",
40
+ "req": "shell_scripts.commands.req_cmd",
41
+ "tests": "shell_scripts.commands.tests_cmd",
42
+ "ubuntu-dark-theme": "shell_scripts.commands.ubuntu_dark_theme",
43
+ "venv": "shell_scripts.commands.venv_cmd",
44
+ "video2h264": "shell_scripts.commands.video2h264",
45
+ "video2h265": "shell_scripts.commands.video2h265",
46
+ "vscode": "shell_scripts.commands.vscode_cmd",
47
+ "vsinsider": "shell_scripts.commands.vsinsider_cmd",
48
+ }
49
+
50
+
51
+ def get_command(name: str) -> ModuleType | None:
52
+ """@brief Resolve one CLI command token to its command module.
53
+
54
+ @details Performs O(1) dictionary lookup on `_COMMAND_MODULES`; returns
55
+ `None` for unknown tokens; imports target module lazily only on hit.
56
+ @param name {str} CLI command token.
57
+ @return {ModuleType|None} Imported command module for known token; `None` otherwise.
58
+ @throws {ImportError} If module path exists in map but import fails.
59
+ @satisfies PRJ-001, DES-001, DES-008, REQ-057, REQ-058
60
+ """
61
+ module_path = _COMMAND_MODULES.get(name)
62
+ if module_path is None:
63
+ return None
64
+ return importlib.import_module(module_path)
65
+
66
+
67
+ def get_all_commands() -> dict[str, str]:
68
+ """@brief Build command-description index for help rendering.
69
+
70
+ @details Iterates sorted command keys for stable output ordering; imports
71
+ each module via `get_command`; extracts `DESCRIPTION` or empty string.
72
+ Time complexity O(N log N) for N commands due to key sorting.
73
+ @return {dict[str, str]} Mapping `command_name -> description`.
74
+ @throws {ImportError} If any mapped command module import fails.
75
+ @satisfies PRJ-002, DES-001, DES-008, REQ-057, REQ-058
76
+ """
77
+ result: dict[str, str] = {}
78
+ for name in sorted(_COMMAND_MODULES):
79
+ mod = get_command(name)
80
+ result[name] = getattr(mod, "DESCRIPTION", "")
81
+ return result
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """@brief Shared MIME-based dispatcher primitives for diff/edit/view commands.
3
+
4
+ @details Provides extension and MIME classification plus external executable
5
+ resolution and blocking subprocess invocation for generic file tool wrappers.
6
+ @satisfies DES-007, REQ-024, REQ-055, REQ-056, REQ-064
7
+ """
8
+
9
+ import os
10
+ import subprocess
11
+
12
+ from shell_scripts.utils import is_executable_command, print_error
13
+
14
+ CODE_EXTENSIONS = {
15
+ "c", "h", "cc", "cpp", "cxx", "hpp", "hh", "hxx", "rs", "go",
16
+ "java", "kt", "kts", "cs", "swift", "php", "rb", "py", "js",
17
+ "mjs", "cjs", "ts", "tsx", "jsx", "vue", "svelte", "sh", "bash",
18
+ "zsh", "lua", "pl", "pm", "sql", "yaml", "yml", "toml", "ini",
19
+ "cfg", "conf", "json", "xml", "gradle", "mk", "cmake", "dockerfile",
20
+ }
21
+
22
+ MARKDOWN_EXTENSIONS = {"md", "markdown", "mdown", "mkd"}
23
+ HTML_EXTENSIONS = {"html", "htm", "xhtml"}
24
+ IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg"}
25
+
26
+
27
+ def get_extension(filepath):
28
+ name = os.path.basename(filepath)
29
+ if "." not in name:
30
+ return ""
31
+ return name.rsplit(".", 1)[1].lower()
32
+
33
+
34
+ def detect_mime(filepath):
35
+ """@brief Detect file MIME type with external tools.
36
+
37
+ @details Probes MIME by trying `mimetype` then `file --mime-type`, using
38
+ executable availability checks before subprocess invocation.
39
+ @param filepath {str} Target file path.
40
+ @return {str} MIME type string or empty string on detection failure.
41
+ @satisfies REQ-024, REQ-056
42
+ """
43
+
44
+ if not os.path.exists(filepath):
45
+ return ""
46
+ for cmd in ["mimetype", "file"]:
47
+ if is_executable_command(cmd):
48
+ try:
49
+ result = subprocess.run(
50
+ [cmd if cmd == "mimetype" else "file", "-b", "--mime-type", filepath]
51
+ if cmd == "file" else ["mimetype", "-b", filepath],
52
+ capture_output=True, text=True, timeout=5
53
+ )
54
+ if result.returncode == 0:
55
+ return result.stdout.strip()
56
+ except (subprocess.TimeoutExpired, FileNotFoundError):
57
+ pass
58
+ return ""
59
+
60
+
61
+ def categorize(filepath):
62
+ ext = get_extension(filepath)
63
+ mime = detect_mime(filepath)
64
+
65
+ if mime.startswith("image/"):
66
+ return "image"
67
+ if mime == "application/pdf":
68
+ return "pdf"
69
+ if mime == "text/html":
70
+ return "html"
71
+
72
+ if (mime.startswith("text/") or mime in (
73
+ "application/json", "application/xml", "application/x-shellscript", ""
74
+ )):
75
+ if ext in MARKDOWN_EXTENSIONS:
76
+ return "markdown"
77
+ if ext in HTML_EXTENSIONS:
78
+ return "html"
79
+ if ext in CODE_EXTENSIONS:
80
+ return "code"
81
+ return "text"
82
+
83
+ if not os.path.exists(filepath):
84
+ if ext in MARKDOWN_EXTENSIONS:
85
+ return "markdown"
86
+ if ext in HTML_EXTENSIONS:
87
+ return "html"
88
+ if ext == "pdf":
89
+ return "pdf"
90
+ if ext in IMAGE_EXTENSIONS:
91
+ return "image"
92
+ if ext in CODE_EXTENSIONS:
93
+ return "code"
94
+ return "text"
95
+
96
+ return "other"
97
+
98
+
99
+ def pick_cmd(primary, fallback):
100
+ """@brief Select primary command when executable, else fallback.
101
+
102
+ @details Uses shared executable-check helper on first token of primary
103
+ command vector.
104
+ @param primary {list[str]} Preferred command vector.
105
+ @param fallback {list[str]} Fallback command vector.
106
+ @return {list[str]} Selected executable command vector.
107
+ @satisfies REQ-024, REQ-055
108
+ """
109
+
110
+ if primary and is_executable_command(primary[0]):
111
+ return primary
112
+ return fallback
113
+
114
+
115
+ def dispatch(category_cmds, fallback_cmd, filepath, extra_args):
116
+ """@brief Dispatch diff/edit/view command by detected file category.
117
+
118
+ @details Resolves category-specific command vector, validates executable
119
+ availability, and executes selected command via blocking subprocess run.
120
+ @param category_cmds {dict[str, list[str]]} Category-to-command mapping.
121
+ @param fallback_cmd {list[str]} Fallback command vector.
122
+ @param filepath {str} Target file path.
123
+ @param extra_args {list[str]} Additional arguments forwarded to executable.
124
+ @return {int} `1` when executable is unavailable; child return code otherwise.
125
+ @satisfies REQ-024, REQ-055, REQ-056, REQ-064
126
+ """
127
+
128
+ category = categorize(filepath)
129
+ cmd_list = category_cmds.get(category, fallback_cmd)
130
+ cmd = pick_cmd(cmd_list, fallback_cmd)
131
+
132
+ if not is_executable_command(cmd[0]):
133
+ print_error(f"Command not executable: {cmd[0]}")
134
+ return 1
135
+
136
+ full_cmd = cmd + [filepath] + extra_args
137
+ result = subprocess.run(full_cmd)
138
+ return result.returncode
@@ -0,0 +1,453 @@
1
+ #!/usr/bin/env python3
2
+ """@brief AI CLI installers dispatcher with OS-aware package resolution.
3
+
4
+ @details Provides selector-based installation flows for npm-distributed tools
5
+ and direct-download installers. Npm command prefix and installer payload
6
+ sources are resolved from detected runtime OS. Kiro package resolution is
7
+ manifest-driven on Linux and explicitly unsupported on Windows/macOS. pi.dev
8
+ installation always runs npm global installation first and then installs the
9
+ configured pi package list without any `--extra` selector gate.
10
+ @satisfies PRJ-003, DES-013, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010, REQ-047, REQ-056, REQ-067, REQ-072, REQ-073, REQ-074
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import platform
16
+ import subprocess
17
+ import shutil
18
+ import zipfile
19
+ import tempfile
20
+ from pathlib import Path
21
+
22
+ from shell_scripts.utils import (
23
+ get_runtime_os,
24
+ print_info,
25
+ print_error,
26
+ print_success,
27
+ require_commands,
28
+ )
29
+
30
+ ## @var PROGRAM
31
+ # @brief Base CLI program name used in help output.
32
+ # @details Constant identifier for usage-line rendering in command help.
33
+ PROGRAM = "shellscripts"
34
+
35
+ ## @var DESCRIPTION
36
+ # @brief One-line command description for dispatcher help surfaces.
37
+ # @details Exposed by command registry introspection (`get_all_commands`).
38
+ DESCRIPTION = "Install AI CLI tools (Codex, Copilot, Gemini, OpenCode, pi.dev, Claude, Kiro)."
39
+
40
+ ## @var TOOLS
41
+ # @brief Static npm-installer registry for supported AI CLIs.
42
+ # @details Maps selector tokens to user-facing installer names and exact npm
43
+ # command vectors consumed by `_install_npm_tool(...)`.
44
+ TOOLS = {
45
+ "codex": {
46
+ "name": "OpenAI Codex CLI",
47
+ "install": ["npm", "install", "-g", "@openai/codex"],
48
+ },
49
+ "copilot": {
50
+ "name": "GitHub Copilot CLI",
51
+ "install": ["npm", "install", "-g", "@github/copilot"],
52
+ },
53
+ "gemini": {
54
+ "name": "Google Gemini CLI",
55
+ "install": ["npm", "install", "-g", "@google/gemini-cli"],
56
+ },
57
+ "opencode": {
58
+ "name": "OpenCode CLI",
59
+ "install": ["npm", "install", "-g", "opencode-ai"],
60
+ },
61
+ "pi": {
62
+ "name": "pi.dev CLI",
63
+ "install": ["npm", "install", "-g", "@mariozechner/pi-coding-agent"],
64
+ },
65
+ }
66
+
67
+ ## @var PI_PACKAGES
68
+ # @brief Post-install pi package registry executed after pi.dev CLI install.
69
+ # @details Ordered tuple of package specifiers passed to `pi install` after
70
+ # successful npm-based pi.dev CLI installation.
71
+ PI_PACKAGES = ("git:github.com/ferologics/pi-notify",)
72
+
73
+ CLAUDE_BUCKET = (
74
+ "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819"
75
+ "/claude-code-releases"
76
+ )
77
+ CLAUDE_ARTIFACT_CANDIDATES = {
78
+ "linux": ("linux-x64/claude",),
79
+ "windows": ("windows-x64/claude.exe", "win32-x64/claude.exe"),
80
+ "darwin": ("darwin-arm64/claude", "darwin-x64/claude"),
81
+ }
82
+ KIRO_CHANNEL_BASE_URL = "https://prod.download.cli.kiro.dev/stable"
83
+ KIRO_MANIFEST_URL = f"{KIRO_CHANNEL_BASE_URL}/latest/manifest.json"
84
+ KIRO_LINUX_VARIANT = "headless"
85
+
86
+
87
+ def print_help(version: str) -> None:
88
+ """@brief Render command help for `ai-install`.
89
+
90
+ @details Prints supported selectors and execution contract for installer
91
+ dispatch. The selector surface excludes any `--extra` compatibility flag.
92
+ @param version {str} CLI version string appended in usage output.
93
+ @return {None} Writes help text to stdout.
94
+ @satisfies DES-008
95
+ """
96
+
97
+ print(f"Usage: {PROGRAM} ai-install [options] ({version})")
98
+ print()
99
+ print("ai-install options:")
100
+ print(" --all - Install all AI CLI tools (default).")
101
+ print(" --codex - Install OpenAI Codex CLI only.")
102
+ print(" --copilot - Install GitHub Copilot CLI only.")
103
+ print(" --gemini - Install Google Gemini CLI only.")
104
+ print(" --opencode - Install OpenCode CLI only.")
105
+ print(" --pi - Install pi.dev CLI only.")
106
+ print(" --claude - Install Claude CLI only.")
107
+ print(" --kiro - Install Kiro CLI only.")
108
+ print(" --help - Show this help message.")
109
+
110
+
111
+ def _install_npm_tool(tool_key):
112
+ """@brief Execute npm-based installer command for selected tool.
113
+
114
+ @details Resolves base npm command from static tool mapping, omits `sudo`
115
+ on Linux and Windows, prepends `sudo` on other runtimes, and uses resolved
116
+ `npm.cmd` path on Windows when available to avoid process-launch failures.
117
+ For Windows Copilot installs, retries once after a non-zero first attempt
118
+ to mitigate transient file-lock failures during binary replacement.
119
+ @param tool_key {str} Tool identifier key from `TOOLS`.
120
+ @return {bool} `True` when installation succeeds; `False` otherwise.
121
+ @satisfies DES-013, REQ-008, REQ-047, REQ-056, REQ-072
122
+ """
123
+
124
+ info = TOOLS[tool_key]
125
+ command = list(info["install"])
126
+ runtime_os = get_runtime_os()
127
+ windows_runtime = runtime_os == "windows"
128
+ if windows_runtime:
129
+ npm_cmd_path = shutil.which("npm.cmd")
130
+ if npm_cmd_path:
131
+ command[0] = npm_cmd_path
132
+ elif runtime_os != "linux":
133
+ require_commands("sudo")
134
+ command = ["sudo"] + command
135
+ require_commands(command[0])
136
+ print_info(f"Installing {info['name']}...")
137
+ max_attempts = 2 if windows_runtime and tool_key == "copilot" else 1
138
+ result = None
139
+ for attempt_idx in range(max_attempts):
140
+ result = subprocess.run(command)
141
+ if result.returncode == 0:
142
+ break
143
+ if attempt_idx + 1 < max_attempts:
144
+ print_info(
145
+ "Retrying GitHub Copilot CLI installation after transient Windows file lock..."
146
+ )
147
+ assert result is not None
148
+ if result.returncode != 0:
149
+ print_error(f"Failed to install {info['name']}.")
150
+ print()
151
+ return False
152
+
153
+ print_success(f"{info['name']} installed.")
154
+ print()
155
+ return True
156
+
157
+
158
+ def _normalize_kiro_linux_arch(machine_token):
159
+ """@brief Normalize machine architecture token for Kiro Linux packages.
160
+
161
+ @details Maps runtime machine names into manifest architecture keys accepted
162
+ by Kiro headless Linux ZIP entries. Raises explicit error for unknown
163
+ architecture to avoid ambiguous package selection.
164
+ @param machine_token {str} Raw `platform.machine()` token.
165
+ @return {str} Normalized architecture token (`x86_64` or `aarch64`).
166
+ @throws {RuntimeError} When architecture is not supported by Kiro installer.
167
+ @satisfies REQ-010, REQ-067
168
+ """
169
+
170
+ normalized = machine_token.strip().lower()
171
+ if normalized in ("x86_64", "amd64"):
172
+ return "x86_64"
173
+ if normalized in ("aarch64", "arm64"):
174
+ return "aarch64"
175
+ raise RuntimeError(
176
+ f"Unsupported Linux architecture for Kiro installer: {machine_token}"
177
+ )
178
+
179
+
180
+ def _detect_kiro_linux_libc():
181
+ """@brief Detect Linux libc class token for Kiro package selection.
182
+
183
+ @details Uses `platform.libc_ver()` to classify runtime libc as `musl` or
184
+ `gnu`. Unknown or empty values default to `gnu` to keep deterministic
185
+ package selection for glibc environments.
186
+ @return {str} libc class token (`musl` or `gnu`).
187
+ @satisfies REQ-010
188
+ """
189
+
190
+ libc_name, _ = platform.libc_ver()
191
+ if "musl" in libc_name.lower():
192
+ return "musl"
193
+ return "gnu"
194
+
195
+
196
+ def _resolve_kiro_linux_download_path(manifest, arch_token, libc_token):
197
+ """@brief Resolve Kiro Linux ZIP download path from manifest metadata.
198
+
199
+ @details Filters manifest packages by Linux OS, headless ZIP variant,
200
+ runtime architecture, and runtime libc class reflected in target triple.
201
+ Returns first matching `download` path and fails explicitly when no match
202
+ exists.
203
+ @param manifest {dict[str, object]} Parsed Kiro manifest JSON payload.
204
+ @param arch_token {str} Normalized architecture token (`x86_64|aarch64`).
205
+ @param libc_token {str} Normalized libc token (`gnu|musl`).
206
+ @return {str} Relative download path from manifest `packages[].download`.
207
+ @throws {RuntimeError} When no manifest package matches runtime filters.
208
+ @satisfies DES-013, REQ-010
209
+ """
210
+
211
+ if libc_token not in ("gnu", "musl"):
212
+ raise RuntimeError(f"Unsupported Linux libc class for Kiro installer: {libc_token}")
213
+
214
+ target_suffix = "linux-musl" if libc_token == "musl" else "linux-gnu"
215
+ packages = manifest.get("packages")
216
+ if not isinstance(packages, list):
217
+ raise RuntimeError("Invalid Kiro manifest format: missing packages list")
218
+
219
+ for package in packages:
220
+ if not isinstance(package, dict):
221
+ continue
222
+ if package.get("os") != "linux":
223
+ continue
224
+ if package.get("fileType") != "zip":
225
+ continue
226
+ if package.get("variant") != KIRO_LINUX_VARIANT:
227
+ continue
228
+ if package.get("architecture") != arch_token:
229
+ continue
230
+ target_triple = str(package.get("targetTriple", ""))
231
+ if target_suffix not in target_triple:
232
+ continue
233
+ download_path = package.get("download")
234
+ if isinstance(download_path, str) and download_path:
235
+ return download_path
236
+
237
+ raise RuntimeError(
238
+ "No Kiro Linux package matched runtime architecture/libc in manifest"
239
+ )
240
+
241
+
242
+ def _install_claude():
243
+ """@brief Install Claude CLI by direct binary download.
244
+
245
+ @details Downloads latest version metadata from configured bucket, resolves
246
+ OS-specific Claude artifact candidates from runtime OS token, downloads the
247
+ first available artifact, writes executable into `~/.claude/bin/claude`,
248
+ and sets execute permissions on non-Windows runtimes.
249
+ @return {None} Executes side effects and prints result messages.
250
+ @throws {RuntimeError} When runtime OS has no configured package candidates.
251
+ @throws {urllib.error.URLError} When metadata or artifact download fails.
252
+ @throws {OSError} When destination write or permission update fails.
253
+ @satisfies DES-013, REQ-009
254
+ """
255
+
256
+ import urllib.error
257
+ import urllib.request
258
+
259
+ print_info("Installing Claude CLI...")
260
+ install_dir = Path.home() / ".claude" / "bin"
261
+ install_dir.mkdir(parents=True, exist_ok=True)
262
+
263
+ try:
264
+ runtime_os = get_runtime_os()
265
+ artifact_candidates = CLAUDE_ARTIFACT_CANDIDATES.get(runtime_os)
266
+ if not artifact_candidates:
267
+ raise RuntimeError(f"Unsupported runtime OS for Claude installer: {runtime_os}")
268
+
269
+ version_url = f"{CLAUDE_BUCKET}/latest"
270
+ with urllib.request.urlopen(version_url, timeout=15) as resp:
271
+ version = resp.read().decode().strip()
272
+
273
+ print_info(f"Downloading Claude CLI version {version}...")
274
+ download_error = None
275
+ dest = install_dir / "claude"
276
+ for artifact_path in artifact_candidates:
277
+ binary_url = f"{CLAUDE_BUCKET}/{version}/{artifact_path}"
278
+ try:
279
+ urllib.request.urlretrieve(binary_url, str(dest))
280
+ break
281
+ except (urllib.error.HTTPError, urllib.error.URLError) as error:
282
+ download_error = error
283
+ else:
284
+ raise RuntimeError(
285
+ f"No Claude artifact candidate was downloadable for OS {runtime_os}"
286
+ ) from download_error
287
+
288
+ if runtime_os != "windows":
289
+ dest.chmod(0o755)
290
+ print_success(f"Claude CLI installed to {dest}")
291
+ except (OSError, RuntimeError, urllib.error.HTTPError, urllib.error.URLError) as e:
292
+ print_error(f"Failed to install Claude CLI: {e}")
293
+ print()
294
+
295
+
296
+ def _install_pi() -> None:
297
+ """@brief Install pi.dev CLI and required post-install pi packages.
298
+
299
+ @details Executes npm-based pi.dev CLI installation first. When npm
300
+ installation succeeds, the function resolves the `pi` executable and
301
+ installs each package from `PI_PACKAGES` using `pi install <package>` in
302
+ tuple order. Package installation is skipped only when CLI installation
303
+ fails.
304
+ @return {None} Executes side effects and prints result messages.
305
+ @satisfies DES-013, REQ-072, REQ-073, REQ-074
306
+ """
307
+
308
+ if not _install_npm_tool("pi"):
309
+ return
310
+
311
+ pi_executable = require_commands("pi")
312
+ print_info("Installing pi.dev packages...")
313
+ for package in PI_PACKAGES:
314
+ result = subprocess.run([pi_executable, "install", package])
315
+ if result.returncode != 0:
316
+ print_error(f"Failed to install pi.dev package: {package}.")
317
+ print()
318
+ return
319
+
320
+ print_success("pi.dev packages installed.")
321
+ print()
322
+
323
+
324
+ def _install_kiro():
325
+ """@brief Install Kiro CLI binaries by ZIP extraction flow.
326
+
327
+ @details Rejects unsupported runtime OS values (`windows`, `darwin`) with
328
+ explicit errors. On Linux, resolves runtime architecture/libc package from
329
+ official stable manifest, downloads selected ZIP archive, extracts
330
+ `kiro-cli*` binaries, and installs them into `~/.local/bin`.
331
+ @return {None} Executes side effects and prints result messages.
332
+ @throws {RuntimeError} When runtime OS is unsupported or manifest has no
333
+ matching package.
334
+ @throws {urllib.error.URLError} When archive download fails.
335
+ @throws {json.JSONDecodeError} When manifest payload is malformed.
336
+ @throws {zipfile.BadZipFile} When downloaded archive is invalid.
337
+ @throws {OSError} When extraction/copy/permission updates fail.
338
+ @satisfies DES-013, REQ-010, REQ-067
339
+ """
340
+
341
+ import urllib.error
342
+ import urllib.request
343
+
344
+ print_info("Installing Kiro CLI...")
345
+ runtime_os = get_runtime_os()
346
+ if runtime_os == "windows":
347
+ print_error("Failed to install Kiro CLI: unsupported runtime OS windows.")
348
+ print()
349
+ return
350
+ if runtime_os == "darwin":
351
+ print_error("Failed to install Kiro CLI: unsupported runtime OS darwin.")
352
+ print()
353
+ return
354
+ if runtime_os != "linux":
355
+ print_error(f"Failed to install Kiro CLI: unsupported runtime OS {runtime_os}.")
356
+ print()
357
+ return
358
+
359
+ install_dir = Path.home() / ".local" / "bin"
360
+ install_dir.mkdir(parents=True, exist_ok=True)
361
+
362
+ try:
363
+ machine_arch = _normalize_kiro_linux_arch(platform.machine())
364
+ libc_token = _detect_kiro_linux_libc()
365
+ with urllib.request.urlopen(KIRO_MANIFEST_URL, timeout=15) as response:
366
+ manifest_payload = json.loads(response.read().decode())
367
+ download_path = _resolve_kiro_linux_download_path(
368
+ manifest_payload,
369
+ machine_arch,
370
+ libc_token,
371
+ )
372
+ archive_url = f"{KIRO_CHANNEL_BASE_URL}/{download_path}"
373
+
374
+ with tempfile.TemporaryDirectory() as tmpdir:
375
+ zip_path = os.path.join(tmpdir, "kiro.zip")
376
+ print_info("Downloading Kiro CLI...")
377
+ urllib.request.urlretrieve(archive_url, zip_path)
378
+
379
+ with zipfile.ZipFile(zip_path, "r") as zf:
380
+ zf.extractall(tmpdir)
381
+
382
+ copied_binaries = []
383
+ for src in Path(tmpdir).rglob("kiro-cli*"):
384
+ if src.is_file():
385
+ dest = install_dir / src.name
386
+ shutil.copy2(src, str(dest))
387
+ dest.chmod(0o755)
388
+ copied_binaries.append(dest.name)
389
+
390
+ if not copied_binaries:
391
+ raise RuntimeError("No kiro-cli* binaries found in extracted archive")
392
+
393
+ print_success(f"Kiro CLI installed to {install_dir}")
394
+ except (
395
+ OSError,
396
+ RuntimeError,
397
+ urllib.error.HTTPError,
398
+ urllib.error.URLError,
399
+ zipfile.BadZipFile,
400
+ ) as e:
401
+ print_error(f"Failed to install Kiro CLI: {e}")
402
+ print()
403
+
404
+
405
+ ALL_INSTALLERS = {
406
+ "codex": lambda: _install_npm_tool("codex"),
407
+ "copilot": lambda: _install_npm_tool("copilot"),
408
+ "gemini": lambda: _install_npm_tool("gemini"),
409
+ "opencode": lambda: _install_npm_tool("opencode"),
410
+ "pi": _install_pi,
411
+ "claude": _install_claude,
412
+ "kiro": _install_kiro,
413
+ }
414
+
415
+
416
+ def run(args: list[str]) -> int:
417
+ """@brief Parse selectors and execute selected AI installer routines.
418
+
419
+ @details Accepts explicit selectors or defaults to the full installer set
420
+ when omitted. Unknown selectors are rejected with return code `1`; no
421
+ compatibility parsing exists for removed `--extra` behavior.
422
+ @param args {list[str]} CLI selector tokens for installer filtering.
423
+ @return {int} `0` on successful dispatch; `1` on unknown selector.
424
+ @satisfies REQ-006, REQ-007, REQ-073, REQ-074
425
+ """
426
+
427
+ selected = []
428
+ include_all = False
429
+ for arg in args:
430
+ key = arg.lstrip("-")
431
+ if key == "all":
432
+ include_all = True
433
+ continue
434
+ if key in ALL_INSTALLERS:
435
+ selected.append(key)
436
+ continue
437
+ print_error(f"Unknown option: {arg}")
438
+ return 1
439
+
440
+ if include_all or not selected:
441
+ selected = list(ALL_INSTALLERS.keys())
442
+ else:
443
+ selected = list(dict.fromkeys(selected))
444
+
445
+ print_info(f"Starting AI CLI tools installation ({len(selected)} tools)...")
446
+ print()
447
+
448
+ for key in selected:
449
+ installer = ALL_INSTALLERS[key]
450
+ installer()
451
+
452
+ print_success("AI CLI tools installation complete.")
453
+ return 0