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/_local_state.py ADDED
@@ -0,0 +1,1053 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Local installation state layout and operations.
3
+
4
+ Manages the ~/.logion/ directory structure, manifest files, compact
5
+ index, recall index, lockfile, and workflow history for the Logion
6
+ Marketplace Companion.
7
+
8
+ State files use a top-level envelope::
9
+
10
+ {"schema_version": 1, "entries": [...]}
11
+
12
+ Reads accept the legacy bare-list form for forward compatibility.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import datetime
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import re
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Layout
28
+ # ---------------------------------------------------------------------------
29
+
30
+ DEFAULT_HOME = Path.home() / ".logion"
31
+
32
+ SCHEMA_VERSION = 1
33
+
34
+ # Identifiers (course_id, version_id) become path segments and lock
35
+ # filenames. Restrict to a safe character set and reject traversal
36
+ # sequences before they reach the filesystem.
37
+ _SAFE_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
38
+
39
+
40
+ class UnsafeIdentifierError(ValueError):
41
+ """Raised when course_id/version_id contains unsafe characters."""
42
+
43
+
44
+ def _safe_segment(value: str, kind: str = "identifier") -> str:
45
+ """Validate *value* as a safe single-segment identifier.
46
+
47
+ Rejects path separators, ``..``, leading dot, control characters,
48
+ empty strings, and anything outside ``[A-Za-z0-9._-]``. Returns the
49
+ value unchanged so callers can use it inline.
50
+ """
51
+ if not isinstance(value, str) or not _SAFE_SEGMENT_RE.fullmatch(value):
52
+ raise UnsafeIdentifierError(
53
+ f"unsafe {kind}: {value!r} (must match [A-Za-z0-9._-], "
54
+ "max 128 chars, no path separators or '..')"
55
+ )
56
+ if value in (".", "..") or ".." in value:
57
+ raise UnsafeIdentifierError(f"unsafe {kind}: {value!r}")
58
+ return value
59
+
60
+
61
+ def get_home() -> Path:
62
+ """Return the Logion home directory (LOGION_HOME override or default)."""
63
+ env = os.environ.get("LOGION_HOME")
64
+ return Path(env) if env else DEFAULT_HOME
65
+
66
+
67
+ def ensure_layout(home: Path | None = None) -> Path:
68
+ """Create the directory layout under *home* if it does not exist."""
69
+ h = home or get_home()
70
+ (h / "installed").mkdir(parents=True, exist_ok=True)
71
+ return h
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Envelope helpers
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def _wrap(entries: list[dict[str, Any]]) -> dict[str, Any]:
80
+ return {"schema_version": SCHEMA_VERSION, "entries": entries}
81
+
82
+
83
+ def _unwrap(raw: Any) -> list[dict[str, Any]]:
84
+ """Return entries from envelope or legacy bare-list form."""
85
+ if isinstance(raw, list):
86
+ return raw
87
+ if isinstance(raw, dict) and isinstance(raw.get("entries"), list):
88
+ return raw["entries"]
89
+ return []
90
+
91
+
92
+ def _read_json_entries(path: Path) -> list[dict[str, Any]]:
93
+ if not path.is_file():
94
+ return []
95
+ try:
96
+ return _unwrap(json.loads(path.read_text(encoding="utf-8")))
97
+ except (json.JSONDecodeError, OSError):
98
+ return []
99
+
100
+
101
+ def _atomic_write_text(path: Path, text: str) -> None:
102
+ """Write *text* to *path* atomically (tmp file + rename).
103
+
104
+ Prevents truncated/half-written JSON if the process is interrupted
105
+ mid-write; readers either see the previous content or the new
106
+ content, never a partial buffer.
107
+ """
108
+ path.parent.mkdir(parents=True, exist_ok=True)
109
+ tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
110
+ try:
111
+ tmp.write_text(text, encoding="utf-8")
112
+ tmp.replace(path)
113
+ finally:
114
+ if tmp.exists():
115
+ with contextlib.suppress(OSError):
116
+ tmp.unlink()
117
+
118
+
119
+ def _write_json_entries(path: Path, entries: list[dict[str, Any]]) -> Path:
120
+ _atomic_write_text(
121
+ path,
122
+ json.dumps(_wrap(entries), indent=2, ensure_ascii=False) + "\n",
123
+ )
124
+ return path
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Secret masking
129
+ # ---------------------------------------------------------------------------
130
+
131
+ SECRET_KEY_PATTERNS: tuple[re.Pattern[str], ...] = (
132
+ re.compile(r"api[_-]?key", re.IGNORECASE),
133
+ re.compile(r"secret", re.IGNORECASE),
134
+ re.compile(r"token", re.IGNORECASE),
135
+ re.compile(r"password|passwd", re.IGNORECASE),
136
+ re.compile(r"credential", re.IGNORECASE),
137
+ re.compile(r"bearer", re.IGNORECASE),
138
+ re.compile(r"^auth$|_auth$|auth_", re.IGNORECASE),
139
+ )
140
+
141
+ MASK_PLACEHOLDER = "***MASKED***"
142
+
143
+ # String-level redaction patterns for command-line secrets. Applied to
144
+ # free-text fields (notably workflow ``commands``) before they hit
145
+ # recall.json, because key-name masking alone cannot catch a secret
146
+ # embedded in a positional argument like ``curl -H "Authorization:
147
+ # Bearer ghp_..."`` or ``./deploy --token=abc123``.
148
+ COMMAND_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
149
+ # Authorization: Bearer <token> / Authorization: Basic <b64>
150
+ re.compile(
151
+ r"(?i)(authorization:\s*(?:bearer|basic|token)\s+)\S+",
152
+ ),
153
+ # --token=..., --api-key=..., --password=..., -p=...
154
+ re.compile(
155
+ r"(?i)(--(?:api[_-]?key|token|password|passwd|secret|"
156
+ r"credential|bearer|auth)[=\s]+)(?:\"[^\"]+\"|'[^']+'|\S+)",
157
+ ),
158
+ # KEY=value style env exports for known secret env names
159
+ re.compile(
160
+ r"(?i)\b((?:AWS_SECRET_ACCESS_KEY|GITHUB_TOKEN|API_KEY|"
161
+ r"BEARER_TOKEN|PASSWORD)=)\S+",
162
+ ),
163
+ # Common token shapes (GitHub, Stripe, OpenAI, generic JWT-ish)
164
+ re.compile(r"\b(gh[pousr]_[A-Za-z0-9]{20,})\b"),
165
+ re.compile(r"\b(sk-[A-Za-z0-9-]{16,})\b"),
166
+ re.compile(
167
+ r"\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+)\b"
168
+ ),
169
+ )
170
+
171
+
172
+ def mask_command_string(value: str) -> str:
173
+ """Redact common command-line secret shapes from *value*.
174
+
175
+ Used on workflow ``commands`` before they are written to
176
+ recall.json so a captured shell line like
177
+ ``curl -H 'Authorization: Bearer ghp_abc' …`` does not persist
178
+ the bearer token unmasked. Pattern matching is conservative —
179
+ over-masking is preferred to under-masking.
180
+ """
181
+ for pat in COMMAND_SECRET_PATTERNS:
182
+ # Each pattern either has one capture group (prefix to keep) or
183
+ # captures the whole match; mask everything after the prefix.
184
+ def _sub(m: re.Match[str]) -> str:
185
+ if m.groups():
186
+ return f"{m.group(1)}{MASK_PLACEHOLDER}"
187
+ return MASK_PLACEHOLDER
188
+
189
+ value = pat.sub(_sub, value)
190
+ return value
191
+
192
+
193
+ def _looks_like_secret_key(key: str) -> bool:
194
+ return any(p.search(key) for p in SECRET_KEY_PATTERNS)
195
+
196
+
197
+ def mask_secrets(data: dict[str, Any]) -> dict[str, Any]:
198
+ """Return a copy of *data* with secret-like fields masked.
199
+
200
+ Walks nested dicts and lists. String values under keys that match
201
+ :data:`SECRET_KEY_PATTERNS` are replaced with
202
+ :data:`MASK_PLACEHOLDER`. Non-string values under those keys are
203
+ also masked.
204
+ """
205
+ return _mask_value(data) # type: ignore[return-value]
206
+
207
+
208
+ def _mask_value(value: Any, parent_key: str | None = None) -> Any:
209
+ if parent_key is not None and _looks_like_secret_key(parent_key):
210
+ return MASK_PLACEHOLDER
211
+ if isinstance(value, dict):
212
+ return {k: _mask_value(v, parent_key=k) for k, v in value.items()}
213
+ if isinstance(value, list):
214
+ return [_mask_value(item, parent_key=parent_key) for item in value]
215
+ return value
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Manifest
220
+ # ---------------------------------------------------------------------------
221
+
222
+ REQUIRED_MANIFEST_KEYS = frozenset({
223
+ "course_id",
224
+ "version_id",
225
+ "title",
226
+ "source",
227
+ "installed_at",
228
+ "entrypoint",
229
+ "capabilities",
230
+ "required_tools",
231
+ "content_sha256",
232
+ "review_status",
233
+ "entitlement_status",
234
+ "license_scope",
235
+ "official_update_channel",
236
+ "last_verified_at",
237
+ "manifest_path",
238
+ })
239
+
240
+ VALID_SOURCES = frozenset({"logion-marketplace", "mirror", "manual"})
241
+
242
+ VALID_ENTITLEMENT_STATUSES = frozenset({
243
+ "active",
244
+ "missing",
245
+ "expired",
246
+ "unknown",
247
+ })
248
+
249
+ VALID_LICENSE_SCOPES = frozenset({
250
+ "single-buyer",
251
+ "team",
252
+ "open",
253
+ "unknown",
254
+ })
255
+
256
+
257
+ def normalize_source(value: Any) -> str:
258
+ """Normalize persisted source values to the public enum."""
259
+ if value == "logion":
260
+ return "logion-marketplace"
261
+ if isinstance(value, str) and value in VALID_SOURCES:
262
+ return value
263
+ return "manual"
264
+
265
+
266
+ def normalize_license_scope(value: Any) -> str:
267
+ """Normalize persisted license scope values to the public enum."""
268
+ if value == "single_buyer":
269
+ return "single-buyer"
270
+ if isinstance(value, str) and value in VALID_LICENSE_SCOPES:
271
+ return value
272
+ return "unknown"
273
+
274
+
275
+ def enrich_manifest(
276
+ manifest: dict[str, Any],
277
+ course_id: str,
278
+ version_id: str,
279
+ home: Path | None = None,
280
+ ) -> dict[str, Any]:
281
+ """Return *manifest* with normalized provenance and manifest_path."""
282
+ h = home or get_home()
283
+ path = (
284
+ installed_dir(course_id, version_id, h) / "manifest.json"
285
+ ).resolve()
286
+ data = dict(manifest)
287
+ source = normalize_source(data.get("source"))
288
+ data["course_id"] = course_id
289
+ data["version_id"] = version_id
290
+ data["source"] = source
291
+ data["entitlement_status"] = data.get("entitlement_status", "unknown")
292
+ data["license_scope"] = normalize_license_scope(data.get("license_scope"))
293
+ data["official_update_channel"] = bool(
294
+ data.get("official_update_channel", source == "logion-marketplace")
295
+ )
296
+ data.setdefault("entrypoint", "SKILL.md")
297
+ data.setdefault("last_verified_at", None)
298
+ data["manifest_path"] = str(path)
299
+ return data
300
+
301
+
302
+ def validate_manifest(data: dict[str, Any]) -> list[str]:
303
+ """Return validation errors for *data*; empty list means valid."""
304
+ errors: list[str] = []
305
+ missing = REQUIRED_MANIFEST_KEYS - set(data.keys())
306
+ for key in sorted(missing):
307
+ errors.append(f"manifest missing required key: {key}")
308
+ if "course_id" in data and not isinstance(data["course_id"], str):
309
+ errors.append("course_id must be a string")
310
+ if "version_id" in data and not isinstance(data["version_id"], str):
311
+ errors.append("version_id must be a string")
312
+ if "capabilities" in data and not isinstance(data["capabilities"], list):
313
+ errors.append("capabilities must be a list")
314
+ if "required_tools" in data and not isinstance(
315
+ data["required_tools"], list
316
+ ):
317
+ errors.append("required_tools must be a list")
318
+ if "source" in data and data["source"] not in VALID_SOURCES:
319
+ errors.append(f"source must be one of {sorted(VALID_SOURCES)}")
320
+ if (
321
+ "entitlement_status" in data
322
+ and data["entitlement_status"] not in VALID_ENTITLEMENT_STATUSES
323
+ ):
324
+ errors.append(
325
+ f"entitlement_status must be one of "
326
+ f"{sorted(VALID_ENTITLEMENT_STATUSES)}"
327
+ )
328
+ if (
329
+ "license_scope" in data
330
+ and data["license_scope"] not in VALID_LICENSE_SCOPES
331
+ ):
332
+ errors.append(
333
+ f"license_scope must be one of {sorted(VALID_LICENSE_SCOPES)}"
334
+ )
335
+ if "official_update_channel" in data and not isinstance(
336
+ data["official_update_channel"], bool
337
+ ):
338
+ errors.append("official_update_channel must be a bool")
339
+ if "manifest_path" in data and not isinstance(data["manifest_path"], str):
340
+ errors.append("manifest_path must be an absolute path string")
341
+ if (
342
+ "last_verified_at" in data
343
+ and data["last_verified_at"] is not None
344
+ and not isinstance(data["last_verified_at"], str)
345
+ ):
346
+ errors.append("last_verified_at must be an ISO 8601 string or null")
347
+ return errors
348
+
349
+
350
+ _HASH_CHUNK = 64 * 1024 # 64 KiB — keeps peak memory bounded for big files.
351
+
352
+
353
+ def sha256_of_files(
354
+ paths: list[Path],
355
+ root: Path | None = None,
356
+ ) -> str:
357
+ """SHA-256 over *paths* including each file's relative path.
358
+
359
+ Reads each file in :data:`_HASH_CHUNK`-sized chunks so peak memory
360
+ stays bounded regardless of file size — important for
361
+ ``verify_installed_content`` running across large installed
362
+ bundles. Each file contributes
363
+ ``<rel_path>\\0<length>\\0<bytes>\\0`` so a rename, repartition, or
364
+ reordering changes the digest. When *root* is provided, paths are
365
+ taken relative to it; otherwise the file name alone is used.
366
+ """
367
+ h = hashlib.sha256()
368
+ for p in sorted(paths):
369
+ if root is not None:
370
+ try:
371
+ rel = p.relative_to(root).as_posix()
372
+ except ValueError:
373
+ rel = p.name
374
+ else:
375
+ rel = p.name
376
+ size = p.stat().st_size
377
+ h.update(rel.encode("utf-8"))
378
+ h.update(b"\0")
379
+ h.update(str(size).encode("ascii"))
380
+ h.update(b"\0")
381
+ with p.open("rb") as fh:
382
+ while True:
383
+ chunk = fh.read(_HASH_CHUNK)
384
+ if not chunk:
385
+ break
386
+ h.update(chunk)
387
+ h.update(b"\0")
388
+ return h.hexdigest()
389
+
390
+
391
+ def _installed_files(version_dir: Path) -> list[Path]:
392
+ """Return on-disk files for an installed version, excluding manifest."""
393
+ return sorted(
394
+ p
395
+ for p in version_dir.rglob("*")
396
+ if p.is_file() and p.name != "manifest.json"
397
+ )
398
+
399
+
400
+ def installed_dir(course_id: str, version_id: str, home: Path) -> Path:
401
+ """Resolve the install path with sanitized identifiers.
402
+
403
+ Rejects path-traversal in *course_id* / *version_id* and guarantees
404
+ the result stays under ``home/installed/``.
405
+ """
406
+ _safe_segment(course_id, "course_id")
407
+ _safe_segment(version_id, "version_id")
408
+ root = (home / "installed").resolve()
409
+ dest = (root / course_id / version_id).resolve()
410
+ # Defence in depth: even with the regex above, confirm the resolved
411
+ # path did not escape the installed root.
412
+ try:
413
+ dest.relative_to(root)
414
+ except ValueError as exc:
415
+ raise UnsafeIdentifierError(
416
+ f"resolved install path escapes home: {dest}"
417
+ ) from exc
418
+ return dest
419
+
420
+
421
+ def write_manifest(
422
+ manifest: dict[str, Any],
423
+ course_id: str,
424
+ version_id: str,
425
+ home: Path | None = None,
426
+ ) -> Path:
427
+ """Write *manifest* to the installed capability directory."""
428
+ h = home or get_home()
429
+ dest = installed_dir(course_id, version_id, h)
430
+ dest.mkdir(parents=True, exist_ok=True)
431
+ path = dest / "manifest.json"
432
+ normalized_manifest = enrich_manifest(manifest, course_id, version_id, h)
433
+ _atomic_write_text(
434
+ path,
435
+ json.dumps(normalized_manifest, indent=2, ensure_ascii=False) + "\n",
436
+ )
437
+ return path
438
+
439
+
440
+ def read_manifest(
441
+ course_id: str,
442
+ version_id: str,
443
+ home: Path | None = None,
444
+ ) -> dict[str, Any] | None:
445
+ """Read an installed capability's manifest, or ``None`` if absent."""
446
+ h = home or get_home()
447
+ try:
448
+ dest = installed_dir(course_id, version_id, h)
449
+ except UnsafeIdentifierError:
450
+ return None
451
+ path = dest / "manifest.json"
452
+ if not path.is_file():
453
+ return None
454
+ try:
455
+ raw = json.loads(path.read_text(encoding="utf-8"))
456
+ except (json.JSONDecodeError, OSError):
457
+ return None
458
+ return enrich_manifest(raw, course_id, version_id, h)
459
+
460
+
461
+ def list_installed(home: Path | None = None) -> list[dict[str, Any]]:
462
+ """Return manifest dicts for all installed capabilities."""
463
+ h = home or get_home()
464
+ installed_root = h / "installed"
465
+ if not installed_root.is_dir():
466
+ return []
467
+ results: list[dict[str, Any]] = []
468
+ for course_dir in sorted(installed_root.iterdir()):
469
+ if not course_dir.is_dir():
470
+ continue
471
+ for version_dir in sorted(course_dir.iterdir()):
472
+ manifest_path = version_dir / "manifest.json"
473
+ if not manifest_path.is_file():
474
+ continue
475
+ try:
476
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
477
+ except (json.JSONDecodeError, OSError):
478
+ continue
479
+ results.append(
480
+ enrich_manifest(data, course_dir.name, version_dir.name, h)
481
+ )
482
+ return results
483
+
484
+
485
+ def compute_installed_hash(
486
+ course_id: str,
487
+ version_id: str,
488
+ home: Path | None = None,
489
+ ) -> str:
490
+ """Re-hash on-disk content for an installed version."""
491
+ h = home or get_home()
492
+ try:
493
+ version_dir = installed_dir(course_id, version_id, h)
494
+ except UnsafeIdentifierError:
495
+ return ""
496
+ if not version_dir.is_dir():
497
+ return ""
498
+ return sha256_of_files(_installed_files(version_dir), root=version_dir)
499
+
500
+
501
+ def verify_installed_content(
502
+ course_id: str,
503
+ version_id: str,
504
+ home: Path | None = None,
505
+ ) -> dict[str, Any]:
506
+ """Compare manifest ``content_sha256`` to on-disk hash.
507
+
508
+ Returns a dict with ``ok`` (bool), ``expected``, ``actual``, and
509
+ ``user_modified`` (True when hashes diverge). Missing manifest
510
+ yields ``ok=False`` with empty hashes.
511
+ """
512
+ manifest = read_manifest(course_id, version_id, home)
513
+ expected = (manifest or {}).get("content_sha256", "")
514
+ actual = compute_installed_hash(course_id, version_id, home)
515
+ ok = bool(expected) and expected == actual
516
+ return {
517
+ "ok": ok,
518
+ "expected": expected,
519
+ "actual": actual,
520
+ "user_modified": bool(expected) and expected != actual,
521
+ }
522
+
523
+
524
+ # ---------------------------------------------------------------------------
525
+ # Index
526
+ # ---------------------------------------------------------------------------
527
+
528
+ INDEX_FILENAME = "index.json"
529
+
530
+
531
+ def build_index(home: Path | None = None) -> list[dict[str, Any]]:
532
+ """Build the compact index from installed manifests (no skill bodies)."""
533
+ manifests = list_installed(home)
534
+ index: list[dict[str, Any]] = []
535
+ for m in manifests:
536
+ index.append({
537
+ "course_id": m.get("course_id", ""),
538
+ "version_id": m.get("version_id", ""),
539
+ "title": m.get("title", ""),
540
+ "source": normalize_source(m.get("source")),
541
+ "entrypoint": m.get("entrypoint", "SKILL.md"),
542
+ "capabilities": m.get("capabilities", []),
543
+ "required_tools": m.get("required_tools", []),
544
+ "review_status": m.get("review_status", ""),
545
+ "entitlement_status": m.get("entitlement_status", "unknown"),
546
+ "license_scope": normalize_license_scope(
547
+ m.get("license_scope", "unknown")
548
+ ),
549
+ "official_update_channel": m.get("official_update_channel", False),
550
+ "last_verified_at": m.get("last_verified_at"),
551
+ "manifest_path": m.get("manifest_path", ""),
552
+ })
553
+ return index
554
+
555
+
556
+ def write_index(
557
+ index: list[dict[str, Any]],
558
+ home: Path | None = None,
559
+ ) -> Path:
560
+ """Write the compact index to ``index.json`` (enveloped)."""
561
+ h = home or get_home()
562
+ ensure_layout(h)
563
+ with state_lock(h):
564
+ return _write_json_entries(h / INDEX_FILENAME, index)
565
+
566
+
567
+ def read_index(home: Path | None = None) -> list[dict[str, Any]]:
568
+ """Read the compact index; empty list if absent."""
569
+ h = home or get_home()
570
+ return _read_json_entries(h / INDEX_FILENAME)
571
+
572
+
573
+ # ---------------------------------------------------------------------------
574
+ # Recall
575
+ # ---------------------------------------------------------------------------
576
+
577
+ RECALL_FILENAME = "recall.json"
578
+
579
+ # Closed enum of danger flags surfaced by recall. Word-boundary regex
580
+ # avoids false positives like "rm-safe-helper" or "removeListener".
581
+ DANGER_FLAG_PATTERNS: dict[str, re.Pattern[str]] = {
582
+ "fs_destructive": re.compile(
583
+ r"\b(rm|rmdir|unlink|shred|mkfs|dd)\b|--force\b|\bdrop\s+table\b",
584
+ re.IGNORECASE,
585
+ ),
586
+ "privilege_escalation": re.compile(
587
+ r"\b(sudo|doas|su)\b",
588
+ re.IGNORECASE,
589
+ ),
590
+ "network_exec": re.compile(
591
+ r"(curl|wget)[^|]*\|\s*(sh|bash|zsh)\b",
592
+ re.IGNORECASE,
593
+ ),
594
+ "shell_eval": re.compile(
595
+ r"\b(eval|exec)\b",
596
+ re.IGNORECASE,
597
+ ),
598
+ "permission_change": re.compile(
599
+ r"\b(chmod|chown)\b",
600
+ re.IGNORECASE,
601
+ ),
602
+ }
603
+
604
+ DANGER_FLAGS: frozenset[str] = frozenset(DANGER_FLAG_PATTERNS)
605
+
606
+
607
+ def detect_danger_flags(commands: list[str] | None) -> list[str]:
608
+ """Return sorted danger flags present in *commands*."""
609
+ if not commands:
610
+ return []
611
+ found: set[str] = set()
612
+ for cmd in commands:
613
+ for flag, pattern in DANGER_FLAG_PATTERNS.items():
614
+ if pattern.search(cmd):
615
+ found.add(flag)
616
+ return sorted(found)
617
+
618
+
619
+ def _recall_tokens(*parts: str) -> list[str]:
620
+ """Return compact searchable tokens for recall ranking."""
621
+ tokens: list[str] = []
622
+ for part in parts:
623
+ text = str(part or "").strip()
624
+ if not text:
625
+ continue
626
+ tokens.extend(segment for segment in re.split(r"\s+", text) if segment)
627
+ return tokens
628
+
629
+
630
+ def build_recall_entries(
631
+ installed: list[dict[str, Any]],
632
+ workflows: list[dict[str, Any]] | None = None,
633
+ ) -> list[dict[str, Any]]:
634
+ """Build compact recall entries with provenance and secret masking."""
635
+ entries: list[dict[str, Any]] = []
636
+
637
+ for m in installed:
638
+ entry = {
639
+ "type": "installed_capability",
640
+ "id": m.get("course_id", ""),
641
+ "title": m.get("title", ""),
642
+ "summary": (m.get("summary", "") or "")[:200],
643
+ "source": "installed_index",
644
+ "entrypoint": (
645
+ f"installed/{m.get('course_id', '')}/"
646
+ f"{m.get('version_id', '')}/"
647
+ f"{m.get('entrypoint', 'SKILL.md')}"
648
+ ),
649
+ "tokens": _recall_tokens(
650
+ str(m.get("course_id", "")),
651
+ str(m.get("version_id", "")),
652
+ str(m.get("entrypoint", "SKILL.md")),
653
+ ),
654
+ "danger_flags": [],
655
+ "entitlement_status": m.get("entitlement_status", "unknown"),
656
+ }
657
+ entries.append(mask_secrets(entry))
658
+
659
+ if workflows:
660
+ for w in workflows:
661
+ raw_commands = w.get("commands", []) or []
662
+ # Danger-flag detection runs on the *original* string so a
663
+ # token like ``--token=…`` does not get masked into
664
+ # invisibility before the regex sees it. Persisted
665
+ # commands are the redacted form.
666
+ danger_flags = detect_danger_flags(raw_commands)
667
+ redacted_commands = [
668
+ mask_command_string(c) if isinstance(c, str) else c
669
+ for c in raw_commands
670
+ ]
671
+ entry = {
672
+ "type": "workflow",
673
+ "id": w.get("id", ""),
674
+ "title": w.get("title", ""),
675
+ "summary": (w.get("summary", "") or "")[:200],
676
+ "confidence": float(w.get("confidence", 0.5)),
677
+ "source": "workflow_history",
678
+ "commands": redacted_commands,
679
+ "tokens": _recall_tokens(
680
+ str(w.get("id", "")),
681
+ *[str(command) for command in redacted_commands],
682
+ ),
683
+ "success_count": int(w.get("success_count", 0)),
684
+ "last_success_at": w.get("last_success_at", ""),
685
+ "danger_flags": danger_flags,
686
+ }
687
+ entries.append(mask_secrets(entry))
688
+
689
+ return entries
690
+
691
+
692
+ def write_recall(
693
+ entries: list[dict[str, Any]],
694
+ home: Path | None = None,
695
+ ) -> Path:
696
+ """Write recall index to ``recall.json`` (enveloped)."""
697
+ h = home or get_home()
698
+ ensure_layout(h)
699
+ with state_lock(h):
700
+ return _write_json_entries(h / RECALL_FILENAME, entries)
701
+
702
+
703
+ def read_recall(home: Path | None = None) -> list[dict[str, Any]]:
704
+ """Read recall entries; empty list if absent."""
705
+ h = home or get_home()
706
+ return _read_json_entries(h / RECALL_FILENAME)
707
+
708
+
709
+ def rebuild_recall(home: Path | None = None) -> Path:
710
+ """Refresh recall.json from current installed manifests and workflows."""
711
+ h = home or get_home()
712
+ with state_lock(h):
713
+ entries = build_recall_entries(list_installed(h), read_workflows(h))
714
+ return write_recall(entries, h)
715
+
716
+
717
+ def search_recall(
718
+ query: str,
719
+ home: Path | None = None,
720
+ limit: int = 5,
721
+ ) -> list[dict[str, Any]]:
722
+ """Search recall with fuzzy ranking and confidence calibration."""
723
+ from cli._recall_calibration import (
724
+ band_for,
725
+ calibrate_installed_confidence,
726
+ calibrate_workflow_confidence,
727
+ )
728
+ from cli._recall_ranker import rank
729
+
730
+ entries = read_recall(home)
731
+ if not entries or not query:
732
+ return []
733
+
734
+ ranked = rank(query, entries, limit=limit)
735
+ out: list[dict[str, Any]] = []
736
+ for similarity, entry in ranked:
737
+ if similarity < 0.20:
738
+ continue
739
+
740
+ entry_type = entry.get("type", "")
741
+ if entry_type == "installed_capability":
742
+ confidence = calibrate_installed_confidence(similarity)
743
+ elif entry_type == "workflow":
744
+ confidence = calibrate_workflow_confidence(
745
+ similarity,
746
+ entry.get("success_count", 0),
747
+ entry.get("last_success_at"),
748
+ )
749
+ elif entry_type == "reference":
750
+ confidence = 0.8 * similarity
751
+ elif entry_type == "project_command":
752
+ confidence = 0.9 * similarity
753
+ else:
754
+ confidence = similarity
755
+
756
+ band = band_for(confidence)
757
+ if band == "NONE":
758
+ continue
759
+
760
+ out.append({
761
+ **entry,
762
+ "confidence": round(confidence, 4),
763
+ "band": band,
764
+ "query_similarity": round(similarity, 4),
765
+ })
766
+
767
+ return out
768
+
769
+
770
+ # ---------------------------------------------------------------------------
771
+ # Lockfile (per course/version)
772
+ # ---------------------------------------------------------------------------
773
+
774
+ LOCKS_DIRNAME = "locks"
775
+ STATE_LOCK_FILENAME = ".state.lock"
776
+ STATE_LOCK_RETRY_DELAY_S = 0.05
777
+ STATE_LOCK_TIMEOUT_S = 5.0
778
+
779
+
780
+ # Re-entrancy: paths this process is currently holding state-locks on.
781
+ _STATE_LOCK_HELD: set[str] = set()
782
+
783
+
784
+ @contextlib.contextmanager
785
+ def state_lock(
786
+ home: Path | None = None,
787
+ timeout: float = STATE_LOCK_TIMEOUT_S,
788
+ ):
789
+ """Cross-process lock for global state files (index/recall/workflows).
790
+
791
+ Two concurrent ``logion skills install`` / ``logion recall record``
792
+ invocations otherwise race on ``index.json`` and ``recall.json`` —
793
+ each rebuilds from a different snapshot and the last writer wins.
794
+ This lock serializes those rebuilds via an ``O_EXCL`` lock file in
795
+ the Logion home directory. Acquisition retries with a short
796
+ sleep up to *timeout* seconds before raising ``TimeoutError``.
797
+
798
+ Re-entrant within a single process: if this process already holds
799
+ the lock for the same *home*, nested ``with state_lock(...)`` calls
800
+ are no-ops. Lets high-level routines (``record_workflow_success``,
801
+ ``copy_and_finalize``) lock once around a sequence of low-level
802
+ writers that each acquire the same lock defensively.
803
+
804
+ The lock is best-effort across processes: it is removed on normal
805
+ exit and on ``finally``; a process that crashes hard may leave a
806
+ stale lock that the next caller will eventually time out on.
807
+ """
808
+ import time
809
+
810
+ h = home or get_home()
811
+ h.mkdir(parents=True, exist_ok=True)
812
+ path = h / STATE_LOCK_FILENAME
813
+ key = str(path)
814
+ if key in _STATE_LOCK_HELD:
815
+ yield path
816
+ return
817
+ deadline = time.monotonic() + max(timeout, 0.0)
818
+ while True:
819
+ try:
820
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
821
+ break
822
+ except FileExistsError:
823
+ if time.monotonic() >= deadline:
824
+ raise TimeoutError(
825
+ f"state lock {path} held longer than {timeout}s"
826
+ ) from None
827
+ time.sleep(STATE_LOCK_RETRY_DELAY_S)
828
+ _STATE_LOCK_HELD.add(key)
829
+ try:
830
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
831
+ f.write(str(os.getpid()))
832
+ yield path
833
+ finally:
834
+ _STATE_LOCK_HELD.discard(key)
835
+ with contextlib.suppress(OSError):
836
+ path.unlink()
837
+
838
+
839
+ class LockHeldError(RuntimeError):
840
+ """Raised when ``acquire_lock`` finds an existing lock file."""
841
+
842
+ def __init__(self, course_id: str, version_id: str, path: Path) -> None:
843
+ super().__init__(
844
+ f"lock already held for {course_id}/{version_id} at {path}"
845
+ )
846
+ self.course_id = course_id
847
+ self.version_id = version_id
848
+ self.path = path
849
+
850
+
851
+ def _lock_path(home: Path, course_id: str, version_id: str) -> Path:
852
+ # Validate before composing the path. Locks live in nested
853
+ # directories (``locks/<course>/<version>.json``) so identifiers
854
+ # containing ``__`` (legal under _SAFE_SEGMENT_RE) cannot collide
855
+ # onto the same filename — e.g. ``a__b``/``c`` and ``a``/``b__c``
856
+ # used to flatten to the same file with a ``__`` separator.
857
+ _safe_segment(course_id, "course_id")
858
+ _safe_segment(version_id, "version_id")
859
+ return home / LOCKS_DIRNAME / course_id / f"{version_id}.json"
860
+
861
+
862
+ def acquire_lock(
863
+ course_id: str,
864
+ version_id: str,
865
+ home: Path | None = None,
866
+ ) -> Path:
867
+ """Acquire an install lock scoped to *course_id*/*version_id*.
868
+
869
+ Uses ``O_CREAT|O_EXCL`` so concurrent installers fail fast with
870
+ :class:`LockHeldError` instead of silently overwriting the same
871
+ lock file. Locks live under ``~/.logion/locks/`` so they survive
872
+ ``rmtree`` of the install directory during reinstall.
873
+ """
874
+ h = home or get_home()
875
+ path = _lock_path(h, course_id, version_id)
876
+ path.parent.mkdir(parents=True, exist_ok=True)
877
+ payload = {
878
+ "schema_version": SCHEMA_VERSION,
879
+ "course_id": course_id,
880
+ "version_id": version_id,
881
+ "pid": os.getpid(),
882
+ "locked_at": _utc_iso_now(),
883
+ }
884
+ body = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
885
+ try:
886
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
887
+ except FileExistsError as exc:
888
+ raise LockHeldError(course_id, version_id, path) from exc
889
+ try:
890
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
891
+ f.write(body)
892
+ except Exception:
893
+ with contextlib.suppress(OSError):
894
+ path.unlink()
895
+ raise
896
+ return path
897
+
898
+
899
+ def read_lock(
900
+ course_id: str,
901
+ version_id: str,
902
+ home: Path | None = None,
903
+ ) -> dict[str, Any] | None:
904
+ """Read the lock for one course/version; ``None`` if absent."""
905
+ h = home or get_home()
906
+ try:
907
+ path = _lock_path(h, course_id, version_id)
908
+ except UnsafeIdentifierError:
909
+ return None
910
+ if not path.is_file():
911
+ return None
912
+ try:
913
+ return json.loads(path.read_text(encoding="utf-8"))
914
+ except (json.JSONDecodeError, OSError):
915
+ return None
916
+
917
+
918
+ def release_lock(
919
+ course_id: str,
920
+ version_id: str,
921
+ home: Path | None = None,
922
+ ) -> bool:
923
+ """Remove the lock for one course/version; ``True`` if removed.
924
+
925
+ Tolerates the file already being gone (``FileNotFoundError``) and
926
+ other ``OSError`` failures so a stale or concurrently-removed lock
927
+ cannot crash a ``finally`` block (notably the one in
928
+ :func:`copy_and_finalize`). Returns ``False`` when the file was
929
+ not present or could not be removed.
930
+ """
931
+ h = home or get_home()
932
+ try:
933
+ path = _lock_path(h, course_id, version_id)
934
+ except UnsafeIdentifierError:
935
+ return False
936
+ try:
937
+ path.unlink()
938
+ except FileNotFoundError:
939
+ return False
940
+ except OSError:
941
+ return False
942
+ return True
943
+
944
+
945
+ def is_locked(
946
+ course_id: str,
947
+ version_id: str,
948
+ home: Path | None = None,
949
+ ) -> bool:
950
+ """Return ``True`` if the course/version has an active lock."""
951
+ return read_lock(course_id, version_id, home) is not None
952
+
953
+
954
+ def any_locks(home: Path | None = None) -> list[tuple[str, str]]:
955
+ """Return ``(course_id, version_id)`` pairs with active locks."""
956
+ h = home or get_home()
957
+ locks_dir = h / LOCKS_DIRNAME
958
+ if not locks_dir.is_dir():
959
+ return []
960
+ locks: list[tuple[str, str]] = []
961
+ # Locks live two levels deep under ``locks/<course>/<version>.json``.
962
+ for path in sorted(locks_dir.glob("*/*.json")):
963
+ try:
964
+ data = json.loads(path.read_text(encoding="utf-8"))
965
+ except (json.JSONDecodeError, OSError):
966
+ continue
967
+ course = data.get("course_id")
968
+ version = data.get("version_id")
969
+ if isinstance(course, str) and isinstance(version, str):
970
+ locks.append((course, version))
971
+ return locks
972
+
973
+
974
+ # ---------------------------------------------------------------------------
975
+ # Workflows
976
+ # ---------------------------------------------------------------------------
977
+
978
+ WORKFLOWS_FILENAME = "workflows.json"
979
+
980
+
981
+ def read_workflows(home: Path | None = None) -> list[dict[str, Any]]:
982
+ """Read workflow history; empty list if absent."""
983
+ h = home or get_home()
984
+ return _read_json_entries(h / WORKFLOWS_FILENAME)
985
+
986
+
987
+ def write_workflows(
988
+ workflows: list[dict[str, Any]],
989
+ home: Path | None = None,
990
+ ) -> Path:
991
+ """Write workflow history (enveloped)."""
992
+ h = home or get_home()
993
+ ensure_layout(h)
994
+ with state_lock(h):
995
+ return _write_json_entries(h / WORKFLOWS_FILENAME, workflows)
996
+
997
+
998
+ def record_workflow_success(
999
+ workflow_id: str,
1000
+ title: str,
1001
+ commands: list[str],
1002
+ home: Path | None = None,
1003
+ confidence: float = 0.5,
1004
+ ) -> dict[str, Any]:
1005
+ """Record a successful workflow invocation.
1006
+
1007
+ If a workflow with *workflow_id* exists, increments ``success_count``
1008
+ and updates ``last_success_at``. Otherwise creates a new record.
1009
+ Always rebuilds the recall index so the new evidence is searchable.
1010
+ Returns the resulting workflow record. The read-modify-write
1011
+ sequence and the recall rebuild are serialized via ``state_lock``
1012
+ so concurrent ``logion recall record`` calls cannot lose updates.
1013
+ """
1014
+ h = home or get_home()
1015
+ with state_lock(h):
1016
+ workflows = read_workflows(h)
1017
+ now = _utc_iso_now()
1018
+ updated: dict[str, Any] | None = None
1019
+ for w in workflows:
1020
+ if w.get("id") == workflow_id:
1021
+ w["title"] = title or w.get("title", "")
1022
+ w["commands"] = commands or w.get("commands", [])
1023
+ w["success_count"] = int(w.get("success_count", 0)) + 1
1024
+ w["last_success_at"] = now
1025
+ w["confidence"] = min(
1026
+ float(w.get("confidence", confidence)) + 0.05, 1.0
1027
+ )
1028
+ updated = w
1029
+ break
1030
+ if updated is None:
1031
+ updated = {
1032
+ "id": workflow_id,
1033
+ "title": title,
1034
+ "commands": commands,
1035
+ "success_count": 1,
1036
+ "last_success_at": now,
1037
+ "confidence": confidence,
1038
+ }
1039
+ workflows.append(updated)
1040
+
1041
+ write_workflows(workflows, h)
1042
+ rebuild_recall(h)
1043
+ return updated
1044
+
1045
+
1046
+ # ---------------------------------------------------------------------------
1047
+ # Helpers
1048
+ # ---------------------------------------------------------------------------
1049
+
1050
+
1051
+ def _utc_iso_now() -> str:
1052
+ """Current UTC time as an ISO-8601 string."""
1053
+ return datetime.datetime.now(datetime.UTC).isoformat()