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
cli/_options.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Shared CLI option definitions."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_common_parser() -> argparse.ArgumentParser:
|
|
10
|
+
"""Build a parent parser with shared CLI options.
|
|
11
|
+
|
|
12
|
+
Use this via ``parents=[build_common_parser()]`` on leaf subcommands
|
|
13
|
+
so that argparse merges common options correctly without overwriting
|
|
14
|
+
user-supplied values.
|
|
15
|
+
"""
|
|
16
|
+
parent = argparse.ArgumentParser(add_help=False)
|
|
17
|
+
parent.add_argument("--api-key")
|
|
18
|
+
parent.add_argument("--base-url")
|
|
19
|
+
parent.add_argument(
|
|
20
|
+
"--json",
|
|
21
|
+
action="store_true",
|
|
22
|
+
dest="json_output",
|
|
23
|
+
)
|
|
24
|
+
parent.add_argument("--timeout", type=float)
|
|
25
|
+
parent.add_argument("--max-retries", type=int)
|
|
26
|
+
parent.add_argument(
|
|
27
|
+
"--no-onboarding",
|
|
28
|
+
action="store_true",
|
|
29
|
+
default=argparse.SUPPRESS,
|
|
30
|
+
help="Never run first-run onboarding for this invocation.",
|
|
31
|
+
)
|
|
32
|
+
return parent
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Module-level singleton so the parent parser is built once.
|
|
36
|
+
COMMON_PARSER = build_common_parser()
|
cli/_output.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Output helpers — JSON and human-readable formatting."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def to_data(value: Any) -> Any:
|
|
13
|
+
"""Recursively convert Pydantic models to plain JSON-safe data."""
|
|
14
|
+
if isinstance(value, BaseModel):
|
|
15
|
+
return value.model_dump(mode="json")
|
|
16
|
+
if isinstance(value, list):
|
|
17
|
+
return [to_data(item) for item in value]
|
|
18
|
+
if isinstance(value, dict):
|
|
19
|
+
return {key: to_data(item) for key, item in value.items()}
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def emit_json(kind: str, data: Any) -> None:
|
|
24
|
+
"""Print a v1 JSON envelope with version, kind, and data."""
|
|
25
|
+
payload = {"version": "v1", "kind": kind, "data": to_data(data)}
|
|
26
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def truncate_summary(text: str | None, max_len: int = 120) -> str:
|
|
30
|
+
"""Truncate *text* to *max_len* chars, appending ``…`` if truncated."""
|
|
31
|
+
if not text:
|
|
32
|
+
return ""
|
|
33
|
+
return text if len(text) <= max_len else text[: max_len - 1] + "…"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def emit(value: Any, *, json_output: bool) -> None:
|
|
37
|
+
"""Print *value* as JSON.
|
|
38
|
+
|
|
39
|
+
In JSON mode the output is sorted and indented for scripts.
|
|
40
|
+
In human mode the output is indented but preserves natural key order
|
|
41
|
+
for readability.
|
|
42
|
+
"""
|
|
43
|
+
data = to_data(value)
|
|
44
|
+
if json_output:
|
|
45
|
+
print(json.dumps(data, indent=2, sort_keys=True))
|
|
46
|
+
else:
|
|
47
|
+
print(json.dumps(data, indent=2))
|
cli/_parser.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Argparse parser factory for the Logion CLI."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
|
|
8
|
+
from cli._version import __version__
|
|
9
|
+
from cli.commands import (
|
|
10
|
+
admin,
|
|
11
|
+
bounties,
|
|
12
|
+
course_reviews,
|
|
13
|
+
courses,
|
|
14
|
+
docs,
|
|
15
|
+
health,
|
|
16
|
+
identity,
|
|
17
|
+
listings,
|
|
18
|
+
notifications,
|
|
19
|
+
payments,
|
|
20
|
+
recall,
|
|
21
|
+
reports,
|
|
22
|
+
skills,
|
|
23
|
+
)
|
|
24
|
+
from cli.commands import (
|
|
25
|
+
credits as credits_mod,
|
|
26
|
+
)
|
|
27
|
+
from cli.commands import (
|
|
28
|
+
referrals as referrals_mod,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
"""Build the top-level argument parser."""
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="logion",
|
|
36
|
+
description="Logion CLI for AI agents and marketplace operators.",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--version",
|
|
40
|
+
action="version",
|
|
41
|
+
version=f"%(prog)s {__version__}",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--no-onboarding",
|
|
45
|
+
action="store_true",
|
|
46
|
+
default=argparse.SUPPRESS,
|
|
47
|
+
help="Never run first-run onboarding for this invocation.",
|
|
48
|
+
)
|
|
49
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
50
|
+
|
|
51
|
+
health.register(subparsers)
|
|
52
|
+
identity.register(subparsers)
|
|
53
|
+
listings.register(subparsers)
|
|
54
|
+
notifications.register(subparsers)
|
|
55
|
+
courses.register(subparsers)
|
|
56
|
+
docs.register(subparsers)
|
|
57
|
+
payments.register(subparsers)
|
|
58
|
+
credits_mod.register(subparsers)
|
|
59
|
+
referrals_mod.register(subparsers)
|
|
60
|
+
reports.register(subparsers)
|
|
61
|
+
course_reviews.register(subparsers)
|
|
62
|
+
admin.register(subparsers)
|
|
63
|
+
bounties.register(subparsers)
|
|
64
|
+
skills.register(subparsers)
|
|
65
|
+
recall.register(subparsers)
|
|
66
|
+
|
|
67
|
+
# Top-level `logion onboarding` alias — same handler as
|
|
68
|
+
# `logion identity onboarding`.
|
|
69
|
+
from cli.commands.identity.onboarding import register_onboarding
|
|
70
|
+
|
|
71
|
+
register_onboarding(subparsers)
|
|
72
|
+
|
|
73
|
+
return parser
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Confidence calibration helpers for local recall.
|
|
3
|
+
|
|
4
|
+
Maps raw similarity scores to calibrated confidence values and
|
|
5
|
+
categorical bands used by the companion's decision policy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_last_success_at(value: str | None) -> _dt.datetime | None:
|
|
14
|
+
if not value:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
normalized = value.strip()
|
|
18
|
+
if not normalized:
|
|
19
|
+
return None
|
|
20
|
+
if normalized.endswith("Z"):
|
|
21
|
+
normalized = f"{normalized[:-1]}+00:00"
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
parsed = _dt.datetime.fromisoformat(normalized)
|
|
25
|
+
except (ValueError, TypeError):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
if parsed.tzinfo is None:
|
|
29
|
+
return parsed.replace(tzinfo=_dt.UTC)
|
|
30
|
+
return parsed
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def calibrate_installed_confidence(query_similarity: float) -> float:
|
|
34
|
+
"""Installed capabilities have no prior beyond presence.
|
|
35
|
+
|
|
36
|
+
The query similarity is the entire signal.
|
|
37
|
+
"""
|
|
38
|
+
return query_similarity
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def calibrate_workflow_confidence(
|
|
42
|
+
query_similarity: float,
|
|
43
|
+
success_count: int = 0,
|
|
44
|
+
last_success_at: str | None = None,
|
|
45
|
+
) -> float:
|
|
46
|
+
"""Combine query similarity with persisted workflow prior and recency.
|
|
47
|
+
|
|
48
|
+
``final = clamp(
|
|
49
|
+
0, 1,
|
|
50
|
+
0.6 * query_similarity
|
|
51
|
+
+ 0.3 * persisted_prior
|
|
52
|
+
+ 0.1 * recency_boost,
|
|
53
|
+
)``
|
|
54
|
+
"""
|
|
55
|
+
persisted_prior = min(success_count / 10, 1.0)
|
|
56
|
+
|
|
57
|
+
recency_boost = 0.0
|
|
58
|
+
last = _parse_last_success_at(last_success_at)
|
|
59
|
+
if last is not None:
|
|
60
|
+
now = _dt.datetime.now(_dt.UTC)
|
|
61
|
+
if last > now:
|
|
62
|
+
last = now
|
|
63
|
+
days_ago = (now - last).days
|
|
64
|
+
if days_ago <= 30:
|
|
65
|
+
recency_boost = 1.0
|
|
66
|
+
elif days_ago <= 365:
|
|
67
|
+
recency_boost = 1.0 - (days_ago - 30) / (365 - 30)
|
|
68
|
+
# beyond 365 days → 0.0
|
|
69
|
+
|
|
70
|
+
final = (
|
|
71
|
+
0.6 * query_similarity + 0.3 * persisted_prior + 0.1 * recency_boost
|
|
72
|
+
)
|
|
73
|
+
return max(0.0, min(1.0, final))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def band_for(confidence: float) -> str:
|
|
77
|
+
"""Return the categorical band for a confidence value.
|
|
78
|
+
|
|
79
|
+
- HIGH : confidence >= 0.80
|
|
80
|
+
- MEDIUM : 0.50 <= confidence < 0.80
|
|
81
|
+
- LOW : 0.20 <= confidence < 0.50
|
|
82
|
+
- NONE : confidence < 0.20
|
|
83
|
+
"""
|
|
84
|
+
if confidence >= 0.80:
|
|
85
|
+
return "HIGH"
|
|
86
|
+
if confidence >= 0.50:
|
|
87
|
+
return "MEDIUM"
|
|
88
|
+
if confidence >= 0.20:
|
|
89
|
+
return "LOW"
|
|
90
|
+
return "NONE"
|
cli/_recall_ranker.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Deterministic ranker for the local recall index.
|
|
3
|
+
|
|
4
|
+
Pure function: given a query and a list of compact recall entries,
|
|
5
|
+
returns a sorted, top-k list with per-entry similarity in [0, 1].
|
|
6
|
+
|
|
7
|
+
Uses ``rapidfuzz.fuzz.partial_token_set_ratio`` when available, falls
|
|
8
|
+
back to ``difflib.SequenceMatcher.ratio()`` otherwise. The fallback
|
|
9
|
+
must produce stable (but possibly lower-quality) rankings.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import difflib
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from rapidfuzz import fuzz as _rapidfuzz_fuzz
|
|
19
|
+
|
|
20
|
+
_rf_fuzz: Any | None = _rapidfuzz_fuzz
|
|
21
|
+
_HAS_RAPIDFUZZ = True
|
|
22
|
+
except ImportError: # pragma: no cover
|
|
23
|
+
_rf_fuzz = None
|
|
24
|
+
_HAS_RAPIDFUZZ = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_MINIMUM_SIMILARITY = 0.10
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _compute_similarity(query: str, composed: str) -> float:
|
|
31
|
+
"""Return similarity in [0, 1] between *query* and *composed* string."""
|
|
32
|
+
if _HAS_RAPIDFUZZ and _rf_fuzz is not None:
|
|
33
|
+
return _rf_fuzz.partial_token_set_ratio(query, composed) / 100.0
|
|
34
|
+
return difflib.SequenceMatcher(None, query, composed).ratio()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _compose_entry_text(entry: dict[str, Any]) -> str:
|
|
38
|
+
"""Build a single lowercase string from entry fields for matching."""
|
|
39
|
+
title = entry.get("title", "")
|
|
40
|
+
summary = entry.get("summary", "")
|
|
41
|
+
tokens = entry.get("tokens") or []
|
|
42
|
+
return f"{title} {summary} {' '.join(tokens)}".lower()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def rank(
|
|
46
|
+
query: str,
|
|
47
|
+
entries: list[dict[str, Any]],
|
|
48
|
+
limit: int = 5,
|
|
49
|
+
) -> list[tuple[float, dict[str, Any]]]:
|
|
50
|
+
"""Return up to *limit* ``(similarity, entry)`` tuples, sorted desc.
|
|
51
|
+
|
|
52
|
+
Ranking is deterministic: entries with equal similarity are ordered
|
|
53
|
+
by ``id`` ascending for stability.
|
|
54
|
+
"""
|
|
55
|
+
if not entries or not query:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
q = query.strip().lower()
|
|
59
|
+
if not q:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
scored: list[tuple[float, str, dict[str, Any]]] = []
|
|
63
|
+
for entry in entries:
|
|
64
|
+
composed = _compose_entry_text(entry)
|
|
65
|
+
similarity = _compute_similarity(q, composed)
|
|
66
|
+
if similarity < _MINIMUM_SIMILARITY:
|
|
67
|
+
continue
|
|
68
|
+
entry_id = entry.get("id", "")
|
|
69
|
+
scored.append((similarity, entry_id, entry))
|
|
70
|
+
|
|
71
|
+
# Sort by similarity desc, then id asc for tie-breaking stability
|
|
72
|
+
scored.sort(key=lambda t: (-t[0], t[1]))
|
|
73
|
+
|
|
74
|
+
return [(s, e) for s, _eid, e in scored[:limit]]
|
cli/_taxonomy.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""CLI-side taxonomy helpers mirroring the backend normalization rules.
|
|
3
|
+
|
|
4
|
+
These are duplicated rather than imported from the backend so the CLI
|
|
5
|
+
can validate locally without a network round-trip. The rules must stay
|
|
6
|
+
in sync with the backend taxonomy module.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
CATEGORY_SLUGS: frozenset[str] = frozenset({
|
|
15
|
+
"automation",
|
|
16
|
+
"code-review",
|
|
17
|
+
"data",
|
|
18
|
+
"devops",
|
|
19
|
+
"documentation",
|
|
20
|
+
"finance",
|
|
21
|
+
"marketing",
|
|
22
|
+
"media",
|
|
23
|
+
"productivity",
|
|
24
|
+
"research",
|
|
25
|
+
"security",
|
|
26
|
+
"testing",
|
|
27
|
+
"writing",
|
|
28
|
+
"other",
|
|
29
|
+
})
|
|
30
|
+
DEFAULT_CATEGORY = "other"
|
|
31
|
+
|
|
32
|
+
RESERVED_TAG_SLUGS: frozenset[str] = frozenset({
|
|
33
|
+
"official",
|
|
34
|
+
"verified",
|
|
35
|
+
"trusted",
|
|
36
|
+
"featured",
|
|
37
|
+
"logion",
|
|
38
|
+
"admin",
|
|
39
|
+
"staff",
|
|
40
|
+
"platform",
|
|
41
|
+
"security-audited",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
TAG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TaxonomyValidationError(ValueError):
|
|
48
|
+
"""Raised when a category or tag fails normalization."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def normalize_category(value: str | None) -> str:
|
|
52
|
+
"""Return a validated category slug, defaulting to ``other``."""
|
|
53
|
+
if value is None:
|
|
54
|
+
return DEFAULT_CATEGORY
|
|
55
|
+
slug = value.strip().lower()
|
|
56
|
+
if not slug:
|
|
57
|
+
return DEFAULT_CATEGORY
|
|
58
|
+
if slug not in CATEGORY_SLUGS:
|
|
59
|
+
raise TaxonomyValidationError(f"Unknown category: {value}")
|
|
60
|
+
return slug
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize_tag(value: str) -> str:
|
|
64
|
+
"""Normalize a single tag slug and reject reserved/invalid labels."""
|
|
65
|
+
if value is None:
|
|
66
|
+
raise TaxonomyValidationError("Invalid tag after normalization: None")
|
|
67
|
+
tag = value.strip().lower()
|
|
68
|
+
tag = tag.replace(" ", "-").replace("_", "-")
|
|
69
|
+
# Collapse repeated hyphens that the above may have introduced.
|
|
70
|
+
tag = re.sub(r"-+", "-", tag)
|
|
71
|
+
tag = tag.strip("-")
|
|
72
|
+
if not tag:
|
|
73
|
+
raise TaxonomyValidationError("Invalid tag after normalization: empty")
|
|
74
|
+
if tag in RESERVED_TAG_SLUGS:
|
|
75
|
+
raise TaxonomyValidationError(f"Reserved tag is not allowed: {tag}")
|
|
76
|
+
if not TAG_RE.match(tag):
|
|
77
|
+
raise TaxonomyValidationError(
|
|
78
|
+
f"Invalid tag after normalization: {tag}"
|
|
79
|
+
)
|
|
80
|
+
return tag
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def normalize_tags(
|
|
84
|
+
values: Sequence[str],
|
|
85
|
+
*,
|
|
86
|
+
max_count: int = 20,
|
|
87
|
+
) -> list[str]:
|
|
88
|
+
"""Normalize a sequence of tags, preserving first-seen order.
|
|
89
|
+
|
|
90
|
+
Duplicates (after normalization) collapse. Raises if the normalized
|
|
91
|
+
set exceeds *max_count* or if any tag is reserved/invalid.
|
|
92
|
+
"""
|
|
93
|
+
seen: set[str] = set()
|
|
94
|
+
result: list[str] = []
|
|
95
|
+
for raw in values:
|
|
96
|
+
tag = normalize_tag(raw)
|
|
97
|
+
if tag in seen:
|
|
98
|
+
continue
|
|
99
|
+
seen.add(tag)
|
|
100
|
+
result.append(tag)
|
|
101
|
+
if len(result) > max_count:
|
|
102
|
+
raise TaxonomyValidationError(
|
|
103
|
+
f"Too many tags: {len(result)} (max {max_count})"
|
|
104
|
+
)
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def tag_search_tokens(tag: str) -> set[str]:
|
|
109
|
+
"""Return the full normalized tag plus each hyphen segment.
|
|
110
|
+
|
|
111
|
+
``pr-review`` yields ``{"pr-review", "pr", "review"}``. The backend
|
|
112
|
+
uses these segments for relevance-search eligibility (not for tag
|
|
113
|
+
filters, which are prefix-only); the CLI mirrors it for local
|
|
114
|
+
suggestion expansion.
|
|
115
|
+
"""
|
|
116
|
+
normalized = normalize_tag(tag)
|
|
117
|
+
segments = normalized.split("-")
|
|
118
|
+
tokens: set[str] = {normalized}
|
|
119
|
+
tokens.update(seg for seg in segments if seg)
|
|
120
|
+
return tokens
|
cli/_update_policy.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Update policy gates for ``logion skills update``.
|
|
3
|
+
|
|
4
|
+
Compares the locally installed manifest against a candidate remote
|
|
5
|
+
manifest and decides whether the upgrade can be applied silently or
|
|
6
|
+
must require explicit user approval.
|
|
7
|
+
|
|
8
|
+
The plan calls out a closed set of manifest fields that, if they
|
|
9
|
+
change between local and remote, require approval:
|
|
10
|
+
|
|
11
|
+
- ``required_tools`` — the skill claims a new tool capability
|
|
12
|
+
- ``permissions`` — declared OS / network / credential scopes
|
|
13
|
+
- ``env_vars`` — environment variables the skill reads
|
|
14
|
+
- ``execution_policy`` — auto-run vs. approval-required, danger flags
|
|
15
|
+
|
|
16
|
+
Pricing is *not* gated here: the entitlement model grants the buyer
|
|
17
|
+
ongoing access to the course (all versions) once purchased, so a
|
|
18
|
+
seller raising the list price does not re-bill an existing user. We
|
|
19
|
+
surface a price change as an informational notice (``reasons``) but
|
|
20
|
+
do not force approval on its account.
|
|
21
|
+
|
|
22
|
+
``evaluate_update`` layers ``verify_installed_content`` on top of the
|
|
23
|
+
base policy: a locally modified install always blocks silent
|
|
24
|
+
overwrite, even when the manifest diff alone would have been safe.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from cli._local_state import verify_installed_content
|
|
34
|
+
|
|
35
|
+
# Fields that force ``requires_approval`` when local ≠ remote.
|
|
36
|
+
GATED_FIELDS: tuple[str, ...] = (
|
|
37
|
+
"required_tools",
|
|
38
|
+
"permissions",
|
|
39
|
+
"env_vars",
|
|
40
|
+
"execution_policy",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Fields surfaced as notices but that do NOT force approval.
|
|
44
|
+
NOTICE_FIELDS: tuple[str, ...] = (
|
|
45
|
+
"price_cents_at_install",
|
|
46
|
+
"currency",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class UpdatePolicyResult:
|
|
52
|
+
"""Outcome of comparing a local manifest to a remote manifest."""
|
|
53
|
+
|
|
54
|
+
applicable: bool = False
|
|
55
|
+
requires_approval: bool = False
|
|
56
|
+
blocks_silent_overwrite: bool = False
|
|
57
|
+
reasons: list[str] = field(default_factory=list)
|
|
58
|
+
notices: list[str] = field(default_factory=list)
|
|
59
|
+
changed_fields: list[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"applicable": self.applicable,
|
|
64
|
+
"requires_approval": self.requires_approval,
|
|
65
|
+
"blocks_silent_overwrite": self.blocks_silent_overwrite,
|
|
66
|
+
"reasons": list(self.reasons),
|
|
67
|
+
"notices": list(self.notices),
|
|
68
|
+
"changed_fields": list(self.changed_fields),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _normalize(value: Any) -> Any:
|
|
73
|
+
"""Make values comparable: lists become sorted tuples; dicts sort keys."""
|
|
74
|
+
if isinstance(value, list):
|
|
75
|
+
return tuple(sorted((_normalize(v) for v in value), key=repr))
|
|
76
|
+
if isinstance(value, dict):
|
|
77
|
+
return tuple(sorted((k, _normalize(v)) for k, v in value.items()))
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_update_policy(
|
|
82
|
+
local_manifest: dict[str, Any],
|
|
83
|
+
remote_manifest: dict[str, Any],
|
|
84
|
+
) -> UpdatePolicyResult:
|
|
85
|
+
"""Diff gated manifest fields and return an :class:`UpdatePolicyResult`.
|
|
86
|
+
|
|
87
|
+
The result is *only* about the manifest diff; it does not look at
|
|
88
|
+
on-disk content. Use :func:`evaluate_update` to add the
|
|
89
|
+
user-modification check.
|
|
90
|
+
"""
|
|
91
|
+
result = UpdatePolicyResult()
|
|
92
|
+
|
|
93
|
+
local_version = local_manifest.get("version_id")
|
|
94
|
+
remote_version = remote_manifest.get("version_id")
|
|
95
|
+
if remote_version and remote_version != local_version:
|
|
96
|
+
result.applicable = True
|
|
97
|
+
|
|
98
|
+
content_changed = local_manifest.get(
|
|
99
|
+
"content_sha256"
|
|
100
|
+
) != remote_manifest.get("content_sha256")
|
|
101
|
+
if content_changed:
|
|
102
|
+
result.applicable = True
|
|
103
|
+
|
|
104
|
+
for field_name in GATED_FIELDS:
|
|
105
|
+
if _normalize(local_manifest.get(field_name)) != _normalize(
|
|
106
|
+
remote_manifest.get(field_name)
|
|
107
|
+
):
|
|
108
|
+
result.changed_fields.append(field_name)
|
|
109
|
+
result.requires_approval = True
|
|
110
|
+
result.reasons.append(
|
|
111
|
+
f"{field_name} changed between installed and remote manifest"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
for field_name in NOTICE_FIELDS:
|
|
115
|
+
if _normalize(local_manifest.get(field_name)) != _normalize(
|
|
116
|
+
remote_manifest.get(field_name)
|
|
117
|
+
):
|
|
118
|
+
result.notices.append(
|
|
119
|
+
f"{field_name} changed (informational; existing entitlement "
|
|
120
|
+
"is not re-billed)"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def evaluate_update(
|
|
127
|
+
course_id: str,
|
|
128
|
+
version_id: str,
|
|
129
|
+
remote_manifest: dict[str, Any],
|
|
130
|
+
local_manifest: dict[str, Any],
|
|
131
|
+
home: Path | None = None,
|
|
132
|
+
) -> UpdatePolicyResult:
|
|
133
|
+
"""Combine :func:`check_update_policy` with content verification.
|
|
134
|
+
|
|
135
|
+
If the installed files have been modified locally (i.e.
|
|
136
|
+
``verify_installed_content`` reports ``user_modified=True``), the
|
|
137
|
+
update is forced to require approval *and*
|
|
138
|
+
``blocks_silent_overwrite`` is set, regardless of how clean the
|
|
139
|
+
manifest diff was.
|
|
140
|
+
"""
|
|
141
|
+
policy = check_update_policy(local_manifest, remote_manifest)
|
|
142
|
+
|
|
143
|
+
verification = verify_installed_content(course_id, version_id, home)
|
|
144
|
+
if verification["user_modified"]:
|
|
145
|
+
policy.requires_approval = True
|
|
146
|
+
policy.blocks_silent_overwrite = True
|
|
147
|
+
policy.reasons.append(
|
|
148
|
+
"installed content differs from manifest hash "
|
|
149
|
+
"(user modification detected)"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return policy
|
cli/_utils.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""CLI utility helpers — generic kwarg building, etc."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def only_not_none(
|
|
10
|
+
base: dict[str, Any],
|
|
11
|
+
**optional: Any,
|
|
12
|
+
) -> dict[str, Any]:
|
|
13
|
+
"""Return *base* merged with only the non-None *optional* entries."""
|
|
14
|
+
result = dict(base)
|
|
15
|
+
result.update({k: v for k, v in optional.items() if v is not None})
|
|
16
|
+
return result
|
cli/_version.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Runtime-accessible package version.
|
|
3
|
+
|
|
4
|
+
The source of truth is the wheel's metadata; this module reads it
|
|
5
|
+
via importlib.metadata so editable installs and built wheels report
|
|
6
|
+
the same string. When the package is not installed (e.g. running
|
|
7
|
+
from a checkout without uv sync), falls back to reading
|
|
8
|
+
pyproject.toml directly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from importlib.metadata import PackageNotFoundError
|
|
14
|
+
from importlib.metadata import version as _pkg_version
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
__version__: str = _pkg_version("logion-cli")
|
|
18
|
+
except PackageNotFoundError: # local checkout / sdist
|
|
19
|
+
import tomllib
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
|
23
|
+
with _pyproject.open("rb") as _f:
|
|
24
|
+
__version__ = tomllib.load(_f)["project"]["version"]
|
|
25
|
+
|
|
26
|
+
__all__ = ["__version__"]
|
cli/commands/__init__.py
ADDED