cli-skills 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli_skills/__init__.py +13 -0
- cli_skills/__main__.py +11 -0
- cli_skills/catalog.py +190 -0
- cli_skills/core.py +459 -0
- cli_skills/installer.py +292 -0
- cli_skills/settings.py +105 -0
- cli_skills/show.py +297 -0
- cli_skills/targets.py +351 -0
- cli_skills-0.8.0.dist-info/METADATA +324 -0
- cli_skills-0.8.0.dist-info/RECORD +20 -0
- cli_skills-0.8.0.dist-info/WHEEL +5 -0
- cli_skills-0.8.0.dist-info/entry_points.txt +2 -0
- cli_skills-0.8.0.dist-info/licenses/LICENSE +674 -0
- cli_skills-0.8.0.dist-info/top_level.txt +2 -0
- resources/__init__.py +8 -0
- resources/manifest.json +15 -0
- resources/prompts/README.md +7 -0
- resources/skills/astra-uv-project/SKILL.md +150 -0
- resources/skills/astra-uv-project/assets/skell.tgz +0 -0
- resources/skills/prompt-skill-architect/SKILL.md +224 -0
cli_skills/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
## @file __init__.py
|
|
2
|
+
# @brief Expose package-level metadata and public CLI entrypoint symbols.
|
|
3
|
+
# @details Defines stable module exports for downstream imports and runtime version checks.
|
|
4
|
+
|
|
5
|
+
## @brief Store semantic version string for package metadata.
|
|
6
|
+
# @details Used by CLI usage/version output and by release automation checks.
|
|
7
|
+
__version__ = "0.8.0"
|
|
8
|
+
|
|
9
|
+
from .core import main # noqa: F401
|
|
10
|
+
|
|
11
|
+
## @brief Enumerate public symbols exported by package root.
|
|
12
|
+
# @details Constrains wildcard-import surface to the version constant and CLI main callable.
|
|
13
|
+
__all__ = ["__version__", "main"]
|
cli_skills/__main__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## @file __main__.py
|
|
2
|
+
# @brief Execute cli-skills CLI when module is invoked with `python -m`.
|
|
3
|
+
# @details Imports the canonical `main` dispatcher and forwards process exit status.
|
|
4
|
+
from .core import main
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## @brief Trigger CLI dispatcher for module-execution entrypoint.
|
|
9
|
+
# @details Invokes `main()` and propagates returned exit code via `sys.exit(...)`.
|
|
10
|
+
if __name__ == "__main__":
|
|
11
|
+
sys.exit(main())
|
cli_skills/catalog.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@file catalog.py
|
|
3
|
+
@brief Generate the skills and prompts manifest for `src/resources`.
|
|
4
|
+
@details Deterministic generator for `src/resources/manifest.json`. Recursively
|
|
5
|
+
scans `src/resources/skills` for `SKILL.md` files and
|
|
6
|
+
`src/resources/prompts` for frontmatter files, extracts frontmatter
|
|
7
|
+
`name` and `description`, and emits entries sorted by relative path.
|
|
8
|
+
Validates every skill directory against the canonical support layout
|
|
9
|
+
before accepting its metadata. Single maintenance path guaranteeing
|
|
10
|
+
each manifest description equals its source frontmatter `description`
|
|
11
|
+
string (exact match).
|
|
12
|
+
@satisfies DES-021 DES-022 DES-023 DES-024 DES-067 DES-068 DES-069 DES-070 DES-071 DES-072 DES-073
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib.resources as ires
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
RESOURCES_ROOT = Path(str(ires.files("resources")))
|
|
22
|
+
MANIFEST_PATH = RESOURCES_ROOT / "manifest.json"
|
|
23
|
+
## @brief Canonical skill support subdirectory names allowed at the skill root.
|
|
24
|
+
## @details Any other root directory or non-`SKILL.md` root file violates DES-067.
|
|
25
|
+
SKILL_SUPPORT_DIRECTORIES = ("scripts", "references", "assets", "images")
|
|
26
|
+
## @brief Canonical nested subdirectory names allowed directly under `references`.
|
|
27
|
+
## @details Any other nested directory under `references` violates DES-069 and DES-070.
|
|
28
|
+
REFERENCE_SUPPORT_DIRECTORIES = ("images",)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SkillLayoutError(ValueError):
|
|
32
|
+
"""
|
|
33
|
+
@brief Signal a bundled skill layout that violates the canonical support layout.
|
|
34
|
+
@details Raised by `validate_skill_layout` when a skill root contains an
|
|
35
|
+
entry outside the canonical support subdirectories or when
|
|
36
|
+
`references` contains a non-canonical subdirectory.
|
|
37
|
+
@satisfies DES-073
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_skill_layout(skill_dir: Path) -> None:
|
|
42
|
+
"""
|
|
43
|
+
@brief Validate a bundled skill directory against the canonical support layout.
|
|
44
|
+
@details Accepts `SKILL.md` plus the canonical support subdirectories at the
|
|
45
|
+
skill root; accepts only `images` as a nested directory directly
|
|
46
|
+
under `references`. Non-canonical root files and directories are
|
|
47
|
+
rejected. Complexity O(E) where E is the number of root entries
|
|
48
|
+
plus direct `references` entries.
|
|
49
|
+
@param skill_dir {Path} Packaged skill directory containing `SKILL.md`.
|
|
50
|
+
@return {None}
|
|
51
|
+
@throws {SkillLayoutError} When a root entry or a nested `references`
|
|
52
|
+
directory violates DES-067 through DES-072.
|
|
53
|
+
@satisfies DES-067 DES-068 DES-069 DES-070 DES-071 DES-072 DES-073
|
|
54
|
+
@pre `skill_dir` MUST be an existing directory.
|
|
55
|
+
@post The directory layout is canonical or an exception is raised.
|
|
56
|
+
"""
|
|
57
|
+
for entry in sorted(skill_dir.iterdir()):
|
|
58
|
+
if entry.name == "SKILL.md" and entry.is_file():
|
|
59
|
+
continue
|
|
60
|
+
if entry.is_dir() and entry.name in SKILL_SUPPORT_DIRECTORIES:
|
|
61
|
+
continue
|
|
62
|
+
raise SkillLayoutError(f"Invalid skill entry: {entry}")
|
|
63
|
+
references = skill_dir / "references"
|
|
64
|
+
if references.is_dir():
|
|
65
|
+
for entry in sorted(references.iterdir()):
|
|
66
|
+
if entry.is_dir() and entry.name not in REFERENCE_SUPPORT_DIRECTORIES:
|
|
67
|
+
raise SkillLayoutError(f"Invalid references entry: {entry}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_frontmatter(text: str) -> dict[str, str]:
|
|
71
|
+
"""
|
|
72
|
+
@brief Parse `name` and `description` from a YAML frontmatter block.
|
|
73
|
+
@details Reads key/value lines up to the closing `---` delimiter, strips
|
|
74
|
+
surrounding YAML quotes from scalar values, and returns only the
|
|
75
|
+
`name` and `description` keys when present.
|
|
76
|
+
@param text {str} Raw file text (UTF-8).
|
|
77
|
+
@return {Dict[str, str]} Frontmatter `name`/`description` mapping; empty
|
|
78
|
+
when the block or keys are absent.
|
|
79
|
+
@pre `text` must be a non-empty string.
|
|
80
|
+
"""
|
|
81
|
+
lines = text.splitlines()
|
|
82
|
+
if not lines or lines[0].strip() != "---":
|
|
83
|
+
return {}
|
|
84
|
+
end = 1
|
|
85
|
+
while end < len(lines) and lines[end].strip() != "---":
|
|
86
|
+
end += 1
|
|
87
|
+
if end >= len(lines):
|
|
88
|
+
return {}
|
|
89
|
+
fields: dict[str, str] = {}
|
|
90
|
+
for line in lines[1:end]:
|
|
91
|
+
if ":" not in line:
|
|
92
|
+
continue
|
|
93
|
+
key, _, value = line.partition(":")
|
|
94
|
+
key = key.strip()
|
|
95
|
+
value = value.strip()
|
|
96
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
97
|
+
value = value[1:-1]
|
|
98
|
+
if key in ("name", "description"):
|
|
99
|
+
fields[key] = value
|
|
100
|
+
return fields
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _discover_skills(root: Path) -> list[Path]:
|
|
104
|
+
"""
|
|
105
|
+
@brief Discover `SKILL.md` files under `root/skills`.
|
|
106
|
+
@details Recursively collects every file named `SKILL.md`, sorted by path.
|
|
107
|
+
@param root {Path} Resources root directory.
|
|
108
|
+
@return {List[Path]} Sorted `SKILL.md` file paths.
|
|
109
|
+
"""
|
|
110
|
+
skills_dir = root / "skills"
|
|
111
|
+
if not skills_dir.is_dir():
|
|
112
|
+
return []
|
|
113
|
+
return sorted(p for p in skills_dir.rglob("SKILL.md") if p.is_file())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _discover_prompts(root: Path) -> list[Path]:
|
|
117
|
+
"""
|
|
118
|
+
@brief Discover candidate prompt files under `root/prompts`.
|
|
119
|
+
@details Recursively collects every regular file, sorted by path.
|
|
120
|
+
Frontmatter filtering is applied by `build_catalog`.
|
|
121
|
+
@param root {Path} Resources root directory.
|
|
122
|
+
@return {List[Path]} Sorted candidate prompt file paths.
|
|
123
|
+
"""
|
|
124
|
+
prompts_dir = root / "prompts"
|
|
125
|
+
if not prompts_dir.is_dir():
|
|
126
|
+
return []
|
|
127
|
+
return sorted(p for p in prompts_dir.rglob("*") if p.is_file())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_catalog(
|
|
131
|
+
resources_root: Path = RESOURCES_ROOT,
|
|
132
|
+
) -> dict[str, list[dict[str, str]]]:
|
|
133
|
+
"""
|
|
134
|
+
@brief Build the skills and prompts catalog dictionary.
|
|
135
|
+
@details Validates each skill directory layout, then scans skills and prompts
|
|
136
|
+
directories, extracts frontmatter metadata, and emits entries
|
|
137
|
+
sorted by relative path. Entries missing `name` or `description`
|
|
138
|
+
are omitted.
|
|
139
|
+
@param resources_root {Path} Resources root directory to scan.
|
|
140
|
+
@return {Dict[str, List[Dict[str, str]]]} Catalog with `skills` and
|
|
141
|
+
`prompts` lists; each entry contains `file`, `name`, `description`.
|
|
142
|
+
@throws {SkillLayoutError} When a discovered skill directory violates the
|
|
143
|
+
canonical support layout.
|
|
144
|
+
@satisfies DES-021 DES-022 DES-023 DES-024 DES-067 DES-068 DES-069 DES-070 DES-071 DES-072 DES-073
|
|
145
|
+
"""
|
|
146
|
+
catalog: dict[str, list[dict[str, str]]] = {"skills": [], "prompts": []}
|
|
147
|
+
for path in _discover_skills(resources_root):
|
|
148
|
+
validate_skill_layout(path.parent)
|
|
149
|
+
meta = _parse_frontmatter(path.read_text(encoding="utf-8"))
|
|
150
|
+
if "name" in meta and "description" in meta:
|
|
151
|
+
catalog["skills"].append(
|
|
152
|
+
{
|
|
153
|
+
"file": path.relative_to(resources_root).as_posix(),
|
|
154
|
+
"name": meta["name"],
|
|
155
|
+
"description": meta["description"],
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
for path in _discover_prompts(resources_root):
|
|
159
|
+
meta = _parse_frontmatter(path.read_text(encoding="utf-8"))
|
|
160
|
+
if "name" in meta and "description" in meta:
|
|
161
|
+
catalog["prompts"].append(
|
|
162
|
+
{
|
|
163
|
+
"file": path.relative_to(resources_root).as_posix(),
|
|
164
|
+
"name": meta["name"],
|
|
165
|
+
"description": meta["description"],
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
return catalog
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def write_catalog(
|
|
172
|
+
resources_root: Path = RESOURCES_ROOT,
|
|
173
|
+
dest: Path = MANIFEST_PATH,
|
|
174
|
+
) -> Path:
|
|
175
|
+
"""
|
|
176
|
+
@brief Write the catalog to the JSON manifest file.
|
|
177
|
+
@details Serializes `build_catalog` output deterministically with UTF-8
|
|
178
|
+
encoding, two-space indent, no ASCII escaping, and trailing newline.
|
|
179
|
+
@param resources_root {Path} Resources root directory to scan.
|
|
180
|
+
@param dest {Path} Destination JSON manifest file path.
|
|
181
|
+
@return {Path} The written manifest file path.
|
|
182
|
+
@satisfies DES-021
|
|
183
|
+
"""
|
|
184
|
+
catalog = build_catalog(resources_root)
|
|
185
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
dest.write_text(
|
|
187
|
+
json.dumps(catalog, indent=2, ensure_ascii=False) + "\n",
|
|
188
|
+
encoding="utf-8",
|
|
189
|
+
)
|
|
190
|
+
return dest
|
cli_skills/core.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""CLI management commands, resource installation and uninstallation dispatch, and release notification orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.request import Request, urlopen
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .installer import install_enabled, uninstall_enabled
|
|
16
|
+
from .settings import load_config, save_config
|
|
17
|
+
from .show import build_show
|
|
18
|
+
from .targets import TARGETS, resolve_target
|
|
19
|
+
|
|
20
|
+
## @brief PyPI JSON API endpoint for the newest published version.
|
|
21
|
+
## @satisfies DES-042
|
|
22
|
+
PYPI_ENDPOINT = "https://pypi.org/pypi/cli-skills/json"
|
|
23
|
+
ENDPOINT = "https://api.github.com/repos/Ogekuri/CLI-Skills/releases/latest"
|
|
24
|
+
UPGRADE = ["uv", "tool", "install", "cli-skills", "--force"]
|
|
25
|
+
## @brief Upgrade command argv installing the latest tagged GitHub release via git.
|
|
26
|
+
## @details Executed for `--upgrade --dev`; bypasses PyPI.
|
|
27
|
+
## @satisfies DES-081 REQ-063
|
|
28
|
+
UPGRADE_DEVELOP = ["uv", "tool", "install", "cli-skills", "--force", "--from", "git+https://github.com/Ogekuri/CLI-Skills.git"]
|
|
29
|
+
UNINSTALL = ["uv", "tool", "uninstall", "cli-skills"]
|
|
30
|
+
SKILLS_INSTALL = ["cli-skills", "install"]
|
|
31
|
+
"""
|
|
32
|
+
@brief Child `cli-skills install` command executed after a successful upgrade.
|
|
33
|
+
@details Spawned by `_post_upgrade_install` so the freshly upgraded package
|
|
34
|
+
installs its bundled skills and prompts into every enabled CLI.
|
|
35
|
+
@satisfies REQ-043
|
|
36
|
+
"""
|
|
37
|
+
HELP = f"""Usage: cli-skills [command] [options] ({__version__})
|
|
38
|
+
|
|
39
|
+
Management Commands:
|
|
40
|
+
--upgrade - Reinstall cli-skills on Linux; print manual command elsewhere.
|
|
41
|
+
--uninstall - Uninstall cli-skills on Linux; print manual command elsewhere.
|
|
42
|
+
--ver - Print the cli-skills version.
|
|
43
|
+
--version - Print the cli-skills version.
|
|
44
|
+
--dev - With --upgrade, install the latest GitHub release instead of PyPI.
|
|
45
|
+
--help - Print the full help screen or the help text of a specific command.
|
|
46
|
+
|
|
47
|
+
Installation Commands:
|
|
48
|
+
enable <cli name> - Enable skill and prompt installation for a supported CLI.
|
|
49
|
+
disable <cli name> - Disable skill and prompt installation for a supported CLI.
|
|
50
|
+
install - Install skills and prompts for every enabled CLI.
|
|
51
|
+
uninstall - Remove installed skills and prompts for every enabled CLI.
|
|
52
|
+
|
|
53
|
+
Reporting Commands:
|
|
54
|
+
show - Show packaged resources, CLI configuration, and installation status.
|
|
55
|
+
|
|
56
|
+
Commands:
|
|
57
|
+
..."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cache_path() -> Path:
|
|
61
|
+
return Path.home() / ".cache" / "cli-skills" / "check_version_idle-time.json"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _human(timestamp: float | None) -> str | None:
|
|
65
|
+
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat() if timestamp is not None else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _version(value: str) -> tuple[int, int, int]:
|
|
69
|
+
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", value)
|
|
70
|
+
if not match:
|
|
71
|
+
raise ValueError(f"Invalid release version: {value}")
|
|
72
|
+
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _http_json(url: str, accept: str) -> dict:
|
|
76
|
+
"""
|
|
77
|
+
@brief Perform one HTTP GET and parse the JSON response body.
|
|
78
|
+
@details Sends a `cli-skills` User-Agent with the caller-supplied Accept
|
|
79
|
+
header and enforces the hardcoded 2-second timeout. Network and
|
|
80
|
+
protocol errors propagate to the caller.
|
|
81
|
+
@param url {str} Absolute HTTP/HTTPS endpoint URL.
|
|
82
|
+
@param accept {str} HTTP Accept header value.
|
|
83
|
+
@return {Dict} Decoded top-level JSON object.
|
|
84
|
+
@throws {OSError} On transport or HTTP errors, including 429 and 503.
|
|
85
|
+
@throws {ValueError} When the response body is not valid JSON.
|
|
86
|
+
@satisfies CTN-006 DES-042 DES-083
|
|
87
|
+
"""
|
|
88
|
+
request = Request(url, headers={"Accept": accept, "User-Agent": "cli-skills"})
|
|
89
|
+
with urlopen(request, timeout=2) as response:
|
|
90
|
+
return json.load(response)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fetch_pypi_latest() -> str:
|
|
94
|
+
"""
|
|
95
|
+
@brief Fetch the newest published version from the PyPI JSON API.
|
|
96
|
+
@details Requests `PYPI_ENDPOINT`, reads `info.version`, and returns it
|
|
97
|
+
trimmed. Any transport, HTTP, or payload-shape failure raises.
|
|
98
|
+
@return {str} Newest PyPI version string.
|
|
99
|
+
@throws {OSError} On transport or HTTP errors, including 429 and 503.
|
|
100
|
+
@throws {ValueError} When the payload is not valid JSON or omits a
|
|
101
|
+
non-empty string `info.version`.
|
|
102
|
+
@throws {KeyError} When the payload omits `info.version`.
|
|
103
|
+
@satisfies DES-042 REQ-002 REQ-003 REQ-059
|
|
104
|
+
"""
|
|
105
|
+
version = _http_json(PYPI_ENDPOINT, "application/json")["info"]["version"]
|
|
106
|
+
if not isinstance(version, str) or not version.strip():
|
|
107
|
+
raise ValueError("missing PyPI latest version")
|
|
108
|
+
return version.strip()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def fetch_github_latest() -> str:
|
|
112
|
+
"""
|
|
113
|
+
@brief Fetch the newest GitHub release version in `x.y.z` form.
|
|
114
|
+
@details Requests `ENDPOINT`, reads `tag_name`, and strips an optional `v`
|
|
115
|
+
prefix. The returned release is the newest tagged GitHub release
|
|
116
|
+
regardless of its source branch, matching the `--upgrade --dev`
|
|
117
|
+
download source.
|
|
118
|
+
@return {str} Newest GitHub release version as `x.y.z`.
|
|
119
|
+
@throws {OSError} On transport or HTTP errors, including 429 and 503.
|
|
120
|
+
@throws {ValueError} When the payload omits or holds an invalid
|
|
121
|
+
`tag_name`.
|
|
122
|
+
@throws {KeyError} When the payload omits `tag_name`.
|
|
123
|
+
@satisfies DES-083 REQ-061 REQ-062 REQ-063
|
|
124
|
+
"""
|
|
125
|
+
tag = _http_json(ENDPOINT, "application/vnd.github+json")["tag_name"]
|
|
126
|
+
if not isinstance(tag, str):
|
|
127
|
+
raise ValueError("missing GitHub latest release tag")
|
|
128
|
+
match = re.fullmatch(r"v?(\d+\.\d+\.\d+)", tag.strip())
|
|
129
|
+
if not match:
|
|
130
|
+
raise ValueError(f"invalid GitHub release tag: {tag}")
|
|
131
|
+
return match.group(1)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_version(force: bool = False) -> None:
|
|
135
|
+
"""
|
|
136
|
+
@brief Run the cached automatic release check against PyPI.
|
|
137
|
+
@details Skips while the cached idle-time is valid unless `force` is set;
|
|
138
|
+
otherwise fetches the newest PyPI version via `fetch_pypi_latest`,
|
|
139
|
+
compares it with `__version__`, prints a bright-green
|
|
140
|
+
available-versus-installed message when a newer version exists or
|
|
141
|
+
a bright-red message for a failed or negative check, records
|
|
142
|
+
last-success timestamps on success only, and writes the cache
|
|
143
|
+
with a 3600-second (positive) or 86400-second (negative or
|
|
144
|
+
failed) expiration.
|
|
145
|
+
@param force {bool} Bypass the cached idle-time and perform the request.
|
|
146
|
+
@return {None}
|
|
147
|
+
@note Cache read/write and network failures are reported to stderr and do
|
|
148
|
+
not raise.
|
|
149
|
+
@satisfies REQ-001 REQ-002 REQ-003 REQ-004 REQ-005 REQ-016 REQ-017 REQ-018
|
|
150
|
+
REQ-019 REQ-020 REQ-021 REQ-022 REQ-023 REQ-059 CTN-006 CTN-007
|
|
151
|
+
CTN-008 DES-042 DES-043 DES-044 DES-045 DES-046
|
|
152
|
+
"""
|
|
153
|
+
path = cache_path()
|
|
154
|
+
cache = {}
|
|
155
|
+
try:
|
|
156
|
+
with path.open(encoding="utf-8") as file:
|
|
157
|
+
cache = json.load(file)
|
|
158
|
+
if not isinstance(cache, dict):
|
|
159
|
+
cache = {}
|
|
160
|
+
except (OSError, ValueError):
|
|
161
|
+
pass
|
|
162
|
+
now = time.time()
|
|
163
|
+
idle = cache.get("idle_time_epoch")
|
|
164
|
+
if not force and isinstance(idle, (int, float)) and idle > now:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
last_success = cache.get("last_success_epoch")
|
|
168
|
+
if not isinstance(last_success, (int, float)):
|
|
169
|
+
last_success = None
|
|
170
|
+
delay = 86400
|
|
171
|
+
try:
|
|
172
|
+
available = fetch_pypi_latest()
|
|
173
|
+
newer = _version(available) > _version(__version__)
|
|
174
|
+
now = time.time()
|
|
175
|
+
last_success = now
|
|
176
|
+
if newer:
|
|
177
|
+
delay = 3600
|
|
178
|
+
print(f"\033[92mAvailable Version: {available} | Installed Version: {__version__}\033[0m", file=sys.stderr)
|
|
179
|
+
else:
|
|
180
|
+
print("\033[91mVersion check: no newer version available.\033[0m", file=sys.stderr)
|
|
181
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
182
|
+
now = time.time()
|
|
183
|
+
print(f"\033[91mVersion check error: {exc}\033[0m", file=sys.stderr)
|
|
184
|
+
data = {
|
|
185
|
+
"last_success_epoch": last_success,
|
|
186
|
+
"last_success_human": _human(last_success),
|
|
187
|
+
"idle_time_epoch": now + delay,
|
|
188
|
+
"idle_time_human": _human(now + delay),
|
|
189
|
+
}
|
|
190
|
+
try:
|
|
191
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
193
|
+
except OSError as exc:
|
|
194
|
+
print(f"\033[91mVersion cache error: {exc}\033[0m", file=sys.stderr)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _manage(command: list[str]) -> int:
|
|
198
|
+
if not sys.platform.startswith("linux"):
|
|
199
|
+
print(f"Warning: run this command manually: {' '.join(command)}")
|
|
200
|
+
return 0
|
|
201
|
+
try:
|
|
202
|
+
return subprocess.run(command, check=False).returncode
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
print(f"Error executing {command[0]}: {exc}", file=sys.stderr)
|
|
205
|
+
return 1
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _post_upgrade_install(home: Path | None = None) -> int:
|
|
209
|
+
"""
|
|
210
|
+
@brief Install bundled resources through `cli-skills install` after a
|
|
211
|
+
successful upgrade.
|
|
212
|
+
@details Loads the user configuration and, when at least one CLI is
|
|
213
|
+
enabled, spawns `cli-skills install` as a child process so the
|
|
214
|
+
freshly upgraded package synchronizes its bundled skills and
|
|
215
|
+
prompts into every enabled CLI. Skips the child process when
|
|
216
|
+
the enabled configuration is empty. The child reads the
|
|
217
|
+
upgraded package resources, not the running process ones. The
|
|
218
|
+
child inherits stdout, so its per-CLI count and installed-name
|
|
219
|
+
report surfaces as visible `--upgrade` output.
|
|
220
|
+
@param home {Path | None} Optional home-directory override used only for
|
|
221
|
+
the configuration read; the child process inherits the real
|
|
222
|
+
environment.
|
|
223
|
+
@return {int} 0 when no CLI is enabled; otherwise the `cli-skills install`
|
|
224
|
+
exit code (1 on spawn or installation failure).
|
|
225
|
+
@throws {OSError} When reading the configuration file fails.
|
|
226
|
+
@satisfies REQ-043 REQ-044 REQ-046 REQ-058
|
|
227
|
+
"""
|
|
228
|
+
if not load_config(home)["enabled"]:
|
|
229
|
+
return 0
|
|
230
|
+
return _manage(SKILLS_INSTALL)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _run_upgrade(command: list[str]) -> int:
|
|
234
|
+
"""
|
|
235
|
+
@brief Execute one upgrade command and chain the post-upgrade install.
|
|
236
|
+
@details Delegates to `_manage` and, on a zero exit code under Linux,
|
|
237
|
+
chains `_post_upgrade_install` so the freshly upgraded package
|
|
238
|
+
installs bundled resources into every enabled CLI. Used by both
|
|
239
|
+
the PyPI `--upgrade` form and the GitHub `--upgrade --dev` form.
|
|
240
|
+
@param command {list[str]} Upgrade command argv executed on Linux and
|
|
241
|
+
printed as the manual command on other operating systems.
|
|
242
|
+
@return {int} Upgrade exit code, or the chained `cli-skills install` exit
|
|
243
|
+
code when the chained install executes.
|
|
244
|
+
@satisfies REQ-010 REQ-011 REQ-012 REQ-043 REQ-044 REQ-045 REQ-046 REQ-063
|
|
245
|
+
"""
|
|
246
|
+
upgraded = _manage(command)
|
|
247
|
+
if upgraded == 0 and sys.platform.startswith("linux"):
|
|
248
|
+
return _post_upgrade_install()
|
|
249
|
+
return upgraded
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _unsupported(name: str) -> str:
|
|
253
|
+
"""
|
|
254
|
+
@brief Build an unsupported-CLI diagnostic message.
|
|
255
|
+
@details Lists the canonical CLI names accepted by the adapter registry.
|
|
256
|
+
@param name {str} Unsupported CLI name from the command line.
|
|
257
|
+
@return {str} Human-readable diagnostic message.
|
|
258
|
+
@satisfies REQ-026
|
|
259
|
+
"""
|
|
260
|
+
return f"Unsupported CLI: {name}. Supported: {', '.join(TARGETS)}."
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _enable(name: str, home: Path | None = None) -> int:
|
|
264
|
+
"""
|
|
265
|
+
@brief Enable resource installation for one supported CLI.
|
|
266
|
+
@details Resolves the CLI adapter, loads the user configuration, appends
|
|
267
|
+
the canonical CLI name when absent, and persists the configuration.
|
|
268
|
+
Re-enabling an enabled CLI leaves configuration unchanged.
|
|
269
|
+
@param name {str} CLI name from the command line.
|
|
270
|
+
@param home {Path | None} Optional home-directory override.
|
|
271
|
+
@return {int} Exit code: 0 on success; 2 for an unsupported CLI.
|
|
272
|
+
@throws {OSError} When saving the configuration file fails.
|
|
273
|
+
@satisfies REQ-024 REQ-026 REQ-027 REQ-028
|
|
274
|
+
"""
|
|
275
|
+
target = resolve_target(name)
|
|
276
|
+
if target is None:
|
|
277
|
+
print(_unsupported(name), file=sys.stderr)
|
|
278
|
+
return 2
|
|
279
|
+
config = load_config(home)
|
|
280
|
+
if target.name in config["enabled"]:
|
|
281
|
+
print(f"{target.name} is already enabled.")
|
|
282
|
+
return 0
|
|
283
|
+
config["enabled"].append(target.name)
|
|
284
|
+
save_config(config, home)
|
|
285
|
+
print(f"Enabled {target.name}.")
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _disable(name: str, home: Path | None = None) -> int:
|
|
290
|
+
"""
|
|
291
|
+
@brief Disable resource installation for one supported CLI.
|
|
292
|
+
@details Resolves the CLI adapter, loads the user configuration, removes
|
|
293
|
+
the canonical CLI name when present, and persists the
|
|
294
|
+
configuration. Disabling an already disabled CLI is a successful
|
|
295
|
+
no-op.
|
|
296
|
+
@param name {str} CLI name from the command line.
|
|
297
|
+
@param home {Path | None} Optional home-directory override.
|
|
298
|
+
@return {int} Exit code: 0 on success; 2 for an unsupported CLI.
|
|
299
|
+
@throws {OSError} When saving the configuration file fails.
|
|
300
|
+
@satisfies REQ-025 REQ-026 REQ-027 REQ-028
|
|
301
|
+
"""
|
|
302
|
+
target = resolve_target(name)
|
|
303
|
+
if target is None:
|
|
304
|
+
print(_unsupported(name), file=sys.stderr)
|
|
305
|
+
return 2
|
|
306
|
+
config = load_config(home)
|
|
307
|
+
if target.name not in config["enabled"]:
|
|
308
|
+
print(f"{target.name} is already disabled.")
|
|
309
|
+
return 0
|
|
310
|
+
config["enabled"] = [item for item in config["enabled"] if item != target.name]
|
|
311
|
+
save_config(config, home)
|
|
312
|
+
print(f"Disabled {target.name}.")
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _install(home: Path | None = None) -> int:
|
|
317
|
+
"""
|
|
318
|
+
@brief Install bundled resources for all enabled CLIs.
|
|
319
|
+
@details Delegates installation to `install_enabled`, prints per-CLI skill
|
|
320
|
+
and prompt counts plus the installed skill names and prompt
|
|
321
|
+
filenames, prints an informational message when no CLI is
|
|
322
|
+
enabled, and reports collected failures.
|
|
323
|
+
@param home {Path | None} Optional home-directory override.
|
|
324
|
+
@return {int} Exit code: 0 when no failure occurs; 1 when any failure
|
|
325
|
+
occurs.
|
|
326
|
+
@satisfies REQ-029 REQ-030 REQ-031 REQ-032 REQ-057
|
|
327
|
+
"""
|
|
328
|
+
summaries, errors = install_enabled(home)
|
|
329
|
+
if not summaries and not errors:
|
|
330
|
+
print("No CLI enabled. Use 'cli-skills enable <cli name>' first.")
|
|
331
|
+
return 0
|
|
332
|
+
for summary in summaries:
|
|
333
|
+
print(
|
|
334
|
+
f"Installed {summary.skills} skills and "
|
|
335
|
+
f"{summary.prompts} prompts for {summary.cli}."
|
|
336
|
+
)
|
|
337
|
+
skill_names = ", ".join(summary.skill_names) or "none"
|
|
338
|
+
prompt_names = ", ".join(summary.prompt_files) or "none"
|
|
339
|
+
print(f" Skills: {skill_names}")
|
|
340
|
+
print(f" Prompts: {prompt_names}")
|
|
341
|
+
for error in errors:
|
|
342
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
343
|
+
return 1 if errors else 0
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _uninstall(home: Path | None = None) -> int:
|
|
347
|
+
"""
|
|
348
|
+
@brief Remove installed bundled resources for all enabled CLIs.
|
|
349
|
+
@details Delegates uninstallation to `uninstall_enabled`, prints per-CLI
|
|
350
|
+
removed skill and prompt counts, prints an informational message
|
|
351
|
+
when no CLI is enabled, and reports collected failures.
|
|
352
|
+
@param home {Path | None} Optional home-directory override.
|
|
353
|
+
@return {int} Exit code: 0 when no failure occurs; 1 when any failure
|
|
354
|
+
occurs.
|
|
355
|
+
@satisfies REQ-047 REQ-054 REQ-055 REQ-056
|
|
356
|
+
"""
|
|
357
|
+
summaries, errors = uninstall_enabled(home)
|
|
358
|
+
if not summaries and not errors:
|
|
359
|
+
print("No CLI enabled. Use 'cli-skills enable <cli name>' first.")
|
|
360
|
+
return 0
|
|
361
|
+
for summary in summaries:
|
|
362
|
+
print(
|
|
363
|
+
f"Removed {summary.skills} skills and "
|
|
364
|
+
f"{summary.prompts} prompts for {summary.cli}."
|
|
365
|
+
)
|
|
366
|
+
for error in errors:
|
|
367
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
368
|
+
return 1 if errors else 0
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _show(home: Path | None = None) -> int:
|
|
372
|
+
"""
|
|
373
|
+
@brief Print packaged resources, CLI configuration, and installation status.
|
|
374
|
+
@details Delegates report rendering to `build_show` and prints the result.
|
|
375
|
+
@param home {Path | None} Optional home-directory override.
|
|
376
|
+
@return {int} Exit code 0 after printing the report.
|
|
377
|
+
@throws {OSError} When bundled resources or the configuration cannot be read.
|
|
378
|
+
@satisfies REQ-037 REQ-038 REQ-039 REQ-042
|
|
379
|
+
"""
|
|
380
|
+
print(build_show(home))
|
|
381
|
+
return 0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def main(argv: list[str] | None = None) -> int:
|
|
385
|
+
"""
|
|
386
|
+
@brief Run the automatic release check and dispatch one CLI command.
|
|
387
|
+
@details Executes `check_version` before argument parsing (forced by
|
|
388
|
+
`--ver` or `--version`), then routes to help, version,
|
|
389
|
+
management, enable/disable, install, uninstall, or show
|
|
390
|
+
handlers. The `--upgrade` branch with or without `--dev` chains
|
|
391
|
+
`cli-skills install` after a successful Linux upgrade when the
|
|
392
|
+
enabled configuration is non-empty and returns the chained exit
|
|
393
|
+
code. The `--ver` and `--version` branch prints the installed
|
|
394
|
+
version plus the PyPI and GitHub latest-version lines.
|
|
395
|
+
@param argv {list[str] | None} Command-line arguments; defaults to
|
|
396
|
+
`sys.argv[1:]`.
|
|
397
|
+
@return {int} Process exit code: 0 on success; 1 on upgrade or install
|
|
398
|
+
failure; 2 on usage errors.
|
|
399
|
+
@satisfies REQ-006 REQ-007 REQ-008 REQ-009 REQ-010 REQ-011 REQ-012
|
|
400
|
+
REQ-013 REQ-014 REQ-015 REQ-024 REQ-025 REQ-026 REQ-029
|
|
401
|
+
REQ-030 REQ-031 REQ-032 REQ-037 REQ-042 REQ-043 REQ-044
|
|
402
|
+
REQ-045 REQ-046 REQ-047 REQ-054 REQ-055 REQ-056 REQ-057
|
|
403
|
+
REQ-058 REQ-059 REQ-060 REQ-061 REQ-062 REQ-063 REQ-064
|
|
404
|
+
REQ-065 DES-089
|
|
405
|
+
"""
|
|
406
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
407
|
+
check_version(force="--ver" in args or "--version" in args)
|
|
408
|
+
if not args or args == ["--help"]:
|
|
409
|
+
print(HELP)
|
|
410
|
+
return 0
|
|
411
|
+
if len(args) == 2 and args[1] == "--help":
|
|
412
|
+
command = args[0]
|
|
413
|
+
descriptions = {
|
|
414
|
+
"--upgrade": "Reinstall cli-skills on Linux; print manual command elsewhere.",
|
|
415
|
+
"--uninstall": "Uninstall cli-skills on Linux; print manual command elsewhere.",
|
|
416
|
+
"--ver": "Print the cli-skills version.",
|
|
417
|
+
"--version": "Print the cli-skills version.",
|
|
418
|
+
"--dev": "With --upgrade, install the latest GitHub release instead of PyPI.",
|
|
419
|
+
"enable": "Enable skill and prompt installation for a supported CLI.",
|
|
420
|
+
"disable": "Disable skill and prompt installation for a supported CLI.",
|
|
421
|
+
"install": "Install skills and prompts for every enabled CLI.",
|
|
422
|
+
"uninstall": "Remove installed skills and prompts for every enabled CLI.",
|
|
423
|
+
"show": "Show packaged resources, CLI configuration, and installation status.",
|
|
424
|
+
}
|
|
425
|
+
if command in descriptions:
|
|
426
|
+
print(f"{command} - {descriptions[command]}")
|
|
427
|
+
return 0
|
|
428
|
+
if args in (["--ver"], ["--version"]):
|
|
429
|
+
print(__version__)
|
|
430
|
+
try:
|
|
431
|
+
print(f"PyPI latest: {fetch_pypi_latest()}")
|
|
432
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
433
|
+
print(f"PyPI latest: unavailable ({exc})")
|
|
434
|
+
try:
|
|
435
|
+
print(f"GitHub latest: {fetch_github_latest()}")
|
|
436
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
437
|
+
print(f"GitHub latest: unavailable ({exc})")
|
|
438
|
+
return 0
|
|
439
|
+
if args == ["--upgrade"]:
|
|
440
|
+
return _run_upgrade(UPGRADE)
|
|
441
|
+
if args in (["--upgrade", "--dev"], ["--upgrade", "--develop"]):
|
|
442
|
+
return _run_upgrade(UPGRADE_DEVELOP)
|
|
443
|
+
if args == ["--uninstall"]:
|
|
444
|
+
return _manage(UNINSTALL)
|
|
445
|
+
if len(args) == 1 and args[0] in ("enable", "disable"):
|
|
446
|
+
print(f"Usage: cli-skills {args[0]} <cli name>", file=sys.stderr)
|
|
447
|
+
return 2
|
|
448
|
+
if len(args) == 2 and args[0] == "enable":
|
|
449
|
+
return _enable(args[1])
|
|
450
|
+
if len(args) == 2 and args[0] == "disable":
|
|
451
|
+
return _disable(args[1])
|
|
452
|
+
if args == ["install"]:
|
|
453
|
+
return _install()
|
|
454
|
+
if args == ["uninstall"]:
|
|
455
|
+
return _uninstall()
|
|
456
|
+
if args == ["show"]:
|
|
457
|
+
return _show()
|
|
458
|
+
print(f"Unknown command or options: {' '.join(args)}", file=sys.stderr)
|
|
459
|
+
return 2
|