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.
- cli/__init__.py +2 -0
- cli/_config.py +51 -0
- cli/_confirm.py +16 -0
- cli/_context.py +17 -0
- cli/_course_bundle.py +46 -0
- cli/_course_capabilities.py +580 -0
- cli/_credentials.py +104 -0
- cli/_errors.py +82 -0
- cli/_first_run.py +90 -0
- cli/_harness/__init__.py +68 -0
- cli/_harness/base.py +106 -0
- cli/_harness/claude_code.py +168 -0
- cli/_harness/codex.py +79 -0
- cli/_harness/custom.py +55 -0
- cli/_harness/hermes.py +93 -0
- cli/_harness/opencode.py +255 -0
- cli/_local_state.py +1053 -0
- cli/_options.py +36 -0
- cli/_output.py +47 -0
- cli/_parser.py +73 -0
- cli/_recall_calibration.py +90 -0
- cli/_recall_ranker.py +74 -0
- cli/_taxonomy.py +120 -0
- cli/_update_policy.py +152 -0
- cli/_utils.py +16 -0
- cli/_version.py +26 -0
- cli/commands/__init__.py +2 -0
- cli/commands/admin.py +535 -0
- cli/commands/bounties.py +490 -0
- cli/commands/course_reviews/__init__.py +6 -0
- cli/commands/course_reviews/_download_handler.py +104 -0
- cli/commands/course_reviews/_render.py +129 -0
- cli/commands/course_reviews/handlers.py +197 -0
- cli/commands/course_reviews/parser.py +93 -0
- cli/commands/courses/__init__.py +6 -0
- cli/commands/courses/_capability_render.py +183 -0
- cli/commands/courses/_cmd_help.py +18 -0
- cli/commands/courses/_purchase.py +76 -0
- cli/commands/courses/_review_helpers.py +93 -0
- cli/commands/courses/_taxonomy_data.py +173 -0
- cli/commands/courses/_upload_bundle_validation.py +28 -0
- cli/commands/courses/_uploads_push.py +243 -0
- cli/commands/courses/capabilities.py +250 -0
- cli/commands/courses/capability_frontmatter.py +150 -0
- cli/commands/courses/handlers.py +50 -0
- cli/commands/courses/mutations.py +217 -0
- cli/commands/courses/parser.py +66 -0
- cli/commands/courses/parser_capabilities.py +95 -0
- cli/commands/courses/parser_sections.py +239 -0
- cli/commands/courses/parser_uploads.py +84 -0
- cli/commands/courses/parser_utils.py +65 -0
- cli/commands/courses/publication.py +60 -0
- cli/commands/courses/report_usage.py +131 -0
- cli/commands/courses/reviews.py +237 -0
- cli/commands/courses/taxonomy_handler.py +61 -0
- cli/commands/courses/taxonomy_suggest.py +197 -0
- cli/commands/courses/uploads.py +142 -0
- cli/commands/courses/versions.py +65 -0
- cli/commands/credits/__init__.py +6 -0
- cli/commands/credits/_helpers.py +153 -0
- cli/commands/credits/handlers.py +218 -0
- cli/commands/credits/parser.py +115 -0
- cli/commands/docs/__init__.py +6 -0
- cli/commands/docs/handlers.py +137 -0
- cli/commands/docs/parser.py +27 -0
- cli/commands/health/__init__.py +6 -0
- cli/commands/health/handlers.py +26 -0
- cli/commands/health/parser.py +20 -0
- cli/commands/identity/__init__.py +6 -0
- cli/commands/identity/_autopost.py +97 -0
- cli/commands/identity/_closing_copy.py +89 -0
- cli/commands/identity/_companion.py +232 -0
- cli/commands/identity/_companion_source.py +135 -0
- cli/commands/identity/_harness_select.py +85 -0
- cli/commands/identity/_onboarding_helpers.py +168 -0
- cli/commands/identity/handlers.py +173 -0
- cli/commands/identity/onboarding.py +246 -0
- cli/commands/identity/parser.py +72 -0
- cli/commands/listings/__init__.py +6 -0
- cli/commands/listings/handlers.py +135 -0
- cli/commands/listings/parser.py +57 -0
- cli/commands/notifications/__init__.py +6 -0
- cli/commands/notifications/handlers.py +120 -0
- cli/commands/notifications/parser.py +49 -0
- cli/commands/payments/__init__.py +6 -0
- cli/commands/payments/_orders_helpers.py +114 -0
- cli/commands/payments/handlers.py +138 -0
- cli/commands/payments/parser.py +97 -0
- cli/commands/recall/__init__.py +7 -0
- cli/commands/recall/handlers.py +87 -0
- cli/commands/recall/parser.py +70 -0
- cli/commands/referrals/__init__.py +6 -0
- cli/commands/referrals/_helpers.py +63 -0
- cli/commands/referrals/handlers.py +100 -0
- cli/commands/referrals/parser.py +65 -0
- cli/commands/reports/__init__.py +6 -0
- cli/commands/reports/handlers.py +57 -0
- cli/commands/reports/parser.py +52 -0
- cli/commands/skills/__init__.py +7 -0
- cli/commands/skills/_agent_symlink.py +161 -0
- cli/commands/skills/_finalize.py +112 -0
- cli/commands/skills/_inspect_handler.py +218 -0
- cli/commands/skills/_install_helpers.py +186 -0
- cli/commands/skills/_query_handlers.py +83 -0
- cli/commands/skills/_search_handler.py +136 -0
- cli/commands/skills/_update_handler.py +110 -0
- cli/commands/skills/_verify_handler.py +109 -0
- cli/commands/skills/handlers.py +202 -0
- cli/commands/skills/parser.py +154 -0
- cli/commands/workspace.py +406 -0
- cli/docs/README.md +5 -0
- cli/docs/__init__.py +1 -0
- cli/docs/bounties-and-referrals.md +18 -0
- cli/docs/concepts.md +47 -0
- cli/docs/creating-courses.md +25 -0
- cli/docs/credits-and-purchases.md +30 -0
- cli/docs/credits-terms.md +23 -0
- cli/docs/getting-started.md +95 -0
- cli/docs/marketplace-loop.md +108 -0
- cli/docs/privacy.md +30 -0
- cli/docs/referral-terms.md +24 -0
- cli/docs/reviews.md +47 -0
- cli/docs/safety.md +28 -0
- cli/docs/terms.md +54 -0
- cli/main.py +84 -0
- cli/templates/__init__.py +2 -0
- cli/templates/course_capabilities.template.yaml +189 -0
- cli/templates/course_license_apache-2.0.template.txt +30 -0
- cli/templates/course_license_logion-standard-course-v1.template.txt +49 -0
- cli/templates/course_license_mit.template.txt +21 -0
- logion_cli-0.1.0.dist-info/METADATA +49 -0
- logion_cli-0.1.0.dist-info/RECORD +135 -0
- logion_cli-0.1.0.dist-info/WHEEL +4 -0
- logion_cli-0.1.0.dist-info/entry_points.txt +4 -0
- 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
|