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
@@ -0,0 +1,580 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Local capability manifest validator for the Logion CLI.
3
+
4
+ Validates and normalizes ``course/capabilities.yaml`` WITHOUT calling the
5
+ API. Rules mirror the server-side validation closely enough for
6
+ author feedback.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ CAPABILITY_MANIFEST_PATH = Path("course/capabilities.yaml")
18
+ ALLOWED_TOOLS = {"browser", "terminal", "file", "web", "vision"}
19
+ ENV_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
20
+ BIN_RE = re.compile(r"^[A-Za-z0-9._+-]+$")
21
+ OS_VALUES = {"linux", "macos", "windows"}
22
+ INSTALL_KINDS = {
23
+ "uv",
24
+ "npm",
25
+ "pip",
26
+ "brew",
27
+ "apt",
28
+ "go",
29
+ "cargo",
30
+ "external",
31
+ "manual",
32
+ }
33
+ SOFTWARE_INSTALL_KINDS = {"external", "manual", "vendor", "unknown"}
34
+
35
+ _RUNTIME_WARNING_CODES = {
36
+ "runtime_env_not_declared_as_secret",
37
+ "runtime_declares_host_dependencies_without_terminal",
38
+ "install_steps_without_human_approval",
39
+ "install_steps_without_network_domains",
40
+ }
41
+
42
+
43
+ class CapabilityManifestError(ValueError):
44
+ """Raised when a capability manifest fails validation."""
45
+
46
+
47
+ def load_and_validate_capability_manifest(
48
+ bundle_dir: Path,
49
+ ) -> dict[str, Any]:
50
+ """Load and validate a capability manifest from *bundle_dir*.
51
+
52
+ Returns the normalised manifest dictionary on success.
53
+ Raises :class:`CapabilityManifestError` on any validation failure.
54
+ """
55
+ manifest_path = bundle_dir / CAPABILITY_MANIFEST_PATH
56
+ if not manifest_path.exists():
57
+ raise CapabilityManifestError("Missing course/capabilities.yaml")
58
+ try:
59
+ raw = yaml.safe_load(manifest_path.read_text())
60
+ except yaml.YAMLError as exc:
61
+ raise CapabilityManifestError(
62
+ "Invalid YAML in course/capabilities.yaml"
63
+ ) from exc
64
+ if not isinstance(raw, dict):
65
+ raise CapabilityManifestError("Capability manifest must be a mapping")
66
+ return normalize_capability_manifest(raw)
67
+
68
+
69
+ def normalize_capability_manifest(
70
+ raw: dict[str, Any],
71
+ ) -> dict[str, Any]:
72
+ """Normalise and validate a raw parsed capability manifest."""
73
+ unknown = set(raw) - {
74
+ "version",
75
+ "summary",
76
+ "tools",
77
+ "network",
78
+ "filesystem",
79
+ "secrets",
80
+ "human_approval",
81
+ "runtime",
82
+ }
83
+ if unknown:
84
+ raise CapabilityManifestError(
85
+ f"Unknown top-level key: {sorted(unknown)[0]}"
86
+ )
87
+ version = raw.get("version")
88
+ if (
89
+ not isinstance(version, int)
90
+ or isinstance(version, bool)
91
+ or version != 1
92
+ ):
93
+ raise CapabilityManifestError(
94
+ "Unsupported capability manifest version"
95
+ )
96
+ summary = raw.get("summary", "")
97
+ if not isinstance(summary, str):
98
+ raise CapabilityManifestError("summary must be a string")
99
+ if len(summary) > 512:
100
+ raise CapabilityManifestError("summary must be at most 512 characters")
101
+ tools = _normalize_tools(_default_list(raw.get("tools")))
102
+ network = _mapping_or_empty(raw.get("network"), "network")
103
+ filesystem = _mapping_or_empty(raw.get("filesystem"), "filesystem")
104
+ secrets = _mapping_or_empty(raw.get("secrets"), "secrets")
105
+ human_approval = _mapping_or_empty(
106
+ raw.get("human_approval"), "human_approval"
107
+ )
108
+ human_approval_required = human_approval.get("required", False)
109
+ if not isinstance(human_approval_required, bool):
110
+ raise CapabilityManifestError(
111
+ "human_approval.required must be a boolean"
112
+ )
113
+ allow_domains = _normalize_domains(
114
+ _default_list(network.get("allow_domains"))
115
+ )
116
+ runtime = _normalize_runtime(raw.get("runtime"))
117
+ return {
118
+ "version": 1,
119
+ "summary": summary,
120
+ "tools": tools,
121
+ "network": {"allow_domains": allow_domains},
122
+ "filesystem": {
123
+ "read": _normalize_paths(_default_list(filesystem.get("read"))),
124
+ "write": _normalize_paths(_default_list(filesystem.get("write"))),
125
+ },
126
+ "secrets": {
127
+ "env": _normalize_env(_default_list(secrets.get("env"))),
128
+ },
129
+ "human_approval": {
130
+ "required": human_approval_required,
131
+ },
132
+ "runtime": runtime,
133
+ }
134
+
135
+
136
+ def summarize_capability_manifest(
137
+ manifest: dict[str, Any],
138
+ ) -> dict[str, Any]:
139
+ """Return a human-oriented summary dict from a normalised manifest."""
140
+ tools = manifest.get("tools") or []
141
+ domains = manifest.get("network", {}).get("allow_domains") or []
142
+ fs = manifest.get("filesystem", {})
143
+ secrets = manifest.get("secrets", {})
144
+ runtime = manifest.get("runtime") or {}
145
+ requires = runtime.get("requires") or {}
146
+ install = runtime.get("install") or []
147
+ summary = {
148
+ "tools": tools,
149
+ "allows_shell": "terminal" in tools,
150
+ "allows_network": (
151
+ bool(domains) or "web" in tools or "browser" in tools
152
+ ),
153
+ "allowed_domains": domains,
154
+ "filesystem_read": fs.get("read") or [],
155
+ "filesystem_write": fs.get("write") or [],
156
+ "secrets_env": secrets.get("env") or [],
157
+ "human_approval_required": bool(
158
+ manifest.get("human_approval", {}).get("required", False)
159
+ ),
160
+ "runtime_requires_env": requires.get("env") or [],
161
+ "runtime_requires_bins": requires.get("bins") or [],
162
+ "runtime_requires_any_bins": requires.get("any_bins") or [],
163
+ "runtime_requires_config": requires.get("config") or [],
164
+ "runtime_requires_os": requires.get("os") or [],
165
+ "runtime_requires_software": requires.get("software") or [],
166
+ "runtime_install": install,
167
+ }
168
+ summary["runtime_warning_codes"] = [
169
+ w["code"] for w in runtime_requirement_warnings(manifest)
170
+ ]
171
+ summary["runtime_warnings"] = runtime_requirement_warnings(manifest)
172
+ return summary
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Internal normalisers
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ def _mapping_or_empty(value: Any, field_name: str) -> dict[str, Any]:
181
+ if value is None:
182
+ return {}
183
+ if not isinstance(value, dict):
184
+ raise CapabilityManifestError(f"{field_name} must be a mapping")
185
+ return value
186
+
187
+
188
+ def _default_list(value: Any) -> Any:
189
+ return [] if value is None else value
190
+
191
+
192
+ def _normalize_tools(tools: Any) -> list[str]:
193
+ if not isinstance(tools, list):
194
+ raise CapabilityManifestError("tools must be a list")
195
+ result: list[str] = []
196
+ for t in tools:
197
+ if not isinstance(t, str):
198
+ raise CapabilityManifestError(f"Invalid tool: {t!r}")
199
+ if t not in ALLOWED_TOOLS:
200
+ raise CapabilityManifestError(f"Unknown tool: {t!r}")
201
+ result.append(t)
202
+ return sorted(set(result))
203
+
204
+
205
+ def _normalize_domains(domains: Any) -> list[str]:
206
+ if not isinstance(domains, list):
207
+ raise CapabilityManifestError("allow_domains must be a list")
208
+ result: list[str] = []
209
+ for d in domains:
210
+ if not isinstance(d, str):
211
+ raise CapabilityManifestError(f"Invalid domain: {d!r}")
212
+ if not d:
213
+ raise CapabilityManifestError("Domain must not be empty")
214
+ if d == "*":
215
+ raise CapabilityManifestError("Wildcard domain not allowed")
216
+ if "://" in d:
217
+ raise CapabilityManifestError(
218
+ f"Domain must not include scheme: {d!r}"
219
+ )
220
+ if "/" in d:
221
+ raise CapabilityManifestError(
222
+ f"Domain must not contain a slash path: {d!r}"
223
+ )
224
+ if d.strip() != d:
225
+ raise CapabilityManifestError(
226
+ "Domain must not contain leading/trailing whitespace"
227
+ )
228
+ result.append(d)
229
+ return sorted(set(result))
230
+
231
+
232
+ def _normalize_paths(paths: Any) -> list[str]:
233
+ if not isinstance(paths, list):
234
+ raise CapabilityManifestError("Filesystem paths must be a list")
235
+ result: list[str] = []
236
+ for p in paths:
237
+ if not isinstance(p, str):
238
+ raise CapabilityManifestError(f"Invalid path: {p!r}")
239
+ path = Path(p)
240
+ if path.is_absolute():
241
+ raise CapabilityManifestError(f"Absolute paths not allowed: {p!r}")
242
+ if ".." in path.parts:
243
+ raise CapabilityManifestError(f"Path traversal not allowed: {p!r}")
244
+ result.append(p)
245
+ return sorted(set(result))
246
+
247
+
248
+ def _normalize_env(env_vars: Any) -> list[str]:
249
+ if not isinstance(env_vars, list):
250
+ raise CapabilityManifestError("env must be a list")
251
+ result: list[str] = []
252
+ for e in env_vars:
253
+ if not isinstance(e, str):
254
+ raise CapabilityManifestError(f"Invalid env var: {e!r}")
255
+ if not ENV_RE.match(e):
256
+ raise CapabilityManifestError(f"Invalid env var name: {e!r}")
257
+ result.append(e)
258
+ return sorted(set(result))
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Runtime requirements normalisers
263
+ # ---------------------------------------------------------------------------
264
+
265
+
266
+ def _normalize_runtime(value: Any) -> dict[str, Any]:
267
+ """Normalise the optional top-level ``runtime`` mapping.
268
+
269
+ Defaults to an empty requires/install shape when absent.
270
+ """
271
+ if value is None:
272
+ return {"requires": _empty_requires(), "install": []}
273
+ if not isinstance(value, dict):
274
+ raise CapabilityManifestError("runtime must be a mapping")
275
+ unknown = set(value) - {"requires", "install"}
276
+ if unknown:
277
+ raise CapabilityManifestError(
278
+ f"Unknown runtime key: {sorted(unknown)[0]}"
279
+ )
280
+ requires = _normalize_runtime_requires(value.get("requires"))
281
+ install = _normalize_install(value.get("install"))
282
+ return {"requires": requires, "install": install}
283
+
284
+
285
+ def _empty_requires() -> dict[str, Any]:
286
+ return {
287
+ "env": [],
288
+ "bins": [],
289
+ "any_bins": [],
290
+ "config": [],
291
+ "os": [],
292
+ "software": [],
293
+ }
294
+
295
+
296
+ def _normalize_runtime_requires(value: Any) -> dict[str, Any]:
297
+ if value is None:
298
+ return _empty_requires()
299
+ if not isinstance(value, dict):
300
+ raise CapabilityManifestError("runtime.requires must be a mapping")
301
+ unknown = set(value) - {
302
+ "env",
303
+ "bins",
304
+ "any_bins",
305
+ "config",
306
+ "os",
307
+ "software",
308
+ }
309
+ if unknown:
310
+ raise CapabilityManifestError(
311
+ f"Unknown runtime.requires key: {sorted(unknown)[0]}"
312
+ )
313
+ return {
314
+ "env": _normalize_env(_default_list(value.get("env"))),
315
+ "bins": _normalize_required_bins(_default_list(value.get("bins"))),
316
+ "any_bins": _normalize_any_bins(_default_list(value.get("any_bins"))),
317
+ "config": _normalize_paths(_default_list(value.get("config"))),
318
+ "os": _normalize_os(_default_list(value.get("os"))),
319
+ "software": _normalize_software(_default_list(value.get("software"))),
320
+ }
321
+
322
+
323
+ def _normalize_required_bins(value: Any) -> list[str]:
324
+ if not isinstance(value, list):
325
+ raise CapabilityManifestError("runtime.requires.bins must be a list")
326
+ result: list[str] = []
327
+ for b in value:
328
+ if not isinstance(b, str):
329
+ raise CapabilityManifestError(f"Invalid binary name: {b!r}")
330
+ if not b:
331
+ raise CapabilityManifestError("Binary name must not be empty")
332
+ if not BIN_RE.match(b):
333
+ raise CapabilityManifestError(
334
+ f"Invalid binary name (slashes, spaces, "
335
+ f"or shell metacharacters not allowed): {b!r}"
336
+ )
337
+ result.append(b)
338
+ return sorted(set(result))
339
+
340
+
341
+ def _normalize_any_bins(value: Any) -> list[list[str]]:
342
+ if not isinstance(value, list):
343
+ raise CapabilityManifestError(
344
+ "runtime.requires.any_bins must be a list"
345
+ )
346
+ groups: list[list[str]] = []
347
+ for group in value:
348
+ if not isinstance(group, list):
349
+ raise CapabilityManifestError(
350
+ "runtime.requires.any_bins entries must be lists"
351
+ )
352
+ if not group:
353
+ raise CapabilityManifestError(
354
+ "runtime.requires.any_bins groups must not be empty"
355
+ )
356
+ normalised = _normalize_required_bins(group)
357
+ groups.append(normalised)
358
+ # Dedupe groups by their tuple representation, preserving order.
359
+ seen: set[tuple[str, ...]] = set()
360
+ deduped: list[list[str]] = []
361
+ for g in groups:
362
+ key = tuple(g)
363
+ if key not in seen:
364
+ seen.add(key)
365
+ deduped.append(g)
366
+ return deduped
367
+
368
+
369
+ def _normalize_os(value: Any) -> list[str]:
370
+ if not isinstance(value, list):
371
+ raise CapabilityManifestError("runtime.requires.os must be a list")
372
+ result: list[str] = []
373
+ for o in value:
374
+ if not isinstance(o, str):
375
+ raise CapabilityManifestError(f"Invalid os value: {o!r}")
376
+ if o not in OS_VALUES:
377
+ raise CapabilityManifestError(
378
+ f"Unknown os value: {o!r} (allowed: "
379
+ f"{', '.join(sorted(OS_VALUES))})"
380
+ )
381
+ result.append(o)
382
+ return sorted(set(result))
383
+
384
+
385
+ def _normalize_software(value: Any) -> list[dict[str, Any]]:
386
+ if not isinstance(value, list):
387
+ raise CapabilityManifestError(
388
+ "runtime.requires.software must be a list"
389
+ )
390
+ result: list[dict[str, Any]] = []
391
+ for i, entry in enumerate(value):
392
+ if not isinstance(entry, dict):
393
+ raise CapabilityManifestError(
394
+ f"runtime.requires.software[{i}] must be a mapping"
395
+ )
396
+ unknown = set(entry) - {"name", "required", "install", "notes"}
397
+ if unknown:
398
+ raise CapabilityManifestError(
399
+ f"Unknown runtime.requires.software key: {sorted(unknown)[0]}"
400
+ )
401
+ name = entry.get("name")
402
+ if not isinstance(name, str) or not name:
403
+ raise CapabilityManifestError(
404
+ f"runtime.requires.software[{i}].name must be a non-empty "
405
+ "string"
406
+ )
407
+ if len(name) > 120:
408
+ raise CapabilityManifestError(
409
+ f"runtime.requires.software[{i}].name must be at most 120 "
410
+ "characters"
411
+ )
412
+ required = entry.get("required", True)
413
+ if not isinstance(required, bool):
414
+ raise CapabilityManifestError(
415
+ f"runtime.requires.software[{i}].required must be a boolean"
416
+ )
417
+ install = entry.get("install", "external")
418
+ if not isinstance(install, str):
419
+ raise CapabilityManifestError(
420
+ f"runtime.requires.software[{i}].install must be a string"
421
+ )
422
+ if install not in SOFTWARE_INSTALL_KINDS:
423
+ raise CapabilityManifestError(
424
+ f"Unknown software install kind: {install!r} (allowed: "
425
+ f"{', '.join(sorted(SOFTWARE_INSTALL_KINDS))})"
426
+ )
427
+ notes = entry.get("notes", "")
428
+ if not isinstance(notes, str):
429
+ raise CapabilityManifestError(
430
+ f"runtime.requires.software[{i}].notes must be a string"
431
+ )
432
+ if len(notes) > 512:
433
+ raise CapabilityManifestError(
434
+ f"runtime.requires.software[{i}].notes must be at most 512 "
435
+ "characters"
436
+ )
437
+ result.append({
438
+ "name": name,
439
+ "required": required,
440
+ "install": install,
441
+ "notes": notes,
442
+ })
443
+ return result
444
+
445
+
446
+ def _normalize_install(value: Any) -> list[dict[str, Any]]:
447
+ if value is None:
448
+ return []
449
+ if not isinstance(value, list):
450
+ raise CapabilityManifestError("runtime.install must be a list")
451
+ result: list[dict[str, Any]] = []
452
+ for i, entry in enumerate(value):
453
+ result.append(_normalize_install_entry(entry, i))
454
+ return result
455
+
456
+
457
+ def _normalize_install_entry(entry: Any, i: int) -> dict[str, Any]:
458
+ """Validate and normalise a single runtime.install entry."""
459
+ if not isinstance(entry, dict):
460
+ raise CapabilityManifestError(
461
+ f"runtime.install[{i}] must be a mapping"
462
+ )
463
+ unknown = set(entry) - {"kind", "command", "required", "notes"}
464
+ if unknown:
465
+ raise CapabilityManifestError(
466
+ f"Unknown runtime.install key: {sorted(unknown)[0]}"
467
+ )
468
+ kind = entry.get("kind")
469
+ if not isinstance(kind, str):
470
+ raise CapabilityManifestError(
471
+ f"runtime.install[{i}].kind must be a string"
472
+ )
473
+ if kind not in INSTALL_KINDS:
474
+ raise CapabilityManifestError(
475
+ f"Unknown install kind: {kind!r} (allowed: "
476
+ f"{', '.join(sorted(INSTALL_KINDS))})"
477
+ )
478
+ command = entry.get("command", "")
479
+ if not isinstance(command, str):
480
+ raise CapabilityManifestError(
481
+ f"runtime.install[{i}].command must be a string"
482
+ )
483
+ if "\n" in command or "\r" in command:
484
+ raise CapabilityManifestError(
485
+ f"runtime.install[{i}].command must not contain newlines"
486
+ )
487
+ if len(command) > 240:
488
+ raise CapabilityManifestError(
489
+ f"runtime.install[{i}].command must be at most 240 characters"
490
+ )
491
+ required = entry.get("required", True)
492
+ if not isinstance(required, bool):
493
+ raise CapabilityManifestError(
494
+ f"runtime.install[{i}].required must be a boolean"
495
+ )
496
+ notes = entry.get("notes", "")
497
+ if not isinstance(notes, str):
498
+ raise CapabilityManifestError(
499
+ f"runtime.install[{i}].notes must be a string"
500
+ )
501
+ if len(notes) > 512:
502
+ raise CapabilityManifestError(
503
+ f"runtime.install[{i}].notes must be at most 512 characters"
504
+ )
505
+ return {
506
+ "kind": kind,
507
+ "command": command,
508
+ "required": required,
509
+ "notes": notes,
510
+ }
511
+
512
+
513
+ def runtime_requirement_warnings(
514
+ manifest: dict[str, Any],
515
+ ) -> list[dict[str, str]]:
516
+ """Derive cross-field warnings from a normalised manifest.
517
+
518
+ Warnings are reviewer/author-facing disclosure only. They never become
519
+ hard validation failures; ``runtime.requires`` lowers false rejections
520
+ for legitimate external dependencies but must not hide behaviour that
521
+ still needs the normal ``tools``/``secrets``/``filesystem``/
522
+ ``network``/``human_approval`` declarations.
523
+ """
524
+ warnings: list[dict[str, str]] = []
525
+ runtime = manifest.get("runtime") or {}
526
+ requires = runtime.get("requires") or {}
527
+ install = runtime.get("install") or []
528
+ secrets_env = set(manifest.get("secrets", {}).get("env") or [])
529
+ tools = manifest.get("tools") or []
530
+ human_approval_required = bool(
531
+ manifest.get("human_approval", {}).get("required", False)
532
+ )
533
+ domains = manifest.get("network", {}).get("allow_domains") or []
534
+
535
+ for env_name in requires.get("env") or []:
536
+ if env_name not in secrets_env:
537
+ warnings.append({
538
+ "code": "runtime_env_not_declared_as_secret",
539
+ "severity": "medium",
540
+ "message": (
541
+ f"runtime.requires.env includes {env_name} but "
542
+ "secrets.env does not."
543
+ ),
544
+ })
545
+
546
+ has_host_deps = bool(
547
+ requires.get("bins") or requires.get("any_bins") or install
548
+ )
549
+ if has_host_deps and "terminal" not in tools:
550
+ warnings.append({
551
+ "code": "runtime_declares_host_dependencies_without_terminal",
552
+ "severity": "low",
553
+ "message": (
554
+ "runtime declares host dependencies or install steps "
555
+ "but 'terminal' is not in tools."
556
+ ),
557
+ })
558
+
559
+ if install and not human_approval_required:
560
+ warnings.append({
561
+ "code": "install_steps_without_human_approval",
562
+ "severity": "medium",
563
+ "message": (
564
+ "runtime.install is non-empty but "
565
+ "human_approval.required is false."
566
+ ),
567
+ })
568
+
569
+ network_kinds = {"uv", "npm", "pip", "brew", "apt", "go", "cargo"}
570
+ if any(step["kind"] in network_kinds for step in install) and not domains:
571
+ warnings.append({
572
+ "code": "install_steps_without_network_domains",
573
+ "severity": "low",
574
+ "message": (
575
+ "runtime.install includes package-manager steps but no "
576
+ "network allow_domains are declared."
577
+ ),
578
+ })
579
+
580
+ return warnings
cli/_credentials.py ADDED
@@ -0,0 +1,104 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Persisted CLI credentials (~/.logion/credentials.json).
3
+
4
+ Stores non-secret identity context (currently the user id and email) so
5
+ commands like ``identity agents-add`` do not require ``--user-id`` on
6
+ every invocation. Secrets (passwords, API keys) are never written here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from cli._local_state import _atomic_write_text, get_home
18
+
19
+ CREDENTIALS_FILENAME = "credentials.json"
20
+
21
+ SCHEMA_VERSION = 1
22
+
23
+
24
+ def credentials_path(home: Path | None = None) -> Path:
25
+ """Return the credentials file path under the Logion home."""
26
+ return (home or get_home()) / CREDENTIALS_FILENAME
27
+
28
+
29
+ def read_credentials(home: Path | None = None) -> dict[str, Any]:
30
+ """Read stored credentials; empty dict if absent or unreadable."""
31
+ path = credentials_path(home)
32
+ if not path.is_file():
33
+ return {}
34
+ try:
35
+ raw = json.loads(path.read_text(encoding="utf-8"))
36
+ except (json.JSONDecodeError, OSError):
37
+ return {}
38
+ return raw if isinstance(raw, dict) else {}
39
+
40
+
41
+ def save_user_identity(
42
+ user_id: str,
43
+ email: str | None = None,
44
+ home: Path | None = None,
45
+ agent_id: str | None = None,
46
+ ) -> Path:
47
+ """Persist the user identity, preserving unrelated keys."""
48
+ path = credentials_path(home)
49
+ data = read_credentials(home)
50
+ data["schema_version"] = SCHEMA_VERSION
51
+ data["user_id"] = user_id
52
+ if email is not None:
53
+ data["email"] = email
54
+ if agent_id is not None:
55
+ data["agent_id"] = agent_id
56
+ _atomic_write_text(
57
+ path,
58
+ json.dumps(data, indent=2, ensure_ascii=False) + "\n",
59
+ )
60
+ # Identity context is not secret, but the file is user-private state.
61
+ with contextlib.suppress(OSError):
62
+ os.chmod(path, 0o600)
63
+ return path
64
+
65
+
66
+ def stored_user_id(home: Path | None = None) -> str | None:
67
+ """Return the stored user id, or ``None`` if not set."""
68
+ value = read_credentials(home).get("user_id")
69
+ if isinstance(value, str) and value.strip():
70
+ return value
71
+ return None
72
+
73
+
74
+ def stored_agent_id(home: Path | None = None) -> str | None:
75
+ """Return the stored agent id, or ``None`` if not set."""
76
+ value = read_credentials(home).get("agent_id")
77
+ if isinstance(value, str) and value.strip():
78
+ return value
79
+ return None
80
+
81
+
82
+ def save_autoreview_consent(enabled: bool, home: Path | None = None) -> Path:
83
+ """Persist the auto-review consent decision (non-secret)."""
84
+ path = credentials_path(home)
85
+ data = read_credentials(home)
86
+ data["schema_version"] = SCHEMA_VERSION
87
+ data["autoreview_consent"] = bool(enabled)
88
+ _atomic_write_text(
89
+ path, json.dumps(data, indent=2, ensure_ascii=False) + "\n"
90
+ )
91
+ with contextlib.suppress(OSError):
92
+ os.chmod(path, 0o600)
93
+ return path
94
+
95
+
96
+ def stored_autoreview_consent(home: Path | None = None) -> bool | None:
97
+ """Return the recorded consent, or ``None`` if never asked."""
98
+ value = read_credentials(home).get("autoreview_consent")
99
+ return bool(value) if isinstance(value, bool) else None
100
+
101
+
102
+ def is_onboarded(home: Path | None = None) -> bool:
103
+ """True once a user id has been stored (drives the first-run trigger)."""
104
+ return stored_user_id(home) is not None