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.
- shell_scripts/__init__.py +5 -0
- shell_scripts/__main__.py +5 -0
- shell_scripts/commands/__init__.py +81 -0
- shell_scripts/commands/_dc_common.py +138 -0
- shell_scripts/commands/ai_install.py +453 -0
- shell_scripts/commands/bin_links.py +78 -0
- shell_scripts/commands/claude.py +56 -0
- shell_scripts/commands/clean.py +98 -0
- shell_scripts/commands/codex.py +107 -0
- shell_scripts/commands/copilot.py +54 -0
- shell_scripts/commands/dicom2jpg.py +98 -0
- shell_scripts/commands/dicomviewer.py +83 -0
- shell_scripts/commands/diff_cmd.py +50 -0
- shell_scripts/commands/doxygen_cmd.py +159 -0
- shell_scripts/commands/edit_cmd.py +50 -0
- shell_scripts/commands/gemini.py +54 -0
- shell_scripts/commands/kiro.py +54 -0
- shell_scripts/commands/opencode.py +54 -0
- shell_scripts/commands/pdf_crop.py +520 -0
- shell_scripts/commands/pdf_merge.py +180 -0
- shell_scripts/commands/pdf_split_by_format.py +243 -0
- shell_scripts/commands/pdf_split_by_toc.py +197 -0
- shell_scripts/commands/pdf_tiler_090.py +61 -0
- shell_scripts/commands/pdf_tiler_100.py +61 -0
- shell_scripts/commands/pdf_toc_clean.py +175 -0
- shell_scripts/commands/pi.py +61 -0
- shell_scripts/commands/req_cmd.py +362 -0
- shell_scripts/commands/tests_cmd.py +87 -0
- shell_scripts/commands/ubuntu_dark_theme.py +64 -0
- shell_scripts/commands/venv_cmd.py +82 -0
- shell_scripts/commands/video2h264.py +89 -0
- shell_scripts/commands/video2h265.py +87 -0
- shell_scripts/commands/view_cmd.py +50 -0
- shell_scripts/commands/vscode_cmd.py +42 -0
- shell_scripts/commands/vsinsider_cmd.py +42 -0
- shell_scripts/config.py +328 -0
- shell_scripts/core.py +324 -0
- shell_scripts/utils.py +455 -0
- shell_scripts/version_check.py +323 -0
- shellscripts-0.23.1.dist-info/METADATA +310 -0
- shellscripts-0.23.1.dist-info/RECORD +45 -0
- shellscripts-0.23.1.dist-info/WHEEL +5 -0
- shellscripts-0.23.1.dist-info/entry_points.txt +3 -0
- shellscripts-0.23.1.dist-info/licenses/LICENSE +674 -0
- shellscripts-0.23.1.dist-info/top_level.txt +1 -0
|
@@ -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
|