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,250 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Handlers for ``courses capabilities`` sub-commands."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from cli._config import resolve_config_from_args
|
|
16
|
+
from cli._course_bundle import CourseBundleError, validate_course_bundle
|
|
17
|
+
from cli._course_capabilities import (
|
|
18
|
+
CAPABILITY_MANIFEST_PATH,
|
|
19
|
+
CapabilityManifestError,
|
|
20
|
+
load_and_validate_capability_manifest,
|
|
21
|
+
summarize_capability_manifest,
|
|
22
|
+
)
|
|
23
|
+
from cli._output import emit_json
|
|
24
|
+
from cli.commands.courses._capability_render import (
|
|
25
|
+
_append_summary_fields,
|
|
26
|
+
)
|
|
27
|
+
from cli.commands.courses.capability_frontmatter import (
|
|
28
|
+
extract_logion_capabilities_from_skill,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
CAPABILITIES_TEMPLATE_FILENAME = "course_capabilities.template.yaml"
|
|
32
|
+
LICENSE_TEMPLATE_FILES = {
|
|
33
|
+
"mit": "course_license_mit.template.txt",
|
|
34
|
+
"apache-2.0": "course_license_apache-2.0.template.txt",
|
|
35
|
+
"logion-standard-course-v1": (
|
|
36
|
+
"course_license_logion-standard-course-v1.template.txt"
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def handle_courses_capabilities_validate(
|
|
42
|
+
args: argparse.Namespace,
|
|
43
|
+
) -> int:
|
|
44
|
+
"""Validate a local capability manifest and print a summary."""
|
|
45
|
+
try:
|
|
46
|
+
validate_course_bundle(args.bundle_dir)
|
|
47
|
+
manifest = load_and_validate_capability_manifest(args.bundle_dir)
|
|
48
|
+
except CourseBundleError as exc:
|
|
49
|
+
print(f"Invalid course bundle: {exc}", file=sys.stderr)
|
|
50
|
+
return 2
|
|
51
|
+
except CapabilityManifestError as exc:
|
|
52
|
+
print(f"Invalid capability manifest: {exc}", file=sys.stderr)
|
|
53
|
+
return 2
|
|
54
|
+
config = resolve_config_from_args(args)
|
|
55
|
+
summary = summarize_capability_manifest(manifest)
|
|
56
|
+
if config.json_output:
|
|
57
|
+
payload = {"capabilities_status": "declared", **summary}
|
|
58
|
+
emit_json("logion.courses.capabilities.validate", payload)
|
|
59
|
+
else:
|
|
60
|
+
lines = ["capabilities_status: declared"]
|
|
61
|
+
_append_summary_fields(lines, summary)
|
|
62
|
+
print("\n".join(lines))
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def handle_courses_capabilities_print(
|
|
67
|
+
args: argparse.Namespace,
|
|
68
|
+
) -> int:
|
|
69
|
+
"""Validate a local capability manifest and print normalised JSON."""
|
|
70
|
+
try:
|
|
71
|
+
validate_course_bundle(args.bundle_dir)
|
|
72
|
+
manifest = load_and_validate_capability_manifest(args.bundle_dir)
|
|
73
|
+
except CourseBundleError as exc:
|
|
74
|
+
print(f"Invalid course bundle: {exc}", file=sys.stderr)
|
|
75
|
+
return 2
|
|
76
|
+
except CapabilityManifestError as exc:
|
|
77
|
+
print(f"Invalid capability manifest: {exc}", file=sys.stderr)
|
|
78
|
+
return 2
|
|
79
|
+
config = resolve_config_from_args(args)
|
|
80
|
+
if config.json_output:
|
|
81
|
+
emit_json("logion.courses.capabilities.print", manifest)
|
|
82
|
+
else:
|
|
83
|
+
print(json.dumps(manifest, indent=2, sort_keys=True))
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _template_text() -> str:
|
|
88
|
+
"""Return the bundled capability manifest scaffold contents."""
|
|
89
|
+
return (
|
|
90
|
+
resources
|
|
91
|
+
.files("cli.templates")
|
|
92
|
+
.joinpath(CAPABILITIES_TEMPLATE_FILENAME)
|
|
93
|
+
.read_text(encoding="utf-8")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _license_template_text(template_name: str) -> str:
|
|
98
|
+
"""Return the bundled LICENSE scaffold contents."""
|
|
99
|
+
return (
|
|
100
|
+
resources
|
|
101
|
+
.files("cli.templates")
|
|
102
|
+
.joinpath(LICENSE_TEMPLATE_FILES[template_name])
|
|
103
|
+
.read_text(encoding="utf-8")
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _default_license_template(license_template: str | None) -> str:
|
|
108
|
+
"""Choose a license template when scaffolding a local bundle."""
|
|
109
|
+
return license_template or "mit"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _serialize_manifest_yaml(manifest: dict[str, Any]) -> str:
|
|
113
|
+
"""Serialise a normalised manifest dict to YAML with a header."""
|
|
114
|
+
body = yaml.safe_dump(
|
|
115
|
+
manifest,
|
|
116
|
+
default_flow_style=False,
|
|
117
|
+
sort_keys=True,
|
|
118
|
+
allow_unicode=True,
|
|
119
|
+
width=88,
|
|
120
|
+
)
|
|
121
|
+
header = (
|
|
122
|
+
"# course/capabilities.yaml — generated from SKILL.md\n"
|
|
123
|
+
"#\n"
|
|
124
|
+
"# Generated by `logion courses capabilities scaffold --from-skill`.\n"
|
|
125
|
+
"# Review every field before publishing. This file is the canonical\n"
|
|
126
|
+
"# publication contract — SKILL.md frontmatter is only a seed.\n"
|
|
127
|
+
"\n"
|
|
128
|
+
)
|
|
129
|
+
return header + body
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _scaffold_from_skill(
|
|
133
|
+
skill_path: Path,
|
|
134
|
+
bundle_dir: Path | None,
|
|
135
|
+
force: bool,
|
|
136
|
+
license_template: str | None,
|
|
137
|
+
) -> int:
|
|
138
|
+
"""Seed a capability manifest from SKILL.md frontmatter."""
|
|
139
|
+
try:
|
|
140
|
+
manifest = extract_logion_capabilities_from_skill(skill_path)
|
|
141
|
+
except CapabilityManifestError as exc:
|
|
142
|
+
print(f"Error reading SKILL.md: {exc}", file=sys.stderr)
|
|
143
|
+
return 2
|
|
144
|
+
if manifest is None:
|
|
145
|
+
print(
|
|
146
|
+
"SKILL.md has no metadata.logion capability manifest",
|
|
147
|
+
file=sys.stderr,
|
|
148
|
+
)
|
|
149
|
+
return 2
|
|
150
|
+
text = _serialize_manifest_yaml(manifest)
|
|
151
|
+
if bundle_dir is None:
|
|
152
|
+
print(text, end="")
|
|
153
|
+
return 0
|
|
154
|
+
dest = bundle_dir / CAPABILITY_MANIFEST_PATH
|
|
155
|
+
if dest.exists() and not force:
|
|
156
|
+
print(
|
|
157
|
+
f"Refusing to overwrite existing manifest: {dest} "
|
|
158
|
+
"(pass --force to replace)",
|
|
159
|
+
file=sys.stderr,
|
|
160
|
+
)
|
|
161
|
+
return 2
|
|
162
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
dest.write_text(text, encoding="utf-8")
|
|
164
|
+
print(f"Wrote scaffold to {dest}")
|
|
165
|
+
return _write_scaffold_license(bundle_dir, license_template, force)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _write_license_template(
|
|
169
|
+
bundle_dir: Path,
|
|
170
|
+
template_name: str,
|
|
171
|
+
force: bool,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Materialise a LICENSE file from the selected template."""
|
|
174
|
+
dest = bundle_dir / "LICENSE"
|
|
175
|
+
if dest.exists() and not force:
|
|
176
|
+
raise FileExistsError(dest)
|
|
177
|
+
dest.write_text(_license_template_text(template_name), encoding="utf-8")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _write_scaffold_license(
|
|
181
|
+
bundle_dir: Path,
|
|
182
|
+
license_template: str | None,
|
|
183
|
+
force: bool,
|
|
184
|
+
) -> int:
|
|
185
|
+
"""Write the bundle LICENSE file for scaffold flows."""
|
|
186
|
+
try:
|
|
187
|
+
_write_license_template(
|
|
188
|
+
bundle_dir,
|
|
189
|
+
_default_license_template(license_template),
|
|
190
|
+
force,
|
|
191
|
+
)
|
|
192
|
+
except FileExistsError:
|
|
193
|
+
license_path = bundle_dir / "LICENSE"
|
|
194
|
+
print(
|
|
195
|
+
f"Refusing to overwrite existing license: {license_path} "
|
|
196
|
+
"(pass --force to replace)",
|
|
197
|
+
file=sys.stderr,
|
|
198
|
+
)
|
|
199
|
+
return 2
|
|
200
|
+
print(f"Wrote scaffold to {bundle_dir / 'LICENSE'}")
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _scaffold_template(
|
|
205
|
+
bundle_dir: Path | None,
|
|
206
|
+
force: bool,
|
|
207
|
+
license_template: str | None,
|
|
208
|
+
) -> int:
|
|
209
|
+
"""Emit the generic commented template scaffold."""
|
|
210
|
+
text = _template_text()
|
|
211
|
+
if bundle_dir is None:
|
|
212
|
+
if license_template is not None:
|
|
213
|
+
print("--license-template requires --bundle-dir", file=sys.stderr)
|
|
214
|
+
return 2
|
|
215
|
+
print(text, end="")
|
|
216
|
+
return 0
|
|
217
|
+
dest = bundle_dir / CAPABILITY_MANIFEST_PATH
|
|
218
|
+
if dest.exists() and not force:
|
|
219
|
+
print(
|
|
220
|
+
f"Refusing to overwrite existing manifest: {dest} "
|
|
221
|
+
"(pass --force to replace)",
|
|
222
|
+
file=sys.stderr,
|
|
223
|
+
)
|
|
224
|
+
return 2
|
|
225
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
# Write the already-loaded text directly. ``resources.files()``
|
|
227
|
+
# returns a Traversable that may not have a stable on-disk path
|
|
228
|
+
# (e.g. zip-installed wheels), so do not round-trip through
|
|
229
|
+
# ``Path(str(...))`` just to call shutil.copyfile.
|
|
230
|
+
dest.write_text(text, encoding="utf-8")
|
|
231
|
+
print(f"Wrote scaffold to {dest}")
|
|
232
|
+
return _write_scaffold_license(bundle_dir, license_template, force)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def handle_courses_capabilities_scaffold(
|
|
236
|
+
args: argparse.Namespace,
|
|
237
|
+
) -> int:
|
|
238
|
+
"""Emit the capability manifest scaffold."""
|
|
239
|
+
bundle_dir: Path | None = getattr(args, "bundle_dir", None)
|
|
240
|
+
from_skill: Path | None = getattr(args, "from_skill", None)
|
|
241
|
+
force: bool = getattr(args, "force", False)
|
|
242
|
+
license_template: str | None = getattr(args, "license_template", None)
|
|
243
|
+
if from_skill is not None:
|
|
244
|
+
return _scaffold_from_skill(
|
|
245
|
+
from_skill,
|
|
246
|
+
bundle_dir,
|
|
247
|
+
force,
|
|
248
|
+
license_template,
|
|
249
|
+
)
|
|
250
|
+
return _scaffold_template(bundle_dir, force, license_template)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Import Logion capability manifests from SKILL.md frontmatter.
|
|
3
|
+
|
|
4
|
+
``course/capabilities.yaml`` is the single canonical publication contract.
|
|
5
|
+
SKILL.md frontmatter may seed or scaffold that file, but it is not a second
|
|
6
|
+
publication source of truth — the backend never reads SKILL.md as a fallback.
|
|
7
|
+
|
|
8
|
+
Accepted frontmatter shapes (under ``metadata``):
|
|
9
|
+
|
|
10
|
+
metadata:
|
|
11
|
+
logion:
|
|
12
|
+
version: 1
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
metadata:
|
|
16
|
+
logion:
|
|
17
|
+
capabilities:
|
|
18
|
+
version: 1
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
Both ``metadata.logion`` and ``metadata.logion.capabilities`` may carry the
|
|
22
|
+
exact manifest shape, but only one may be present at a time. The extracted
|
|
23
|
+
mapping is passed through ``normalize_capability_manifest()`` so the result
|
|
24
|
+
is identical to loading ``course/capabilities.yaml`` directly.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
from cli._course_capabilities import (
|
|
35
|
+
CapabilityManifestError,
|
|
36
|
+
normalize_capability_manifest,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract_logion_capabilities_from_skill(
|
|
41
|
+
skill_path: Path,
|
|
42
|
+
) -> dict[str, Any] | None:
|
|
43
|
+
"""Return a normalised capability manifest from SKILL.md frontmatter.
|
|
44
|
+
|
|
45
|
+
Returns ``None`` when the file has no ``metadata.logion`` section.
|
|
46
|
+
|
|
47
|
+
Raises :class:`CapabilityManifestError` when:
|
|
48
|
+
- both ``metadata.logion`` and ``metadata.logion.capabilities`` are
|
|
49
|
+
present (ambiguous — refuse rather than guess);
|
|
50
|
+
- the extracted value is present but not a YAML mapping;
|
|
51
|
+
- the mapping fails ``normalize_capability_manifest()``.
|
|
52
|
+
|
|
53
|
+
Only the YAML frontmatter block delimited by the first two ``---`` lines
|
|
54
|
+
at the top of the file is parsed. The Markdown body is ignored.
|
|
55
|
+
"""
|
|
56
|
+
raw = _read_frontmatter(skill_path)
|
|
57
|
+
if raw is None:
|
|
58
|
+
return None
|
|
59
|
+
manifest = _extract_logion_mapping(raw)
|
|
60
|
+
if manifest is None:
|
|
61
|
+
return None
|
|
62
|
+
if not isinstance(manifest, dict):
|
|
63
|
+
raise CapabilityManifestError(
|
|
64
|
+
"metadata.logion capability manifest must be a mapping"
|
|
65
|
+
)
|
|
66
|
+
return normalize_capability_manifest(manifest)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_frontmatter(skill_path: Path) -> dict[str, Any] | None:
|
|
70
|
+
"""Parse the YAML frontmatter block from *skill_path*.
|
|
71
|
+
|
|
72
|
+
Returns ``None`` if the file is missing or has no frontmatter block.
|
|
73
|
+
"""
|
|
74
|
+
if not skill_path.is_file():
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
text = skill_path.read_text(encoding="utf-8")
|
|
78
|
+
except OSError:
|
|
79
|
+
return None
|
|
80
|
+
if not text.startswith("---"):
|
|
81
|
+
return None
|
|
82
|
+
# Anchor closing delimiter to end-of-line so "\n---xyz" is not
|
|
83
|
+
# mistaken for a frontmatter terminator.
|
|
84
|
+
end = text.find("\n---\n", 3)
|
|
85
|
+
if end < 0:
|
|
86
|
+
# Handle frontmatter that is the entire file (no trailing newline).
|
|
87
|
+
stripped = text.rstrip()
|
|
88
|
+
if stripped.endswith("---") and text.count("---") >= 2:
|
|
89
|
+
end = stripped.rfind("---")
|
|
90
|
+
block = text[3:end].rstrip()
|
|
91
|
+
else:
|
|
92
|
+
return None
|
|
93
|
+
else:
|
|
94
|
+
block = text[3:end]
|
|
95
|
+
try:
|
|
96
|
+
parsed = yaml.safe_load(block)
|
|
97
|
+
except yaml.YAMLError as exc:
|
|
98
|
+
raise CapabilityManifestError(
|
|
99
|
+
"Invalid YAML in SKILL.md frontmatter"
|
|
100
|
+
) from exc
|
|
101
|
+
if not isinstance(parsed, dict):
|
|
102
|
+
return None
|
|
103
|
+
return parsed
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_logion_mapping(
|
|
107
|
+
frontmatter: dict[str, Any],
|
|
108
|
+
) -> Any | None:
|
|
109
|
+
"""Pull the Logion capability manifest from parsed frontmatter.
|
|
110
|
+
|
|
111
|
+
Accepts ``metadata.logion`` (direct manifest) or
|
|
112
|
+
``metadata.logion.capabilities`` (nested). Rejects having both.
|
|
113
|
+
Returns ``None`` when neither is present.
|
|
114
|
+
"""
|
|
115
|
+
metadata = frontmatter.get("metadata")
|
|
116
|
+
if not isinstance(metadata, dict):
|
|
117
|
+
return None
|
|
118
|
+
logion = metadata.get("logion")
|
|
119
|
+
if logion is None:
|
|
120
|
+
return None
|
|
121
|
+
if not isinstance(logion, dict):
|
|
122
|
+
raise CapabilityManifestError(
|
|
123
|
+
"metadata.logion capability manifest must be a mapping"
|
|
124
|
+
)
|
|
125
|
+
direct = logion
|
|
126
|
+
nested = logion.get("capabilities")
|
|
127
|
+
has_direct = any(
|
|
128
|
+
k in logion
|
|
129
|
+
for k in (
|
|
130
|
+
"version",
|
|
131
|
+
"summary",
|
|
132
|
+
"tools",
|
|
133
|
+
"network",
|
|
134
|
+
"filesystem",
|
|
135
|
+
"secrets",
|
|
136
|
+
"human_approval",
|
|
137
|
+
"runtime",
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
has_nested = isinstance(nested, dict)
|
|
141
|
+
if has_direct and has_nested:
|
|
142
|
+
raise CapabilityManifestError(
|
|
143
|
+
"SKILL.md has both metadata.logion and "
|
|
144
|
+
"metadata.logion.capabilities; provide only one."
|
|
145
|
+
)
|
|
146
|
+
if has_nested:
|
|
147
|
+
return nested
|
|
148
|
+
if has_direct:
|
|
149
|
+
return direct
|
|
150
|
+
return None
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Stable handler exports for courses commands."""
|
|
3
|
+
|
|
4
|
+
from ._purchase import handle_purchase
|
|
5
|
+
from .capabilities import (
|
|
6
|
+
handle_courses_capabilities_print,
|
|
7
|
+
handle_courses_capabilities_validate,
|
|
8
|
+
)
|
|
9
|
+
from .mutations import (
|
|
10
|
+
handle_create,
|
|
11
|
+
handle_get,
|
|
12
|
+
handle_update,
|
|
13
|
+
)
|
|
14
|
+
from .publication import (
|
|
15
|
+
handle_publication_latest,
|
|
16
|
+
handle_publication_request,
|
|
17
|
+
)
|
|
18
|
+
from .report_usage import handle_report_usage
|
|
19
|
+
from .reviews import (
|
|
20
|
+
handle_feedback,
|
|
21
|
+
handle_reviews_list,
|
|
22
|
+
handle_reviews_mine,
|
|
23
|
+
handle_reviews_summary,
|
|
24
|
+
handle_reviews_upsert,
|
|
25
|
+
)
|
|
26
|
+
from .uploads import (
|
|
27
|
+
handle_uploads_complete,
|
|
28
|
+
handle_uploads_create,
|
|
29
|
+
)
|
|
30
|
+
from .versions import handle_versions_get
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"handle_courses_capabilities_print",
|
|
34
|
+
"handle_courses_capabilities_validate",
|
|
35
|
+
"handle_create",
|
|
36
|
+
"handle_feedback",
|
|
37
|
+
"handle_get",
|
|
38
|
+
"handle_publication_latest",
|
|
39
|
+
"handle_publication_request",
|
|
40
|
+
"handle_purchase",
|
|
41
|
+
"handle_report_usage",
|
|
42
|
+
"handle_reviews_list",
|
|
43
|
+
"handle_reviews_mine",
|
|
44
|
+
"handle_reviews_summary",
|
|
45
|
+
"handle_reviews_upsert",
|
|
46
|
+
"handle_update",
|
|
47
|
+
"handle_uploads_complete",
|
|
48
|
+
"handle_uploads_create",
|
|
49
|
+
"handle_versions_get",
|
|
50
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Mutation handlers for courses commands."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from cli._config import resolve_config_from_args
|
|
10
|
+
from cli._context import make_client
|
|
11
|
+
from cli._errors import handle_error, print_err, validate_uuid_id
|
|
12
|
+
from cli._output import emit, emit_json, to_data
|
|
13
|
+
from cli._utils import only_not_none
|
|
14
|
+
from cli.commands.courses._capability_render import (
|
|
15
|
+
_append_summary_fields,
|
|
16
|
+
append_approved_capability_summary_lines,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
MUTABLE_UPDATE_FIELDS = [
|
|
20
|
+
"title",
|
|
21
|
+
"description",
|
|
22
|
+
"price_cents",
|
|
23
|
+
"currency",
|
|
24
|
+
"language",
|
|
25
|
+
"short_summary",
|
|
26
|
+
"visibility",
|
|
27
|
+
"category",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _append_course_capability_lines(
|
|
32
|
+
lines: list[str],
|
|
33
|
+
data: dict[str, object],
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Append latest-version capability summary lines for course detail."""
|
|
36
|
+
status = data.get("latest_version_capabilities_status")
|
|
37
|
+
if status:
|
|
38
|
+
lines.append(f"latest_version_capabilities_status: {status}")
|
|
39
|
+
schema_version = data.get("latest_version_capabilities_schema_version")
|
|
40
|
+
if schema_version is not None:
|
|
41
|
+
lines.append(
|
|
42
|
+
f"latest_version_capabilities_schema_version: {schema_version}"
|
|
43
|
+
)
|
|
44
|
+
summary = data.get("latest_version_capabilities_summary")
|
|
45
|
+
if summary and isinstance(summary, dict):
|
|
46
|
+
_append_summary_fields(lines, summary)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _check_clear_price_conflict(args: argparse.Namespace) -> int | None:
|
|
50
|
+
if args.clear_price and (args.currency or args.clear_currency):
|
|
51
|
+
print_err(
|
|
52
|
+
"Error: --clear-price cannot be used with "
|
|
53
|
+
"--currency or --clear-currency."
|
|
54
|
+
)
|
|
55
|
+
return 2
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _apply_update_overrides(
|
|
60
|
+
args: argparse.Namespace,
|
|
61
|
+
kwargs: dict[str, object],
|
|
62
|
+
) -> None:
|
|
63
|
+
if args.clear_tags:
|
|
64
|
+
kwargs["tags"] = []
|
|
65
|
+
elif args.tags:
|
|
66
|
+
kwargs["tags"] = args.tags
|
|
67
|
+
if args.clear_description:
|
|
68
|
+
kwargs["description"] = None
|
|
69
|
+
if args.clear_short_summary:
|
|
70
|
+
kwargs["short_summary"] = None
|
|
71
|
+
if args.clear_language:
|
|
72
|
+
kwargs["language"] = None
|
|
73
|
+
if args.clear_currency:
|
|
74
|
+
kwargs["currency"] = None
|
|
75
|
+
if args.clear_price:
|
|
76
|
+
kwargs["price_cents"] = None
|
|
77
|
+
kwargs["currency"] = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _has_mutable_field(args: argparse.Namespace) -> bool:
|
|
81
|
+
if any(
|
|
82
|
+
getattr(args, field, None) is not None
|
|
83
|
+
for field in MUTABLE_UPDATE_FIELDS
|
|
84
|
+
):
|
|
85
|
+
return True
|
|
86
|
+
if args.tags:
|
|
87
|
+
return True
|
|
88
|
+
return bool(
|
|
89
|
+
args.clear_tags
|
|
90
|
+
or args.clear_description
|
|
91
|
+
or args.clear_short_summary
|
|
92
|
+
or args.clear_language
|
|
93
|
+
or args.clear_currency
|
|
94
|
+
or args.clear_price
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def handle_create(args: argparse.Namespace) -> int:
|
|
99
|
+
"""Execute the courses create command."""
|
|
100
|
+
config = resolve_config_from_args(args)
|
|
101
|
+
client = make_client(config)
|
|
102
|
+
try:
|
|
103
|
+
kwargs = only_not_none(
|
|
104
|
+
{"title": args.title, "slug": args.slug},
|
|
105
|
+
description=args.description,
|
|
106
|
+
price_cents=args.price_cents,
|
|
107
|
+
currency=args.currency,
|
|
108
|
+
language=args.language,
|
|
109
|
+
short_summary=args.short_summary,
|
|
110
|
+
visibility=args.visibility,
|
|
111
|
+
category=getattr(args, "category", None),
|
|
112
|
+
)
|
|
113
|
+
if args.tags:
|
|
114
|
+
kwargs["tags"] = args.tags
|
|
115
|
+
result = client.v1.courses.create(**kwargs)
|
|
116
|
+
if config.json_output:
|
|
117
|
+
emit_json("logion.courses.create", to_data(result))
|
|
118
|
+
else:
|
|
119
|
+
emit(result, json_output=False)
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
return handle_error(exc)
|
|
122
|
+
else:
|
|
123
|
+
return 0
|
|
124
|
+
finally:
|
|
125
|
+
client.close()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def handle_get(args: argparse.Namespace) -> int:
|
|
129
|
+
"""Execute the courses get command."""
|
|
130
|
+
bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
|
|
131
|
+
if bad_id is not None:
|
|
132
|
+
return bad_id
|
|
133
|
+
config = resolve_config_from_args(args)
|
|
134
|
+
client = make_client(config)
|
|
135
|
+
try:
|
|
136
|
+
result = client.v1.courses.get(course_id=args.course_id)
|
|
137
|
+
if config.json_output:
|
|
138
|
+
emit(result, json_output=True)
|
|
139
|
+
else:
|
|
140
|
+
data = to_data(result)
|
|
141
|
+
lines: list[str] = [
|
|
142
|
+
f"id: {data['id']}",
|
|
143
|
+
f"owner_agent_id: {data['owner_agent_id']}",
|
|
144
|
+
f"title: {data['title']}",
|
|
145
|
+
f"slug: {data['slug']}",
|
|
146
|
+
f"status: {data['status']}",
|
|
147
|
+
f"visibility: {data['visibility']}",
|
|
148
|
+
]
|
|
149
|
+
if data.get("description"):
|
|
150
|
+
lines.append(f"description: {data['description']}")
|
|
151
|
+
if data.get("short_summary"):
|
|
152
|
+
lines.append(f"short_summary: {data['short_summary']}")
|
|
153
|
+
lines.append(f"price_cents: {data['price_cents']}")
|
|
154
|
+
lines.append(f"currency: {data['currency']}")
|
|
155
|
+
if data.get("language"):
|
|
156
|
+
lines.append(f"language: {data['language']}")
|
|
157
|
+
if data.get("tags"):
|
|
158
|
+
lines.append(f"tags: {', '.join(data['tags'])}")
|
|
159
|
+
lines.append(f"current_version: {data.get('current_version')}")
|
|
160
|
+
latest_version_id = data.get("latest_version_id")
|
|
161
|
+
if latest_version_id:
|
|
162
|
+
lines.append(f"latest_version_id: {latest_version_id}")
|
|
163
|
+
_append_course_capability_lines(lines, data)
|
|
164
|
+
append_approved_capability_summary_lines(lines, data)
|
|
165
|
+
if data.get("published_at"):
|
|
166
|
+
lines.append(f"published_at: {data['published_at']}")
|
|
167
|
+
lines.append(f"created_at: {data['created_at']}")
|
|
168
|
+
lines.append(f"updated_at: {data['updated_at']}")
|
|
169
|
+
sys.stdout.write("\n".join(lines))
|
|
170
|
+
sys.stdout.write("\n")
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
return handle_error(exc)
|
|
173
|
+
else:
|
|
174
|
+
return 0
|
|
175
|
+
finally:
|
|
176
|
+
client.close()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def handle_update(args: argparse.Namespace) -> int:
|
|
180
|
+
"""Execute the courses update command."""
|
|
181
|
+
bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
|
|
182
|
+
if bad_id is not None:
|
|
183
|
+
return bad_id
|
|
184
|
+
price_conflict = _check_clear_price_conflict(args)
|
|
185
|
+
if price_conflict is not None:
|
|
186
|
+
return price_conflict
|
|
187
|
+
if not _has_mutable_field(args):
|
|
188
|
+
print_err(
|
|
189
|
+
"Error: courses update requires at least one field to change."
|
|
190
|
+
)
|
|
191
|
+
return 2
|
|
192
|
+
config = resolve_config_from_args(args)
|
|
193
|
+
client = make_client(config)
|
|
194
|
+
try:
|
|
195
|
+
kwargs = only_not_none(
|
|
196
|
+
{"course_id": args.course_id},
|
|
197
|
+
title=args.title,
|
|
198
|
+
description=args.description,
|
|
199
|
+
price_cents=args.price_cents,
|
|
200
|
+
currency=args.currency,
|
|
201
|
+
language=args.language,
|
|
202
|
+
short_summary=args.short_summary,
|
|
203
|
+
visibility=args.visibility,
|
|
204
|
+
category=getattr(args, "category", None),
|
|
205
|
+
)
|
|
206
|
+
_apply_update_overrides(args, kwargs)
|
|
207
|
+
result = client.v1.courses.update(**kwargs)
|
|
208
|
+
if config.json_output:
|
|
209
|
+
emit_json("logion.courses.update", to_data(result))
|
|
210
|
+
else:
|
|
211
|
+
emit(result, json_output=False)
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
return handle_error(exc)
|
|
214
|
+
else:
|
|
215
|
+
return 0
|
|
216
|
+
finally:
|
|
217
|
+
client.close()
|