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 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