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