skillnav 0.3.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {skillnav-0.3.0 → skillnav-0.4.2}/PKG-INFO +6 -5
  2. {skillnav-0.3.0 → skillnav-0.4.2}/README.md +2 -3
  3. {skillnav-0.3.0 → skillnav-0.4.2}/pyproject.toml +23 -3
  4. skillnav-0.4.2/src/skillnav/__init__.py +3 -0
  5. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav/__main__.py +1 -1
  6. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav/api.py +8 -7
  7. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav/cli.py +140 -65
  8. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav/context.py +2 -1
  9. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav/contributors.py +13 -4
  10. skillnav-0.4.2/src/skillnav/error_hints.py +424 -0
  11. skillnav-0.4.2/src/skillnav/errors.py +53 -0
  12. skillnav-0.4.2/src/skillnav/output.py +874 -0
  13. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav/self_update.py +4 -2
  14. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav.egg-info/PKG-INFO +6 -5
  15. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav.egg-info/SOURCES.txt +1 -0
  16. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav.egg-info/requires.txt +2 -0
  17. skillnav-0.3.0/src/skillnav/__init__.py +0 -3
  18. skillnav-0.3.0/src/skillnav/errors.py +0 -32
  19. skillnav-0.3.0/src/skillnav/output.py +0 -427
  20. {skillnav-0.3.0 → skillnav-0.4.2}/setup.cfg +0 -0
  21. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav/config.py +0 -0
  22. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav/packages.py +0 -0
  23. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav/publish_metadata.py +0 -0
  24. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav/skill_categories.py +0 -0
  25. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav/urls.py +0 -0
  26. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav.egg-info/dependency_links.txt +0 -0
  27. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav.egg-info/entry_points.txt +0 -0
  28. {skillnav-0.3.0 → skillnav-0.4.2}/src/skillnav.egg-info/top_level.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillnav
3
- Version: 0.3.0
4
- Summary: CLI client for the Skill management platform (MonoSkillNavigator)
3
+ Version: 0.4.2
4
+ Summary: CLI client for the Skill management platform (SkillNavigator)
5
5
  License: Apache-2.0
6
6
  Keywords: skill,cli,agent,registry
7
7
  Classifier: Programming Language :: Python :: 3
@@ -12,17 +12,19 @@ Description-Content-Type: text/markdown
12
12
  Requires-Dist: typer>=0.12
13
13
  Provides-Extra: dev
14
14
  Requires-Dist: pytest>=8.0; extra == "dev"
15
+ Requires-Dist: ruff>=0.9; extra == "dev"
16
+ Requires-Dist: mypy>=1.10; extra == "dev"
15
17
 
16
18
  # skillnav
17
19
 
18
- Official CLI client for the Skill management platform (MonoSkillNavigator).
20
+ Official CLI client for the Skill management platform (SkillNavigator).
19
21
 
20
22
  Specification: `docs/cli-design.md` in the platform repository.
21
23
 
22
24
  ## Install
23
25
 
24
26
  ```bash
25
- pipx install skillnav
27
+ pip install skillnav -i https://mirrors.aliyun.com/pypi/simple/
26
28
  # or from this repo:
27
29
  pip install -e "cli-py[dev]"
28
30
  ```
@@ -36,7 +38,6 @@ skillnav config test
36
38
  skillnav login --api-key sk_...
37
39
  skillnav search demo
38
40
  skillnav info demo-skill
39
- skillnav review examples/demo-skill
40
41
  skillnav publish examples/demo-skill --dry-run
41
42
  skillnav download demo-skill -o /tmp/demo.zip
42
43
  skillnav update # upgrade when PyPI has a newer release
@@ -1,13 +1,13 @@
1
1
  # skillnav
2
2
 
3
- Official CLI client for the Skill management platform (MonoSkillNavigator).
3
+ Official CLI client for the Skill management platform (SkillNavigator).
4
4
 
5
5
  Specification: `docs/cli-design.md` in the platform repository.
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- pipx install skillnav
10
+ pip install skillnav -i https://mirrors.aliyun.com/pypi/simple/
11
11
  # or from this repo:
12
12
  pip install -e "cli-py[dev]"
