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/_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()
|