logion-cli 0.1.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.
Files changed (135) hide show
  1. cli/__init__.py +2 -0
  2. cli/_config.py +51 -0
  3. cli/_confirm.py +16 -0
  4. cli/_context.py +17 -0
  5. cli/_course_bundle.py +46 -0
  6. cli/_course_capabilities.py +580 -0
  7. cli/_credentials.py +104 -0
  8. cli/_errors.py +82 -0
  9. cli/_first_run.py +90 -0
  10. cli/_harness/__init__.py +68 -0
  11. cli/_harness/base.py +106 -0
  12. cli/_harness/claude_code.py +168 -0
  13. cli/_harness/codex.py +79 -0
  14. cli/_harness/custom.py +55 -0
  15. cli/_harness/hermes.py +93 -0
  16. cli/_harness/opencode.py +255 -0
  17. cli/_local_state.py +1053 -0
  18. cli/_options.py +36 -0
  19. cli/_output.py +47 -0
  20. cli/_parser.py +73 -0
  21. cli/_recall_calibration.py +90 -0
  22. cli/_recall_ranker.py +74 -0
  23. cli/_taxonomy.py +120 -0
  24. cli/_update_policy.py +152 -0
  25. cli/_utils.py +16 -0
  26. cli/_version.py +26 -0
  27. cli/commands/__init__.py +2 -0
  28. cli/commands/admin.py +535 -0
  29. cli/commands/bounties.py +490 -0
  30. cli/commands/course_reviews/__init__.py +6 -0
  31. cli/commands/course_reviews/_download_handler.py +104 -0
  32. cli/commands/course_reviews/_render.py +129 -0
  33. cli/commands/course_reviews/handlers.py +197 -0
  34. cli/commands/course_reviews/parser.py +93 -0
  35. cli/commands/courses/__init__.py +6 -0
  36. cli/commands/courses/_capability_render.py +183 -0
  37. cli/commands/courses/_cmd_help.py +18 -0
  38. cli/commands/courses/_purchase.py +76 -0
  39. cli/commands/courses/_review_helpers.py +93 -0
  40. cli/commands/courses/_taxonomy_data.py +173 -0
  41. cli/commands/courses/_upload_bundle_validation.py +28 -0
  42. cli/commands/courses/_uploads_push.py +243 -0
  43. cli/commands/courses/capabilities.py +250 -0
  44. cli/commands/courses/capability_frontmatter.py +150 -0
  45. cli/commands/courses/handlers.py +50 -0
  46. cli/commands/courses/mutations.py +217 -0
  47. cli/commands/courses/parser.py +66 -0
  48. cli/commands/courses/parser_capabilities.py +95 -0
  49. cli/commands/courses/parser_sections.py +239 -0
  50. cli/commands/courses/parser_uploads.py +84 -0
  51. cli/commands/courses/parser_utils.py +65 -0
  52. cli/commands/courses/publication.py +60 -0
  53. cli/commands/courses/report_usage.py +131 -0
  54. cli/commands/courses/reviews.py +237 -0
  55. cli/commands/courses/taxonomy_handler.py +61 -0
  56. cli/commands/courses/taxonomy_suggest.py +197 -0
  57. cli/commands/courses/uploads.py +142 -0
  58. cli/commands/courses/versions.py +65 -0
  59. cli/commands/credits/__init__.py +6 -0
  60. cli/commands/credits/_helpers.py +153 -0
  61. cli/commands/credits/handlers.py +218 -0
  62. cli/commands/credits/parser.py +115 -0
  63. cli/commands/docs/__init__.py +6 -0
  64. cli/commands/docs/handlers.py +137 -0
  65. cli/commands/docs/parser.py +27 -0
  66. cli/commands/health/__init__.py +6 -0
  67. cli/commands/health/handlers.py +26 -0
  68. cli/commands/health/parser.py +20 -0
  69. cli/commands/identity/__init__.py +6 -0
  70. cli/commands/identity/_autopost.py +97 -0
  71. cli/commands/identity/_closing_copy.py +89 -0
  72. cli/commands/identity/_companion.py +232 -0
  73. cli/commands/identity/_companion_source.py +135 -0
  74. cli/commands/identity/_harness_select.py +85 -0
  75. cli/commands/identity/_onboarding_helpers.py +168 -0
  76. cli/commands/identity/handlers.py +173 -0
  77. cli/commands/identity/onboarding.py +246 -0
  78. cli/commands/identity/parser.py +72 -0
  79. cli/commands/listings/__init__.py +6 -0
  80. cli/commands/listings/handlers.py +135 -0
  81. cli/commands/listings/parser.py +57 -0
  82. cli/commands/notifications/__init__.py +6 -0
  83. cli/commands/notifications/handlers.py +120 -0
  84. cli/commands/notifications/parser.py +49 -0
  85. cli/commands/payments/__init__.py +6 -0
  86. cli/commands/payments/_orders_helpers.py +114 -0
  87. cli/commands/payments/handlers.py +138 -0
  88. cli/commands/payments/parser.py +97 -0
  89. cli/commands/recall/__init__.py +7 -0
  90. cli/commands/recall/handlers.py +87 -0
  91. cli/commands/recall/parser.py +70 -0
  92. cli/commands/referrals/__init__.py +6 -0
  93. cli/commands/referrals/_helpers.py +63 -0
  94. cli/commands/referrals/handlers.py +100 -0
  95. cli/commands/referrals/parser.py +65 -0
  96. cli/commands/reports/__init__.py +6 -0
  97. cli/commands/reports/handlers.py +57 -0
  98. cli/commands/reports/parser.py +52 -0
  99. cli/commands/skills/__init__.py +7 -0
  100. cli/commands/skills/_agent_symlink.py +161 -0
  101. cli/commands/skills/_finalize.py +112 -0
  102. cli/commands/skills/_inspect_handler.py +218 -0
  103. cli/commands/skills/_install_helpers.py +186 -0
  104. cli/commands/skills/_query_handlers.py +83 -0
  105. cli/commands/skills/_search_handler.py +136 -0
  106. cli/commands/skills/_update_handler.py +110 -0
  107. cli/commands/skills/_verify_handler.py +109 -0
  108. cli/commands/skills/handlers.py +202 -0
  109. cli/commands/skills/parser.py +154 -0
  110. cli/commands/workspace.py +406 -0
  111. cli/docs/README.md +5 -0
  112. cli/docs/__init__.py +1 -0
  113. cli/docs/bounties-and-referrals.md +18 -0
  114. cli/docs/concepts.md +47 -0
  115. cli/docs/creating-courses.md +25 -0
  116. cli/docs/credits-and-purchases.md +30 -0
  117. cli/docs/credits-terms.md +23 -0
  118. cli/docs/getting-started.md +95 -0
  119. cli/docs/marketplace-loop.md +108 -0
  120. cli/docs/privacy.md +30 -0
  121. cli/docs/referral-terms.md +24 -0
  122. cli/docs/reviews.md +47 -0
  123. cli/docs/safety.md +28 -0
  124. cli/docs/terms.md +54 -0
  125. cli/main.py +84 -0
  126. cli/templates/__init__.py +2 -0
  127. cli/templates/course_capabilities.template.yaml +189 -0
  128. cli/templates/course_license_apache-2.0.template.txt +30 -0
  129. cli/templates/course_license_logion-standard-course-v1.template.txt +49 -0
  130. cli/templates/course_license_mit.template.txt +21 -0
  131. logion_cli-0.1.0.dist-info/METADATA +49 -0
  132. logion_cli-0.1.0.dist-info/RECORD +135 -0
  133. logion_cli-0.1.0.dist-info/WHEEL +4 -0
  134. logion_cli-0.1.0.dist-info/entry_points.txt +4 -0
  135. logion_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,186 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Internal helpers shared by the ``skills install`` and ``update``