13
13
  ```
@@ -21,7 +21,6 @@ skillnav config test
21
21
  skillnav login --api-key sk_...
22
22
  skillnav search demo
23
23
  skillnav info demo-skill
24
- skillnav review examples/demo-skill
25
24
  skillnav publish examples/demo-skill --dry-run
26
25
  skillnav download demo-skill -o /tmp/demo.zip
27
26
  skillnav update # upgrade when PyPI has a newer release
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skillnav"
7
- version = "0.3.0"
8
- description = "CLI client for the Skill management platform (MonoSkillNavigator)"
7
+ version = "0.4.2"
8
+ description = "CLI client for the Skill management platform (SkillNavigator)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
11
11
  license = { text = "Apache-2.0" }
@@ -20,10 +20,30 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  [project.optional-dependencies]
23
- dev = ["pytest>=8.0"]
23
+ dev = [
24
+ "pytest>=8.0",
25
+ "ruff>=0.9",
26
+ "mypy>=1.10",
27
+ ]
24
28
 
25
29
  [project.scripts]
26
30
  skillnav = "skillnav.cli:main"
27
31
 
32
+ [tool.ruff]
33
+ line-length = 100
34
+ target-version = "py39"
35
+ src = ["src"]
36
+
37
+ [tool.ruff.lint]
38
+ # 默认集(F 全部 + E4/E7/E9);后续需要可扩展(I/UP/B 等)。
39
+ select = ["E4", "E7", "E9", "F"]
40
+
41
+ [tool.mypy]
42
+ python_version = "3.9"
43
+ check_untyped_defs = false
44
+ warn_redundant_casts = true
45
+ no_implicit_optional = true
46
+ ignore_missing_imports = true
47
+
28
48
  [tool.setuptools.packages.find]
29
49
  where = ["src"]
@@ -0,0 +1,3 @@
1
+ """skillnav — CLI client for the Skill management platform (SkillNavigator)."""
2
+
3
+ __version__ = "0.4.2"
@@ -1,4 +1,4 @@
1
1
  from skillnav.cli import main
2
2
 
3
3
  if __name__ == "__main__":
4
- raise SystemExit(main())
4
+ main()
@@ -7,7 +7,8 @@ import urllib.error
7
7
  import urllib.request
8
8
  from typing import Any
9
9
 
10
- from skillnav.errors import NetworkError, SkillnavError
10
+ from skillnav.error_hints import enrich_api_error, network_unreachable
11
+ from skillnav.errors import AuthError, NetworkError, SkillnavError
11
12
 
12
13
 
13
14
  def request_bytes(
@@ -35,7 +36,9 @@ def request_bytes(
35
36
  return exc.code, exc.read(), dict(exc.headers.items())
36
37
  except urllib.error.URLError as exc:
37
38
  reason = getattr(exc, "reason", exc)
38
- raise NetworkError(str(reason)) from exc
39
+ raise NetworkError.from_hint(network_unreachable(str(reason), registry=url)) from exc
40
+ except OSError as exc:
41
+ raise NetworkError.from_hint(network_unreachable(str(exc), registry=url)) from exc
39
42
 
40
43
 
41
44
  def request_json(
@@ -69,12 +72,10 @@ def api_error_message(body: Any) -> str:
69
72
  def raise_for_api_status(status: int, body: Any) -> None:
70
73
  if status < 400:
71
74
  return
72
- message = api_error_message(body)
75
+ hint = enrich_api_error(api_error_message(body), status=status, body=body)
73
76
  if status in (401, 403):
74
- from skillnav.errors import AuthError
75
-
76
- raise AuthError(message)
77
- raise SkillnavError(message)
77
+ raise AuthError(hint.summary, hint=hint)
78
+ raise SkillnavError(hint.summary, hint=hint)
78
79
 
79
80
 
80
81
  def parse_content_disposition_filename(header: str | None) -> str | None:
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import sys
6
5
  from pathlib import Path
7
6
  from typing import Annotated, Any, Optional
8
7
 
@@ -16,14 +15,22 @@ from skillnav.api import (
16
15
  request_bytes,
17
16
  request_json,
18
17
  )
19
- from skillnav.config import get_profile, load_config, save_config
18
+ from skillnav.config import get_profile, load_config, resolve_profile_api_key, save_config
20
19
  from skillnav.contributors import resolve_contributor_id
21
20
  from skillnav.context import CliContext
21
+ from skillnav.error_hints import (
22
+ enrich_api_error,
23
+ enrich_usage_error,
24
+ hint_from_message,
25
+ invalid_api_key,
26
+ not_logged_in,
27
+ )
22
28
  from skillnav.errors import (
23
29
  EXIT_AUTH,
24
30
  EXIT_BUSINESS,
25
31
  EXIT_NETWORK,
26
32
  EXIT_OK,
33
+ EXIT_USAGE,
27
34
  AuthError,
28
35
  NetworkError,
29
36
  SkillnavError,
@@ -32,17 +39,16 @@ from skillnav.errors import (
32
39
  from skillnav.output import (
33
40
  emit_error,
34
41
  emit_json,
42
+ filter_skill_body_version,
35
43
  print_leaderboard,
36
44
  print_report_version,
37
- print_review,
38
- print_review_result,
45
+ print_inspection_result,
39
46
  print_search_results,
40
47
  print_skill_info,
41
48
  print_skill_status,
42
- print_virustotal_summary,
43
49
  unwrap_resource_id,
44
50
  )
45
- from skillnav.packages import extract_zip_to_directory, package_to_base64, resolve_user_path
51
+ from skillnav.packages import extract_zip_to_directory, package_to_base64
46
52
  from skillnav.publish_metadata import build_publish_metadata, read_frontmatter_hints, resolve_package_path
47
53
  from skillnav.urls import join_registry_url, slug_path
48
54
 
@@ -61,20 +67,46 @@ def _ctx() -> CliContext:
61
67
  return _state["ctx"]
62
68
 
63
69
 
70
+ def _normalize_skillnav_error(exc: SkillnavError) -> SkillnavError:
71
+ if exc.detail or exc.next_steps:
72
+ return exc
73
+ if isinstance(exc, UsageError):
74
+ hint = enrich_usage_error(exc.message)
75
+ else:
76
+ hint = hint_from_message(exc.message)
77
+ return type(exc)(hint.summary, hint=hint)
78
+
79
+
80
+ def _emit_skillnav_error(exc: SkillnavError, *, json_output: bool) -> None:
81
+ emit_error(
82
+ exc.message,
83
+ json_output=json_output,
84
+ detail=exc.detail,
85
+ next_steps=exc.next_steps,
86
+ )
87
+
88
+
64
89
  def _handle_error(exc: BaseException) -> None:
65
90
  ctx = _state.get("ctx")
66
91
  json_output = bool(ctx and ctx.json_output)
92
+
93
+ if isinstance(exc, ValueError):
94
+ hint = enrich_usage_error(str(exc))
95
+ wrapped = UsageError(hint.summary, hint=hint)
96
+ _emit_skillnav_error(wrapped, json_output=json_output)
97
+ raise typer.Exit(EXIT_USAGE) from exc
98
+
67
99
  if isinstance(exc, AuthError):
68
- emit_error(exc.message, json_output=json_output)
100
+ _emit_skillnav_error(_normalize_skillnav_error(exc), json_output=json_output)
69
101
  raise typer.Exit(EXIT_AUTH) from exc
70
102
  if isinstance(exc, UsageError):
71
- emit_error(exc.message, json_output=json_output)
72
- raise typer.Exit(EXIT_BUSINESS) from exc
103
+ _emit_skillnav_error(_normalize_skillnav_error(exc), json_output=json_output)
104
+ raise typer.Exit(EXIT_USAGE) from exc
73
105
  if isinstance(exc, NetworkError):
74
- emit_error(exc.message, json_output=json_output)
106
+ _emit_skillnav_error(exc, json_output=json_output)
75
107
  raise typer.Exit(EXIT_NETWORK) from exc
76
108
  if isinstance(exc, SkillnavError):
77
- emit_error(exc.message, json_output=json_output)
109
+ _emit_skillnav_error(_normalize_skillnav_error(exc), json_output=json_output)
78
110
  raise typer.Exit(EXIT_BUSINESS) from exc
79
111
  raise exc
80
112
 
@@ -92,16 +124,6 @@ def _api_json(method: str, path: str, *, body: dict | None = None, auth: bool =
92
124
  return payload
93
125
 
94
126
 
95
- def _friendly_publish_error(message: str) -> str:
96
- if message == "skill_in_recycle_bin":
97
- return "Skill is in the recycle bin — restore it on the web UI before publishing."
98
- if message == "Only skill contributors can publish new versions":
99
- return "Only skill contributors can publish new versions — ask the owner to add you."
100
- if message == "review_pipeline_incomplete":
101
- return "Review pipeline incomplete (503) — retry in a moment."
102
- return message
103
-
104
-
105
127
  def _version_callback(value: bool) -> None:
106
128
  if value:
107
129
  typer.echo(f"skillnav {__version__}")
@@ -109,7 +131,7 @@ def _version_callback(value: bool) -> None:
109
131
 
110
132
 
111
133
  @app.callback()
112
- def main(
134
+ def cli_root(
113
135
  ctx: typer.Context,
114
136
  registry: Annotated[
115
137
  Optional[str], typer.Option("--registry", help="API base URL (overrides profile)")
@@ -132,7 +154,7 @@ def main(
132
154
  ),
133
155
  ] = None,
134
156
  ) -> None:
135
- """CLI client for the Skill management platform (MonoSkillNavigator)."""
157
+ """CLI client for the Skill management platform (SkillNavigator)."""
136
158
  _state["ctx"] = CliContext.resolve(
137
159
  registry_flag=registry,
138
160
  profile_flag=profile,
@@ -172,7 +194,7 @@ def config_use(name: Annotated[str, typer.Argument(help="Profile name to activat
172
194
  cli = _ctx()
173
195
  config = load_config()
174
196
  if name not in config.get("profiles", {}):
175
- raise UsageError(f"Unknown profile: {name}")
197
+ raise UsageError.from_hint(enrich_usage_error(f"Unknown profile: {name}"))
176
198
  config["defaultProfile"] = name
177
199
  save_config(config)
178
200
  if cli.json_output:
@@ -216,7 +238,8 @@ def config_test(
216
238
  registry = profile.get("registry") or cli.registry
217
239
  status, body = request_json("GET", join_registry_url(registry, "/health"))
218
240
  if status >= 400:
219
- raise SkillnavError(api_error_message(body))
241
+ hint = enrich_api_error(api_error_message(body), status=status, body=body)
242
+ raise SkillnavError(hint.summary, hint=hint)
220
243
  if cli.json_output:
221
244
  emit_json(body)
222
245
  else:
@@ -249,17 +272,17 @@ def login_cmd(
249
272
  credential = api_key
250
273
  if not credential:
251
274
  if cli.no_input:
252
- raise AuthError("not logged in (run: skillnav login --api-key KEY)")
275
+ raise AuthError.from_hint(not_logged_in(profile=cli.profile_name))
253
276
  credential = typer.prompt("API key", hide_input=True).strip()
254
277
  if not credential:
255
- raise AuthError("API key is required")
278
+ raise AuthError.from_hint(not_logged_in(profile=cli.profile_name))
256
279
  status, me_body = request_json(
257
280
  "GET",
258
281
  join_registry_url(cli.registry, "/auth/me"),
259
282
  token=credential,
260
283
  )
261
284
  if status >= 400:
262
- raise AuthError(api_error_message(me_body))
285
+ raise AuthError.from_hint(invalid_api_key())
263
286
  cli.persist_api_key(credential, me_body)
264
287
  user = me_body.get("user") or {}
265
288
  if cli.json_output:
@@ -375,8 +398,14 @@ def info_cmd(slug: Annotated[str, typer.Argument(help="Skill slug")]) -> None:
375
398
 
376
399
 
377
400
  @app.command("status")
378
- def status_cmd(slug: Annotated[str, typer.Argument(help="Skill slug")]) -> None:
379
- """Show publish status and version review summaries."""
401
+ def status_cmd(
402
+ slug: Annotated[str, typer.Argument(help="Skill slug")],
403
+ version: Annotated[
404
+ Optional[str],
405
+ typer.Option("--version", help="Version to show (default: latest)"),
406
+ ] = None,
407
+ ) -> None:
408
+ """Show publish and inspection status for one skill version."""
380
409
  try:
381
410
  cli = _ctx()
382
411
  status, body = request_json(
@@ -385,10 +414,14 @@ def status_cmd(slug: Annotated[str, typer.Argument(help="Skill slug")]) -> None:
385
414
  token=cli.token,
386
415
  )
387
416
  raise_for_api_status(status, body)
417
+ target_version = version or body.get("latestVersion")
418
+ if not target_version:
419
+ raise SkillnavError(f"No versions found for skill '{slug}'")
420
+ body = filter_skill_body_version(body, str(target_version))
388
421
  if cli.json_output:
389
422
  emit_json(body)
390
423
  else:
391
- print_skill_status(body)
424
+ print_skill_status(body, version=str(target_version))
392
425
  except Exception as exc: # noqa: BLE001
393
426
  _handle_error(exc)
394
427
 
@@ -418,7 +451,28 @@ def report_cmd(
418
451
  _handle_error(exc)
419
452
 
420
453
 
421
- # --- publish / review ---
454
+ # --- publish ---
455
+
456
+
457
+ def _print_publish_response(status: int, payload: dict[str, Any], *, waited: bool) -> None:
458
+ if status == 202 or payload.get("inspectionStatus") == "inspecting":
459
+ typer.echo(
460
+ f"Uploaded {payload.get('name')} ({payload.get('slug')})@{payload.get('version')}"
461
+ )
462
+ if waited:
463
+ typer.echo("Inspection started (inspectionStatus: inspecting).")
464
+ else:
465
+ typer.echo("Inspection started in the background (inspectionStatus: inspecting).")
466
+ typer.echo(f"Check progress: skillnav status {payload.get('slug')}")
467
+ return
468
+
469
+ typer.echo(
470
+ f"Published {payload.get('name')} ({payload.get('slug')})@{payload.get('version')}"
471
+ )
472
+ typer.echo(f"Status: {payload.get('status')}")
473
+ typer.echo(f"Hash: {payload.get('contentHash')}")
474
+ if payload.get("inspection") or payload.get("review") or payload.get("evaluation"):
475
+ print_inspection_result(payload)
422
476
 
423
477
 
424
478
  @app.command("publish")
@@ -443,8 +497,15 @@ def publish_cmd(
443
497
  ] = None,
444
498
  changelog: Annotated[Optional[str], typer.Option("--changelog", help="Changelog text")] = None,
445
499
  dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview without publishing")] = False,
500
+ wait: Annotated[
501
+ bool,
502
+ typer.Option(
503
+ "--wait",
504
+ help="Wait for the full inspection pipeline to finish before returning (default: upload only, inspection in background)",
505
+ ),
506
+ ] = False,
446
507
  ) -> None:
447
- """Publish a skill package to the registry."""
508
+ """Upload a skill package to the registry; inspection runs in the background unless --wait."""
448
509
  try:
449
510
  cli = _ctx()
450
511
  token = cli.require_token()
@@ -466,6 +527,8 @@ def publish_cmd(
466
527
  body: dict[str, Any] = {"archiveBase64": archive_base64, "metadata": metadata}
467
528
  if changelog:
468
529
  body["changelog"] = changelog
530
+ if not dry_run:
531
+ body["async"] = not wait
469
532
  path = "/skills/publish/preview" if dry_run else "/skills/publish"
470
533
  status, payload = request_json(
471
534
  method="POST",
@@ -474,8 +537,8 @@ def publish_cmd(
474
537
  token=token,
475
538
  )
476
539
  if status >= 400:
477
- message = _friendly_publish_error(api_error_message(payload))
478
- raise SkillnavError(message)
540
+ hint = enrich_api_error(api_error_message(payload), status=status, body=payload)
541
+ raise SkillnavError(hint.summary, hint=hint)
479
542
  if cli.json_output:
480
543
  emit_json(payload if not dry_run else {"metadata": metadata, **payload})
481
544
  return
@@ -490,41 +553,39 @@ def publish_cmd(
490
553
  if payload.get("entryPath"):
491
554
  typer.echo(f"Entry: {payload['entryPath']}")
492
555
  return
493
- typer.echo(
494
- f"Published {payload.get('name')} ({payload.get('slug')})@{payload.get('version')}"
495
- )
496
- typer.echo(f"Status: {payload.get('status')}")
497
- typer.echo(f"Hash: {payload.get('contentHash')}")
498
- if payload.get("review") or payload.get("evaluation"):
499
- print_review_result(payload)
556
+ _print_publish_response(status, payload, waited=wait)
500
557
  except Exception as exc: # noqa: BLE001
501
558
  _handle_error(exc)
502
559
 
503
560
 
504
- @app.command("review")
505
- def review_cmd(
506
- package: Annotated[str, typer.Argument(help="Skill directory or .zip")],
507
- version: Annotated[Optional[str], typer.Option("--version", help="Version label")] = None,
561
+ @app.command("retry-publish")
562
+ def retry_publish_cmd(
563
+ slug: Annotated[str, typer.Argument(help="Skill slug")],
564
+ wait: Annotated[
565
+ bool,
566
+ typer.Option(
567
+ "--wait",
568
+ help="Wait for the full inspection pipeline to finish before returning",
569
+ ),
570
+ ] = False,
508
571
  ) -> None:
509
- """Run remote review without publishing."""
572
+ """Re-run inspection on the stored package after a failed or incomplete inspection."""
510
573
  try:
511
574
  cli = _ctx()
512
- archive_base64 = package_to_base64(resolve_user_path(package))
513
- body: dict[str, Any] = {"archiveBase64": archive_base64}
514
- if version:
515
- body["version"] = version
575
+ token = cli.require_token()
516
576
  status, payload = request_json(
517
- "POST",
518
- join_registry_url(cli.registry, "/reviews/run"),
519
- body=body,
520
- token=cli.require_token(),
577
+ method="POST",
578
+ url=join_registry_url(cli.registry, f"/skills/{slug_path(slug)}/retry-publish"),
579
+ body={"async": not wait},
580
+ token=token,
521
581
  )
522
- raise_for_api_status(status, payload)
582
+ if status >= 400:
583
+ hint = enrich_api_error(api_error_message(payload), status=status, body=payload)
584
+ raise SkillnavError(hint.summary, hint=hint)
523
585
  if cli.json_output:
524
586
  emit_json(payload)
525
587
  return
526
- if payload.get("review") or payload.get("evaluation"):
527
- print_review_result(payload)
588
+ _print_publish_response(status, payload, waited=wait)
528
589
  except Exception as exc: # noqa: BLE001
529
590
  _handle_error(exc)
530
591
 
@@ -557,9 +618,11 @@ def download_cmd(
557
618
  import json
558
619
 
559
620
  payload = json.loads(data.decode("utf-8"))
560
- raise SkillnavError(api_error_message(payload))
621
+ hint = enrich_api_error(api_error_message(payload), status=status, body=payload)
622
+ raise SkillnavError(hint.summary, hint=hint)
561
623
  except (json.JSONDecodeError, UnicodeDecodeError):
562
- raise SkillnavError(f"download failed ({status})") from None
624
+ hint = enrich_api_error(f"download failed ({status})", status=status)
625
+ raise SkillnavError(hint.summary, hint=hint) from None
563
626
  filename = parse_content_disposition_filename(headers.get("Content-Disposition"))
564
627
  out_path = output or Path(f"{slug}-{version}.zip")
565
628
  out_path.write_bytes(data)
@@ -596,9 +659,11 @@ def install_cmd(
596
659
 
597
660
  try:
598
661
  payload = json.loads(data.decode("utf-8"))
599
- raise SkillnavError(api_error_message(payload))
662
+ hint = enrich_api_error(api_error_message(payload), status=status, body=payload)
663
+ raise SkillnavError(hint.summary, hint=hint)
600
664
  except (json.JSONDecodeError, UnicodeDecodeError):
601
- raise SkillnavError(f"download failed ({status})") from None
665
+ hint = enrich_api_error(f"download failed ({status})", status=status)
666
+ raise SkillnavError(hint.summary, hint=hint) from None
602
667
  target = dir or Path(slug)
603
668
  extract_zip_to_directory(data, target)
604
669
  if cli.json_output:
@@ -739,9 +804,11 @@ def remove_contributor_cmd(
739
804
  """Remove a contributor (owner only)."""
740
805
  try:
741
806
  if not id and not username:
742
- raise UsageError("specify --id or --username")
807
+ raise UsageError.from_hint(enrich_usage_error("specify --id or --username"))
743
808
  if id and username:
744
- raise UsageError("specify only one of --id or --username")
809
+ raise UsageError.from_hint(
810
+ enrich_usage_error("specify only one of --id or --username")
811
+ )
745
812
 
746
813
  cli = _ctx()
747
814
  token = cli.require_token()
@@ -755,6 +822,9 @@ def remove_contributor_cmd(
755
822
  raise_for_api_status(status, skill_body)
756
823
  contributor_id = resolve_contributor_id(skill_body, username)
757
824
 
825
+ if contributor_id is None:
826
+ raise UsageError.from_hint(enrich_usage_error("no contributor id available to remove"))
827
+
758
828
  status, payload = request_json(
759
829
  "DELETE",
760
830
  join_registry_url(
@@ -808,7 +878,12 @@ def run(argv: list[str] | None = None) -> int:
808
878
  except typer.Exit as exc:
809
879
  return int(exc.exit_code)
810
880
  except KeyboardInterrupt:
811
- emit_error("interrupted", json_output=bool(_state.get("ctx") and _state["ctx"].json_output))
881
+ emit_error(
882
+ "interrupted",
883
+ json_output=bool(_state.get("ctx") and _state["ctx"].json_output),
884
+ detail="Command cancelled by user (Ctrl+C).",
885
+ next_steps=("Retry the command when ready.",),
886
+ )
812
887
  return 130
813
888
 
814
889
 
@@ -14,6 +14,7 @@ from skillnav.config import (
14
14
  save_config,
15
15
  set_profile_identity,
16
16
  )
17
+ from skillnav.error_hints import not_logged_in
17
18
  from skillnav.errors import AuthError
18
19
 
19
20
 
@@ -68,7 +69,7 @@ class CliContext:
68
69
 
69
70
  def require_token(self) -> str:
70
71
  if not self.api_key:
71
- raise AuthError("not logged in (run: skillnav login --api-key KEY)")
72
+ raise AuthError.from_hint(not_logged_in(profile=self.profile_name))
72
73
  return self.api_key
73
74
 
74
75
  def persist_api_key(self, api_key: str, me_body: dict) -> None:
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
6
 
7
+ from skillnav.error_hints import enrich_usage_error
7
8
  from skillnav.errors import SkillnavError
8
9
 
9
10
 
@@ -11,7 +12,9 @@ def resolve_contributor_id(skill_body: dict[str, Any], username: str) -> str:
11
12
  """Resolve a contributor id by username or display name (case-insensitive)."""
12
13
  needle = username.strip().casefold()
13
14
  if not needle:
14
- raise SkillnavError("username must not be empty")
15
+ raise SkillnavError.from_hint(
16
+ enrich_usage_error("username must not be empty")
17
+ )
15
18
 
16
19
  matches: list[dict[str, Any]] = []
17
20
  for contributor in skill_body.get("contributors") or []:
@@ -25,10 +28,16 @@ def resolve_contributor_id(skill_body: dict[str, Any], username: str) -> str:
25
28
  matches.append(contributor)
26
29
 
27
30
  if not matches:
28
- raise SkillnavError(f"contributor not found: {username}")
31
+ raise SkillnavError.from_hint(
32
+ enrich_usage_error(f"contributor not found: {username}")
33
+ )
29
34
  if len(matches) > 1:
30
- raise SkillnavError(f"ambiguous contributor: {username}")
35
+ raise SkillnavError.from_hint(
36
+ enrich_usage_error(f"ambiguous contributor: {username}")
37
+ )
31
38
  contributor_id = matches[0].get("id")
32
39
  if not contributor_id:
33
- raise SkillnavError(f"contributor not found: {username}")
40
+ raise SkillnavError.from_hint(
41
+ enrich_usage_error(f"contributor not found: {username}")
42
+ )
34
43
  return str(contributor_id)