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,65 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Parser registration for referrals commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from cli._options import COMMON_PARSER
9
+
10
+ from .handlers import (
11
+ handle_referrals_attributions,
12
+ handle_referrals_code,
13
+ handle_referrals_link,
14
+ handle_referrals_stats,
15
+ )
16
+
17
+
18
+ def register(subparsers: argparse._SubParsersAction) -> None:
19
+ """Register the ``referrals`` subcommand group."""
20
+ parser = subparsers.add_parser(
21
+ "referrals",
22
+ help="Manage referral codes, links, and attributions",
23
+ )
24
+ sub = parser.add_subparsers(
25
+ dest="referrals_command",
26
+ required=True,
27
+ )
28
+
29
+ # referrals code
30
+ code = sub.add_parser(
31
+ "code",
32
+ help="Show your default referral code",
33
+ parents=[COMMON_PARSER],
34
+ )
35
+ code.set_defaults(handler=handle_referrals_code)
36
+
37
+ # referrals link
38
+ link = sub.add_parser(
39
+ "link",
40
+ help="Generate a referral link for a course",
41
+ parents=[COMMON_PARSER],
42
+ )
43
+ link.add_argument("course_id", metavar="COURSE_ID")
44
+ link.add_argument(
45
+ "--yes",
46
+ action="store_true",
47
+ help="Confirm sharing this referral link.",
48
+ )
49
+ link.set_defaults(handler=handle_referrals_link)
50
+
51
+ # referrals stats
52
+ stats = sub.add_parser(
53
+ "stats",
54
+ help="Show referral statistics",
55
+ parents=[COMMON_PARSER],
56
+ )
57
+ stats.set_defaults(handler=handle_referrals_stats)
58
+
59
+ # referrals attributions
60
+ attributions = sub.add_parser(
61
+ "attributions",
62
+ help="List referral attributions",
63
+ parents=[COMMON_PARSER],
64
+ )
65
+ attributions.set_defaults(handler=handle_referrals_attributions)
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Reports command package."""
3
+
4
+ from .parser import register
5
+
6
+ __all__ = ["register"]
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Handlers for reports commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from cli._config import resolve_config_from_args
9
+ from cli._confirm import require_yes
10
+ from cli._context import make_client
11
+ from cli._errors import handle_error, require_non_empty_id, validate_uuid_id
12
+ from cli._output import emit
13
+ from cli._utils import only_not_none
14
+
15
+ UUID_TARGET_TYPES = frozenset([
16
+ "agent",
17
+ "bounty",
18
+ "bounty_submission",
19
+ "course",
20
+ "user",
21
+ ])
22
+
23
+
24
+ def _validate_target_id(target_type: str, target_id: str) -> int | None:
25
+ """Validate ``target_id`` for the selected report target type."""
26
+ if target_type in UUID_TARGET_TYPES:
27
+ return validate_uuid_id(target_id, "--target-id")
28
+ return require_non_empty_id(target_id, "--target-id")
29
+
30
+
31
+ def handle_create(args: argparse.Namespace) -> int:
32
+ """Execute the reports create command."""
33
+ bad_id = _validate_target_id(args.target_type, args.target_id)
34
+ if bad_id is not None:
35
+ return bad_id
36
+ refusal = require_yes(args.yes, "create this report")
37
+ if refusal is not None:
38
+ return refusal
39
+ config = resolve_config_from_args(args)
40
+ client = make_client(config)
41
+ try:
42
+ kwargs = only_not_none(
43
+ {
44
+ "target_type": args.target_type,
45
+ "target_id": args.target_id,
46
+ "reason": args.reason,
47
+ },
48
+ description=args.description,
49
+ )
50
+ result = client.v1.reports.create(**kwargs)
51
+ emit(result, json_output=config.json_output)
52
+ except Exception as exc:
53
+ return handle_error(exc)
54
+ else:
55
+ return 0
56
+ finally:
57
+ client.close()
@@ -0,0 +1,52 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Parser registration for reports commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from cli._options import COMMON_PARSER
9
+
10
+ from .handlers import handle_create
11
+
12
+ TARGET_TYPES = [
13
+ "agent",
14
+ "bounty",
15
+ "bounty_submission",
16
+ "course",
17
+ "user",
18
+ ]
19
+ REPORT_REASONS = [
20
+ "spam",
21
+ "scam",
22
+ "harassment",
23
+ "hate",
24
+ "illegal",
25
+ "ip_violation",
26
+ "malware",
27
+ "other",
28
+ ]
29
+
30
+
31
+ def register(subparsers: argparse._SubParsersAction) -> None:
32
+ """Register the ``reports`` subcommand group."""
33
+ parser = subparsers.add_parser(
34
+ "reports",
35
+ help="Create content reports",
36
+ )
37
+ sub = parser.add_subparsers(
38
+ dest="reports_command",
39
+ required=True,
40
+ )
41
+
42
+ create = sub.add_parser(
43
+ "create",
44
+ help="Create a new report",
45
+ parents=[COMMON_PARSER],
46
+ )
47
+ create.add_argument("--target-type", required=True, choices=TARGET_TYPES)
48
+ create.add_argument("--target-id", required=True)
49
+ create.add_argument("--reason", required=True, choices=REPORT_REASONS)
50
+ create.add_argument("--description")
51
+ create.add_argument("--yes", action="store_true")
52
+ create.set_defaults(handler=handle_create)
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Skills command package — local install/update/inspect of marketplace
3
+ capabilities under ``~/.logion/installed``."""
4
+
5
+ from .parser import register
6
+
7
+ __all__ = ["register"]
@@ -0,0 +1,161 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Symlink installed skills into a coding agent's skill directory.
3
+
4
+ Logion's canonical install location is
5
+ ``$LOGION_HOME/installed/<course-id>/<version-id>/``. That keeps
6
+ Logion's lifecycle separate from the user's agent harness. But agents
7
+ (Claude Code, Codex, OpenCode, Hermes, ...) load skills from their own
8
+ fixed directories, so without a symlink the user has to wire the two
9
+ together manually after every install.
10
+
11
+ This module reads the skill name from the bundle's SKILL.md frontmatter
12
+ and offers to create the symlink. The target directory is whatever the
13
+ user types — no auto-detection, no harness inference. The known
14
+ conventions are listed in the prompt as examples only.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ # Shown only as examples in the prompt. We do not infer or filter.
23
+ EXAMPLE_AGENT_DIRS: tuple[tuple[str, str], ...] = (
24
+ ("Claude Code", "~/.claude/skills"),
25
+ ("Codex", "~/.agents/skills"),
26
+ ("OpenCode", "~/.config/opencode/skills"),
27
+ ("Hermes", "~/.hermes/skills"),
28
+ )
29
+
30
+
31
+ def read_skill_name(source_dir: Path) -> str | None:
32
+ """Read ``name:`` from the bundle's SKILL.md frontmatter."""
33
+ skill_md = source_dir / "SKILL.md"
34
+ if not skill_md.is_file():
35
+ return None
36
+ try:
37
+ text = skill_md.read_text(encoding="utf-8", errors="replace")
38
+ except OSError:
39
+ return None
40
+ if not text.startswith("---"):
41
+ return None
42
+ end = text.find("\n---", 3)
43
+ if end < 0:
44
+ return None
45
+ block = text[3:end]
46
+ for line in block.splitlines():
47
+ stripped = line.strip()
48
+ if stripped.startswith("name:"):
49
+ value = stripped[len("name:") :].strip().strip('"').strip("'")
50
+ return value or None
51
+ return None
52
+
53
+
54
+ def prompt_symlink_dir(
55
+ skill_name: str,
56
+ *,
57
+ explicit_dir: str | None,
58
+ no_symlink: bool,
59
+ ) -> Path | None:
60
+ """Decide where to symlink the installed skill.
61
+
62
+ Returns the resolved parent directory (which the link will be
63
+ placed inside) or ``None`` to skip.
64
+
65
+ Resolution order:
66
+ 1. ``--no-symlink`` → None.
67
+ 2. ``--symlink-dir PATH`` → PATH (no prompt).
68
+ 3. Non-interactive (stdin not a TTY) → None.
69
+ 4. Otherwise prompt: y/n, then a free-form path.
70
+ """
71
+ if no_symlink:
72
+ return None
73
+ if explicit_dir:
74
+ return Path(explicit_dir).expanduser()
75
+ if not sys.stdin.isatty():
76
+ return None
77
+
78
+ sys.stdout.write(
79
+ f"\nSymlink skill '{skill_name}' into a coding-agent skill dir? "
80
+ "[y/N]: "
81
+ )
82
+ sys.stdout.flush()
83
+ try:
84
+ raw = input().strip().lower()
85
+ except (EOFError, KeyboardInterrupt):
86
+ sys.stdout.write("\n")
87
+ return None
88
+ if raw not in ("y", "yes"):
89
+ return None
90
+
91
+ sys.stdout.write("Target directory for the symlink. Examples:\n")
92
+ for label, path in EXAMPLE_AGENT_DIRS:
93
+ sys.stdout.write(f" {label:14} {path}\n")
94
+ sys.stdout.write("Path: ")
95
+ sys.stdout.flush()
96
+ try:
97
+ raw_path = input().strip()
98
+ except (EOFError, KeyboardInterrupt):
99
+ sys.stdout.write("\n")
100
+ return None
101
+ if not raw_path:
102
+ return None
103
+ return Path(raw_path).expanduser()
104
+
105
+
106
+ def create_symlink(
107
+ parent_dir: Path, skill_name: str, install_dest: Path
108
+ ) -> Path:
109
+ """Symlink ``install_dest`` into ``parent_dir / skill_name``.
110
+
111
+ Replaces any prior symlink or file at the target. Refuses to
112
+ clobber a real directory (the user may have placed it themselves).
113
+ Creates ``parent_dir`` if missing. Returns the resulting link path.
114
+ """
115
+ parent_dir.mkdir(parents=True, exist_ok=True)
116
+ link = parent_dir / skill_name
117
+ if link.is_symlink() or link.is_file():
118
+ link.unlink()
119
+ elif link.is_dir():
120
+ raise FileExistsError(
121
+ f"{link} is a real directory, not a symlink. "
122
+ "Remove it before re-running install."
123
+ )
124
+ link.symlink_to(install_dest, target_is_directory=True)
125
+ return link
126
+
127
+
128
+ def resolve_symlink_intent(
129
+ source_dir: Path, args
130
+ ) -> tuple[str | None, Path | None]:
131
+ """Read the skill name and prompt for symlink target up-front.
132
+
133
+ Returns ``(skill_name, symlink_parent)``. Either or both may be None
134
+ if the bundle has no readable name or the user declined.
135
+ """
136
+ skill_name = read_skill_name(source_dir)
137
+ if not skill_name:
138
+ return None, None
139
+ symlink_parent = prompt_symlink_dir(
140
+ skill_name,
141
+ explicit_dir=getattr(args, "symlink_dir", None),
142
+ no_symlink=getattr(args, "dry_run", False)
143
+ or getattr(args, "no_symlink", True),
144
+ )
145
+ return skill_name, symlink_parent
146
+
147
+
148
+ def apply_post_install_symlink(
149
+ symlink_parent: Path, skill_name: str, dest: Path
150
+ ) -> None:
151
+ """Create the symlink and surface errors as warnings (non-fatal)."""
152
+ try:
153
+ link = create_symlink(symlink_parent, skill_name, dest)
154
+ except FileExistsError as exc:
155
+ sys.stderr.write(f"WARN: symlink skipped: {exc}\n")
156
+ except OSError as exc:
157
+ sys.stderr.write(
158
+ f"WARN: symlink failed ({exc}); canonical install is fine\n"
159
+ )
160
+ else:
161
+ sys.stdout.write(f"Symlinked: {link} -> {dest}\n")
@@ -0,0 +1,112 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Finalisation step for ``logion skills install``.
3
+
4
+ Kept separate from :mod:`_install_helpers` so each source file stays
5
+ under the per-file line budget enforced by ``test_cli_architecture``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import shutil
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from cli._local_state import (
17
+ build_index,
18
+ enrich_manifest,
19
+ rebuild_recall,
20
+ release_lock,
21
+ state_lock,
22
+ validate_manifest,
23
+ write_index,
24
+ write_manifest,
25
+ )
26
+
27
+ from ._install_helpers import compute_content_hash, copy_skill_files
28
+
29
+
30
+ def copy_and_finalize(
31
+ source: Path,
32
+ dest: Path,
33
+ course_id: str,
34
+ version_id: str,
35
+ manifest_data: dict[str, Any],
36
+ home: Path,
37
+ ) -> tuple[int, list[Path]]:
38
+ """Copy files, write manifest+index+recall, and release the lock.
39
+
40
+ Returns ``(exit_code, copied)``. On failure the partial install
41
+ is removed so the next attempt is not confused by orphan files.
42
+ Filesystem errors (rmtree/copy2/hashing) are caught and reported
43
+ as exit code 5 rather than allowed to crash the CLI.
44
+ """
45
+ copied: list[Path] = []
46
+ try:
47
+ try:
48
+ if dest.exists():
49
+ shutil.rmtree(dest)
50
+ copied = copy_skill_files(source, dest, dry_run=False)
51
+ existing_files = [
52
+ p for p in sorted(dest.rglob("*")) if p.is_file()
53
+ ]
54
+ manifest_data["content_sha256"] = compute_content_hash(
55
+ existing_files, root=dest
56
+ )
57
+ except (OSError, shutil.Error) as exc:
58
+ print(
59
+ f"ERROR: filesystem error while installing "
60
+ f"{course_id}/{version_id}: {exc}",
61
+ file=sys.stderr,
62
+ )
63
+ if dest.exists():
64
+ shutil.rmtree(dest, ignore_errors=True)
65
+ return 5, copied
66
+
67
+ manifest_data["installed_at"] = datetime.datetime.now(
68
+ datetime.UTC
69
+ ).isoformat()
70
+ manifest_data = enrich_manifest(
71
+ manifest_data,
72
+ course_id,
73
+ version_id,
74
+ home,
75
+ )
76
+ errors = validate_manifest(manifest_data)
77
+ if errors:
78
+ for e in errors:
79
+ print(f"MANIFEST ERROR: {e}", file=sys.stderr)
80
+ if dest.exists():
81
+ shutil.rmtree(dest, ignore_errors=True)
82
+ return 3, copied
83
+ try:
84
+ write_manifest(manifest_data, course_id, version_id, home)
85
+ except OSError as exc:
86
+ print(
87
+ f"ERROR: failed to write manifest for "
88
+ f"{course_id}/{version_id}: {exc}",
89
+ file=sys.stderr,
90
+ )
91
+ if dest.exists():
92
+ shutil.rmtree(dest, ignore_errors=True)
93
+ return 5, copied
94
+ finally:
95
+ release_lock(course_id, version_id, home)
96
+
97
+ try:
98
+ # state_lock serializes the index+recall refresh so a parallel
99
+ # install or `recall record` cannot race and drop entries.
100
+ with state_lock(home):
101
+ write_index(build_index(home), home)
102
+ rebuild_recall(home)
103
+ except OSError as exc:
104
+ # Install succeeded on disk; only the index/recall refresh
105
+ # failed. Report but do not roll back — the next install or
106
+ # recall rebuild will repair the indexes.
107
+ print(
108
+ f"WARNING: install succeeded but index/recall refresh "
109
+ f"failed: {exc}",
110
+ file=sys.stderr,
111
+ )
112
+ return 0, copied
@@ -0,0 +1,218 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Handler for ``logion skills inspect``.
3
+
4
+ Kept separate from :mod:`handlers` so each file stays under the
5
+ CLI's 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._config import resolve_config_from_args
15
+ from cli._context import make_client
16
+ from cli._errors import emit_error_json
17
+ from cli._local_state import (
18
+ UnsafeIdentifierError,
19
+ _safe_segment,
20
+ list_installed,
21
+ read_manifest,
22
+ )
23
+ from cli._output import emit_json
24
+
25
+ from ._install_helpers import resolve_target
26
+
27
+ _REMOTE_MERGE_KEYS = (
28
+ "title",
29
+ "slug",
30
+ "status",
31
+ "visibility",
32
+ "description",
33
+ "short_summary",
34
+ "price_cents",
35
+ "currency",
36
+ "language",
37
+ "owner_agent_id",
38
+ "tags",
39
+ )
40
+
41
+
42
+ def _error(
43
+ args: argparse.Namespace, code: str, message: str, exit_code: int
44
+ ) -> int:
45
+ """Emit a compliant error in JSON or human form."""
46
+ if getattr(args, "json_output", False):
47
+ emit_error_json(code, message, exit_code)
48
+ else:
49
+ print(f"ERROR: {message}", file=sys.stderr)
50
+ return exit_code
51
+
52
+
53
+ def _to_plain_dict(value: Any) -> dict[str, Any] | None:
54
+ """Convert SDK values to plain dicts when possible."""
55
+ if value is None:
56
+ return None
57
+ if hasattr(value, "model_dump"):
58
+ return value.model_dump(mode="json") # type: ignore[union-attr]
59
+ if isinstance(value, dict):
60
+ return dict(value)
61
+ return None
62
+
63
+
64
+ def _fetch_remote_payloads(
65
+ args: argparse.Namespace,
66
+ course_id: str,
67
+ version_id: str | None,
68
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
69
+ """Fetch remote course and version payloads when SDK support exists."""
70
+ try:
71
+ config = resolve_config_from_args(args)
72
+ client = make_client(config)
73
+ except Exception:
74
+ return None, None
75
+
76
+ try:
77
+ remote_course = None
78
+ remote_version = None
79
+ try:
80
+ remote_course = _to_plain_dict(
81
+ client.v1.courses.get(course_id=course_id)
82
+ )
83
+ except Exception:
84
+ remote_course = None
85
+
86
+ if version_id is not None and hasattr(
87
+ client.v1.courses, "get_version"
88
+ ):
89
+ try:
90
+ remote_version = _to_plain_dict(
91
+ client.v1.courses.get_version(
92
+ course_id=course_id,
93
+ version_id=version_id,
94
+ )
95
+ )
96
+ except Exception:
97
+ remote_version = None
98
+ return remote_course, remote_version
99
+ finally:
100
+ client.close()
101
+
102
+
103
+ def _local_manifest_for_course(
104
+ home: Any, course_id: str
105
+ ) -> dict[str, Any] | None:
106
+ """Return the newest available local manifest for *course_id*."""
107
+ candidates = [
108
+ m for m in list_installed(home) if m.get("course_id") == course_id
109
+ ]
110
+ if not candidates:
111
+ return None
112
+ return candidates[-1]
113
+
114
+
115
+ def _synthesized_remote_manifest(
116
+ course_id: str,
117
+ version_id: str | None,
118
+ remote_course: dict[str, Any],
119
+ ) -> dict[str, Any]:
120
+ """Build a provenance-compatible inspect payload
121
+ for remote-only courses.
122
+ """
123
+ latest_version = remote_course.get("latest_version_id")
124
+ return {
125
+ "course_id": course_id,
126
+ "version_id": version_id or latest_version,
127
+ "title": remote_course.get("title", ""),
128
+ "source": "logion-marketplace",
129
+ "entitlement_status": "missing",
130
+ "license_scope": "unknown",
131
+ "official_update_channel": True,
132
+ "last_verified_at": None,
133
+ "manifest_path": None,
134
+ "entrypoint": "SKILL.md",
135
+ }
136
+
137
+
138
+ def handle_skills_inspect(args: argparse.Namespace) -> int:
139
+ """Inspect a local skill install, enriched with remote metadata."""
140
+ home = resolve_target(args)
141
+ course_id: str = args.course_id
142
+ version_id: str | None = getattr(args, "version_id", None)
143
+ verbose = bool(getattr(args, "verbose", False))
144
+
145
+ try:
146
+ _safe_segment(course_id, "course_id")
147
+ if version_id is not None:
148
+ _safe_segment(version_id, "version_id")
149
+ except UnsafeIdentifierError as exc:
150
+ return _error(args, "unsafe_identifier", str(exc), 2)
151
+
152
+ manifest = (
153
+ read_manifest(course_id, version_id, home)
154
+ if version_id is not None
155
+ else _local_manifest_for_course(home, course_id)
156
+ )
157
+ remote_course, remote_version = _fetch_remote_payloads(
158
+ args, course_id, version_id
159
+ )
160
+
161
+ if manifest is None and remote_course is None:
162
+ target = f"{course_id}/{version_id}" if version_id else course_id
163
+ return _error(
164
+ args, "not_found", f"No skill metadata found for {target}", 1
165
+ )
166
+
167
+ merged: dict[str, Any] = (
168
+ dict(manifest)
169
+ if manifest is not None
170
+ else _synthesized_remote_manifest(
171
+ course_id, version_id, remote_course or {}
172
+ )
173
+ )
174
+ if remote_course is not None:
175
+ for key in _REMOTE_MERGE_KEYS:
176
+ if key in remote_course and remote_course[key] is not None:
177
+ merged[key] = remote_course[key]
178
+ if verbose:
179
+ merged["remote_course"] = remote_course
180
+ if remote_version is not None:
181
+ merged["remote_version"] = remote_version
182
+ merged["version_id"] = remote_version.get(
183
+ "id", merged.get("version_id")
184
+ )
185
+ merged.setdefault(
186
+ "capabilities_manifest_path",
187
+ remote_version.get("capabilities_manifest_path"),
188
+ )
189
+ merged.setdefault("version_status", remote_version.get("status"))
190
+
191
+ if getattr(args, "json_output", False):
192
+ emit_json("logion.skills.inspect", merged)
193
+ else:
194
+ fields = [
195
+ ("course_id", merged.get("course_id", "")),
196
+ ("version_id", merged.get("version_id", "")),
197
+ ("title", merged.get("title", "")),
198
+ ("source", merged.get("source", "")),
199
+ ("entitlement_status", merged.get("entitlement_status", "")),
200
+ ("license_scope", merged.get("license_scope", "")),
201
+ (
202
+ "official_update_channel",
203
+ merged.get("official_update_channel", ""),
204
+ ),
205
+ (
206
+ "last_verified_at",
207
+ merged.get("last_verified_at", "") or "never",
208
+ ),
209
+ ("manifest_path", merged.get("manifest_path", "") or "n/a"),
210
+ ]
211
+ for label, value in fields:
212
+ print(f" {label}: {value}")
213
+ if verbose and remote_version is not None:
214
+ print(
215
+ f" remote_version_status: {remote_version.get('status', '')}"
216
+ )
217
+
218
+ return 0