3
+ handlers. Kept separate so handlers.py stays under the CLI's per-file
4
+ source-size budget."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import hashlib
10
+ import shutil
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from cli._local_state import ensure_layout, read_manifest
18
+
19
+ # Subset of ``source_dir`` that ``copy_skill_files`` actually installs.
20
+ # Hashing must scan the same subset, otherwise extra files in the
21
+ # source bundle (scripts/, tests/, etc.) produce false "different
22
+ # content" errors against a manifest hash computed over only the
23
+ # installed files.
24
+ INSTALLED_SUBDIRS: tuple[str, ...] = ("course", "references", "templates")
25
+
26
+
27
+ def resolve_target(args: argparse.Namespace) -> Path:
28
+ """Resolve LOGION_HOME from ``--target`` or the environment."""
29
+ target: Path | None = getattr(args, "target", None)
30
+ return ensure_layout(target)
31
+
32
+
33
+ def collect_installable_files(source_dir: Path) -> list[Path]:
34
+ """Return the files that ``copy_skill_files`` would install.
35
+
36
+ Same algorithm as :func:`copy_skill_files` so a pre-copy hash can
37
+ be compared apples-to-apples against the post-install manifest
38
+ hash. Excludes anything outside ``SKILL.md`` and
39
+ :data:`INSTALLED_SUBDIRS`.
40
+ """
41
+ out: list[Path] = []
42
+ skill_md = source_dir / "SKILL.md"
43
+ if skill_md.is_file():
44
+ out.append(skill_md)
45
+ for subdir in INSTALLED_SUBDIRS:
46
+ sdir = source_dir / subdir
47
+ if not sdir.is_dir():
48
+ continue
49
+ for child in sdir.rglob("*"):
50
+ if child.is_file() and child.name != "manifest.json":
51
+ out.append(child)
52
+ return sorted(out)
53
+
54
+
55
+ _HASH_CHUNK = 64 * 1024 # 64 KiB — keeps peak memory bounded for big files.
56
+
57
+
58
+ def compute_content_hash(files: list[Path], root: Path | None = None) -> str:
59
+ """Return SHA-256 over *files*, prefixing each with its rel path + length.
60
+
61
+ Reads each file in :data:`_HASH_CHUNK`-sized chunks so the peak
62
+ memory cost is bounded regardless of file size. Each file
63
+ contributes ``<rel_path>\\0<length>\\0<bytes>\\0`` so renames or
64
+ repartitioning change the digest. When *root* is provided, paths
65
+ are taken relative to it; otherwise the file name is used.
66
+ """
67
+ if not files:
68
+ return ""
69
+ h = hashlib.sha256()
70
+ for p in sorted(files):
71
+ if root is not None:
72
+ try:
73
+ rel = p.relative_to(root).as_posix()
74
+ except ValueError:
75
+ rel = p.name
76
+ else:
77
+ rel = p.name
78
+ size = p.stat().st_size
79
+ h.update(rel.encode("utf-8"))
80
+ h.update(b"\0")
81
+ h.update(str(size).encode("ascii"))
82
+ h.update(b"\0")
83
+ with p.open("rb") as fh:
84
+ while True:
85
+ chunk = fh.read(_HASH_CHUNK)
86
+ if not chunk:
87
+ break
88
+ h.update(chunk)
89
+ h.update(b"\0")
90
+ return h.hexdigest()
91
+
92
+
93
+ def read_capabilities(
94
+ cap_yaml: Path,
95
+ manifest_data: dict[str, Any],
96
+ ) -> dict[str, Any]:
97
+ """Update *manifest_data* with capabilities.yaml data if present.
98
+
99
+ Treats unreadable / malformed manifests the same way as a missing
100
+ file: leaves *manifest_data* untouched. Catches ``OSError`` and
101
+ ``UnicodeDecodeError`` from ``read_text`` and ``yaml.YAMLError``
102
+ from parsing so a permissions glitch or non-UTF-8 file does not
103
+ crash ``logion skills install/update``.
104
+ """
105
+ if not cap_yaml.is_file():
106
+ return manifest_data
107
+ try:
108
+ raw = cap_yaml.read_text(encoding="utf-8")
109
+ except (OSError, UnicodeDecodeError):
110
+ return manifest_data
111
+ try:
112
+ cap_data = yaml.safe_load(raw)
113
+ except yaml.YAMLError:
114
+ return manifest_data
115
+ if not isinstance(cap_data, dict):
116
+ return manifest_data
117
+ manifest_data["capabilities"] = [
118
+ c.get("id", "")
119
+ for c in cap_data.get("capabilities", [])
120
+ if isinstance(c, dict) and "id" in c
121
+ ]
122
+ manifest_data["required_tools"] = cap_data.get(
123
+ "tools", manifest_data.get("required_tools", ["terminal", "file"])
124
+ )
125
+ return manifest_data
126
+
127
+
128
+ def check_existing_install(
129
+ course_id: str,
130
+ version_id: str,
131
+ source_dir: Path,
132
+ home: Path,
133
+ ) -> int:
134
+ """Return 0 if install can proceed, 2 if conflicting install exists."""
135
+ existing = read_manifest(course_id, version_id, home)
136
+ if existing is None:
137
+ return 0
138
+ existing_hash = existing.get("content_sha256", "")
139
+ new_hash = compute_content_hash(
140
+ collect_installable_files(source_dir), root=source_dir
141
+ )
142
+ if existing_hash and new_hash != existing_hash:
143
+ print(
144
+ f"ERROR: {course_id}/{version_id} already installed with "
145
+ "different content. Re-run `logion skills install ... --force` "
146
+ "to overwrite, or use `logion skills update` for the safety "
147
+ "checks.",
148
+ file=sys.stderr,
149
+ )
150
+ return 2
151
+ return 0
152
+
153
+
154
+ def copy_skill_files(
155
+ src: Path,
156
+ dest: Path,
157
+ dry_run: bool,
158
+ ) -> list[Path]:
159
+ """Copy SKILL.md and supporting dirs from *src* to *dest*.
160
+
161
+ Excludes any ``manifest.json`` under the source tree — the manifest
162
+ is the install's own metadata, not part of the skill content, and
163
+ excluding it here keeps ``copy_skill_files`` byte-for-byte aligned
164
+ with the file set :func:`collect_installable_files` enumerates.
165
+ """
166
+ copied: list[Path] = []
167
+ skill_md = src / "SKILL.md"
168
+ if skill_md.is_file():
169
+ target = dest / "SKILL.md"
170
+ if not dry_run:
171
+ target.parent.mkdir(parents=True, exist_ok=True)
172
+ shutil.copy2(skill_md, target)
173
+ copied.append(target)
174
+ for subdir in INSTALLED_SUBDIRS:
175
+ sdir = src / subdir
176
+ if not sdir.is_dir():
177
+ continue
178
+ for child in sorted(sdir.rglob("*")):
179
+ if not child.is_file() or child.name == "manifest.json":
180
+ continue
181
+ target = dest / child.relative_to(src)
182
+ if not dry_run:
183
+ target.parent.mkdir(parents=True, exist_ok=True)
184
+ shutil.copy2(child, target)
185
+ copied.append(target)
186
+ return copied
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Handlers for ``skills installed`` and ``skills updates`` commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from typing import Any
8
+
9
+ from cli._local_state import (
10
+ list_installed,
11
+ verify_installed_content,
12
+ )
13
+ from cli._output import emit_json
14
+
15
+ from .handlers import resolve_target
16
+
17
+
18
+ def handle_skills_installed(args: argparse.Namespace) -> int:
19
+ """List installed skills."""
20
+ home = resolve_target(args)
21
+ installed = list_installed(home)
22
+ if getattr(args, "json_output", False):
23
+ emit_json("logion.skills.installed", installed)
24
+ return 0
25
+ if not installed:
26
+ print(f"No installed capabilities under {home / 'installed'}.")
27
+ return 0
28
+ print(f"Installed capabilities ({len(installed)}):")
29
+ for m in installed:
30
+ course_id = m.get("course_id", "?")
31
+ version_id = m.get("version_id", "?")
32
+ title = m.get("title", "")
33
+ status = m.get("review_status", "unknown")
34
+ line = f" {course_id}/{version_id}"
35
+ if title:
36
+ line += f" — {title}"
37
+ line += f" [{status}]"
38
+ verification = verify_installed_content(course_id, version_id, home)
39
+ if verification["user_modified"]:
40
+ line += " [LOCALLY MODIFIED]"
41
+ print(line)
42
+ return 0
43
+
44
+
45
+ def handle_skills_updates(args: argparse.Namespace) -> int:
46
+ """Report integrity of installed skills."""
47
+ home = resolve_target(args)
48
+ installed = list_installed(home)
49
+ if not installed:
50
+ print(f"No installed capabilities under {home / 'installed'}.")
51
+ return 0
52
+ out: list[dict[str, Any]] = []
53
+ for m in installed:
54
+ course_id = m.get("course_id", "?")
55
+ version_id = m.get("version_id", "?")
56
+ verification = verify_installed_content(course_id, version_id, home)
57
+ out.append({
58
+ "course_id": course_id,
59
+ "version_id": version_id,
60
+ "title": m.get("title", ""),
61
+ "source": m.get("source", "manual"),
62
+ "entitlement_status": m.get("entitlement_status", "unknown"),
63
+ "license_scope": m.get("license_scope", "unknown"),
64
+ "official_update_channel": m.get("official_update_channel", False),
65
+ "last_verified_at": m.get("last_verified_at"),
66
+ "manifest_path": m.get("manifest_path"),
67
+ "entrypoint": m.get("entrypoint", "SKILL.md"),
68
+ "ok": verification["ok"],
69
+ "user_modified": verification["user_modified"],
70
+ })
71
+ if getattr(args, "json_output", False):
72
+ emit_json("logion.skills.updates", out)
73
+ return 0
74
+ print(f"Update status ({len(out)} installed):")
75
+ for entry in out:
76
+ flags: list[str] = []
77
+ if entry["user_modified"]:
78
+ flags.append("locally-modified")
79
+ if not entry["ok"] and not entry["user_modified"]:
80
+ flags.append("integrity-unknown")
81
+ suffix = f" [{', '.join(flags)}]" if flags else ""
82
+ print(f" {entry['course_id']}/{entry['version_id']}{suffix}")
83
+ return 0
@@ -0,0 +1,136 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Handler for ``logion skills search``.
3
+
4
+ Searches marketplace listings via the API and annotates each result
5
+ with entitlement status derived from locally installed manifests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from typing import Any
13
+
14
+ from cli._config import resolve_config_from_args
15
+ from cli._context import make_client
16
+ from cli._errors import handle_error
17
+ from cli._local_state import VALID_ENTITLEMENT_STATUSES, list_installed
18
+ from cli._output import emit_json, truncate_summary
19
+
20
+ from ._install_helpers import resolve_target
21
+
22
+
23
+ def _normalize_items(result: Any) -> list[dict[str, Any]]:
24
+ """Convert SDK response items to plain dicts."""
25
+ # Prefer dict key 'items' over the dict method
26
+ if isinstance(result, dict):
27
+ raw = result.get("items", [])
28
+ elif hasattr(result, "items") and not isinstance(result, dict):
29
+ raw = getattr(result, "items", result)
30
+ if callable(raw):
31
+ raw = result
32
+ else:
33
+ raw = result
34
+
35
+ items: list[dict[str, Any]] = []
36
+ if isinstance(raw, list):
37
+ inner = raw
38
+ elif isinstance(raw, dict):
39
+ inner = raw.get("items", [raw])
40
+ else:
41
+ return items
42
+
43
+ for item in inner:
44
+ if hasattr(item, "model_dump"):
45
+ items.append(item.model_dump(mode="json"))
46
+ elif isinstance(item, dict):
47
+ items.append(dict(item))
48
+ else:
49
+ items.append({"value": item})
50
+ return items
51
+
52
+
53
+ def _annotate_entitlement(
54
+ items: list[dict[str, Any]],
55
+ installed_manifests: list[dict[str, Any]],
56
+ ) -> list[dict[str, Any]]:
57
+ """Add ``entitlement_status`` to each item based on installed data."""
58
+ entitlement_map: dict[str, str] = {}
59
+ for m in installed_manifests:
60
+ cid = m.get("course_id", "")
61
+ entitlement_map[cid] = m.get("entitlement_status", "unknown")
62
+
63
+ for item in items:
64
+ course_id = item.get("course_id")
65
+ if not isinstance(course_id, str) or not course_id:
66
+ item["entitlement_status"] = "unknown"
67
+ continue
68
+ if course_id in entitlement_map:
69
+ status = entitlement_map[course_id]
70
+ if status in VALID_ENTITLEMENT_STATUSES:
71
+ item["entitlement_status"] = status
72
+ else:
73
+ item["entitlement_status"] = "unknown"
74
+ else:
75
+ item["entitlement_status"] = "missing"
76
+ return items
77
+
78
+
79
+ def _print_human(items: list[dict[str, Any]], verbose: bool) -> None:
80
+ """Print compact human-readable results."""
81
+ if not items:
82
+ print("No results found.")
83
+ return
84
+ print(f"Search results ({len(items)}):")
85
+ for item in items:
86
+ course_id = item.get("course_id", item.get("id", "?"))
87
+ title = item.get("title", "")
88
+ summary = truncate_summary(
89
+ item.get("short_summary") or item.get("summary") or ""
90
+ )
91
+ status = item.get("entitlement_status", "unknown")
92
+ line = f" {course_id}"
93
+ if title:
94
+ line += f" — {title}"
95
+ line += f" [{status}]"
96
+ if summary:
97
+ line += f"\n {summary}"
98
+ print(line, file=sys.stdout)
99
+ if verbose:
100
+ import json as _json
101
+
102
+ for item in items:
103
+ print(_json.dumps(item, indent=2, sort_keys=True))
104
+
105
+
106
+ def handle_skills_search(args: argparse.Namespace) -> int:
107
+ """Search marketplace listings with installed/entitlement annotations."""
108
+ config = resolve_config_from_args(args)
109
+ client = make_client(config)
110
+ home = resolve_target(args)
111
+
112
+ installed_manifests = list_installed(home)
113
+
114
+ try:
115
+ result = client.v1.listings.search(
116
+ query=args.query,
117
+ limit=args.limit,
118
+ )
119
+ except Exception as exc:
120
+ return handle_error(exc)
121
+ finally:
122
+ client.close()
123
+
124
+ items = _normalize_items(result)
125
+ items = _annotate_entitlement(items, installed_manifests)
126
+
127
+ if config.json_output:
128
+ emit_json(
129
+ "logion.skills.search",
130
+ {"items": items, "total": len(items)},
131
+ )
132
+ else:
133
+ verbose = getattr(args, "verbose", False)
134
+ _print_human(items, verbose)
135
+
136
+ return 0
@@ -0,0 +1,110 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Handler for ``logion skills update``.
3
+
4
+ Kept separate from :mod:`handlers` so each file stays under the CLI's
5
+ per-source-file line budget. See :func:`handle_skills_update` for the
6
+ control flow; the policy primitives live in :mod:`cli._update_policy`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from cli._local_state import read_manifest
17
+ from cli._update_policy import evaluate_update
18
+
19
+ from ._install_helpers import read_capabilities, resolve_target
20
+
21
+
22
+ def _build_remote_manifest(
23
+ source: Path,
24
+ course_id: str,
25
+ version_id: str,
26
+ title: str | None,
27
+ ) -> dict[str, Any]:
28
+ """Synthesize a candidate manifest from a local source bundle.
29
+
30
+ The marketplace API is not in the loop for this flow — the
31
+ "remote" manifest is the one we *would* write if this install
32
+ proceeded. ``evaluate_update`` diffs it against the installed
33
+ manifest to decide whether the change requires approval.
34
+ """
35
+ manifest: dict[str, Any] = {
36
+ "course_id": course_id,
37
+ "version_id": version_id,
38
+ "title": title or "",
39
+ "entrypoint": "SKILL.md",
40
+ "capabilities": [],
41
+ "required_tools": ["terminal", "file"],
42
+ "permissions": [],
43
+ "env_vars": [],
44
+ "execution_policy": "approval-required",
45
+ "review_status": "approved",
46
+ }
47
+ return read_capabilities(source / "course" / "capabilities.yaml", manifest)
48
+
49
+
50
+ def handle_skills_update(args: argparse.Namespace) -> int:
51
+ """Apply an update with safety policy.
52
+
53
+ Diffs the installed manifest against the candidate manifest from
54
+ *source* via :func:`evaluate_update`. If any gated field
55
+ (permissions, required tools, env vars, execution policy) changed
56
+ or the local content was modified, refuses unless ``--force`` is
57
+ passed. Otherwise delegates to the install handler.
58
+ """
59
+ # Import here to avoid a circular import; handlers imports from
60
+ # this module via the parser registration.
61
+ from .handlers import handle_skills_install
62
+
63
+ home = resolve_target(args)
64
+ course_id: str = args.course_id
65
+ version_id: str = args.version_id
66
+
67
+ local_manifest = read_manifest(course_id, version_id, home)
68
+ if local_manifest is None:
69
+ print(
70
+ f"ERROR: no installed manifest for {course_id}/{version_id}; "
71
+ "use `logion skills install` first.",
72
+ file=sys.stderr,
73
+ )
74
+ return 1
75
+
76
+ remote_manifest = _build_remote_manifest(
77
+ args.source.resolve(),
78
+ course_id,
79
+ version_id,
80
+ getattr(args, "title", None),
81
+ )
82
+ policy = evaluate_update(
83
+ course_id, version_id, remote_manifest, local_manifest, home
84
+ )
85
+
86
+ if policy.notices:
87
+ for note in policy.notices:
88
+ print(f"NOTICE: {note}", file=sys.stderr)
89
+
90
+ blocked = policy.requires_approval or policy.blocks_silent_overwrite
91
+ if blocked and not args.force:
92
+ print(
93
+ f"Refusing to update {course_id}/{version_id} without approval:",
94
+ file=sys.stderr,
95
+ )
96
+ for reason in policy.reasons:
97
+ print(f" - {reason}", file=sys.stderr)
98
+ print("Pass --force to apply.", file=sys.stderr)
99
+ return 2
100
+
101
+ install_args = argparse.Namespace(
102
+ source=args.source,
103
+ course_id=course_id,
104
+ version_id=version_id,
105
+ title=getattr(args, "title", None),
106
+ target=getattr(args, "target", None),
107
+ dry_run=False,
108
+ force=True,
109
+ )
110
+ return handle_skills_install(install_args)
@@ -0,0 +1,109 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Handler for ``logion skills verify``.
3
+
4
+ Kept separate from :mod:`handlers` so each file stays under the CLI's
5
+ per-source-file line budget.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from typing import Any
13
+
14
+ from cli._errors import emit_error_json
15
+ from cli._local_state import (
16
+ VALID_ENTITLEMENT_STATUSES,
17
+ UnsafeIdentifierError,
18
+ _safe_segment,
19
+ list_installed,
20
+ write_manifest,
21
+ )
22
+ from cli._output import emit_json
23
+
24
+ from ._install_helpers import resolve_target
25
+
26
+
27
+ def _error(
28
+ args: argparse.Namespace, code: str, message: str, exit_code: int
29
+ ) -> int:
30
+ """Emit a compliant error in JSON or human form."""
31
+ if getattr(args, "json_output", False):
32
+ emit_error_json(code, message, exit_code)
33
+ else:
34
+ print(f"ERROR: {message}", file=sys.stderr)
35
+ return exit_code
36
+
37
+
38
+ def _local_verify_status(manifest: dict[str, Any]) -> str:
39
+ """Preserve the locally recorded entitlement status.
40
+
41
+ The public SDK currently exposes no entitlements read endpoint, so this
42
+ command cannot prove fresh server-side ownership. Marketplace installs keep
43
+ their stored entitlement state; non-marketplace installs remain unknown.
44
+ """
45
+ source = manifest.get("source")
46
+ current = manifest.get("entitlement_status")
47
+ if source != "logion-marketplace":
48
+ return "unknown"
49
+ if current in VALID_ENTITLEMENT_STATUSES:
50
+ return str(current)
51
+ return "unknown"
52
+
53
+
54
+ def _verification_mode(_manifest: dict[str, Any]) -> str:
55
+ """Return the verification mode reported to callers."""
56
+ return "local-manifest-only"
57
+
58
+
59
+ def handle_skills_verify(args: argparse.Namespace) -> int:
60
+ """Refresh locally stored entitlement metadata for installed skills."""
61
+ home = resolve_target(args)
62
+ course_id: str | None = getattr(args, "course_id", None)
63
+
64
+ if course_id is not None:
65
+ try:
66
+ _safe_segment(course_id, "course_id")
67
+ except UnsafeIdentifierError as exc:
68
+ return _error(args, "unsafe_identifier", str(exc), 2)
69
+
70
+ installed = list_installed(home)
71
+ if course_id is not None:
72
+ installed = [m for m in installed if m.get("course_id") == course_id]
73
+
74
+ results: list[dict[str, Any]] = []
75
+
76
+ for manifest in installed:
77
+ cid = str(manifest.get("course_id", "?"))
78
+ vid = str(manifest.get("version_id", "?"))
79
+ new_status = _local_verify_status(manifest)
80
+ old_status = manifest.get("entitlement_status")
81
+ manifest["entitlement_status"] = new_status
82
+ if new_status != old_status:
83
+ write_manifest(manifest, cid, vid, home)
84
+ results.append({
85
+ "course_id": cid,
86
+ "entitlement_status": new_status,
87
+ "last_verified_at": manifest.get("last_verified_at"),
88
+ "source": manifest.get("source", "unknown"),
89
+ "verification_mode": _verification_mode(manifest),
90
+ })
91
+
92
+ if getattr(args, "json_output", False):
93
+ emit_json("logion.skills.verify", results)
94
+ return 0
95
+
96
+ if not results:
97
+ print("No installed skills to verify.")
98
+ return 0
99
+
100
+ print(f"Verification results ({len(results)} skill(s)):")
101
+ for entry in results:
102
+ print(
103
+ f" {entry['course_id']}: "
104
+ f"entitlement={entry['entitlement_status']}, "
105
+ f"mode={entry['verification_mode']}, "
106
+ f"source={entry['source']}, "
107
+ f"last_verified_at={entry['last_verified_at']}"
108
+ )
109
+ return 0