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,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()