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
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__"]
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """CLI command modules."""