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/_harness/hermes.py ADDED
@@ -0,0 +1,93 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Hermes Agent harness adapter.
3
+
4
+ Hermes stores its configuration in ``~/.hermes/config.yaml`` (YAML, not
5
+ JSON) and loads skills from ``~/.hermes/skills/``. Permission gating is
6
+ controlled by ``approvals.mode`` (``manual`` / ``smart`` / ``off``), and
7
+ Hermes *does* keep a per-command allow list — ``command_allowlist`` in
8
+ config.yaml — that bypasses approval in future sessions:
9
+
10
+ command_allowlist:
11
+ - rm
12
+ - systemctl
13
+
14
+ Crucially, those entries are keyed on the **command name** (approving
15
+ ``rm -rf`` "always" stores ``rm``), not a full command line. Logion's
16
+ autopost grant is deliberately sub-command-scoped — *exactly*
17
+ ``logion courses report-usage``, nothing broader (see ``base.py``).
18
+ That scope is not expressible in ``command_allowlist``: the only entry
19
+ we could add is ``logion``, which would pre-approve **every** ``logion``
20
+ command. Over-granting like that defeats the point of the grant.
21
+
22
+ Therefore the autopost grant is a **no-op** for Hermes: ``grant`` and
23
+ ``revoke`` report ``already=True`` without writing anything, and
24
+ ``is_granted`` always returns ``False`` — not because Hermes lacks an
25
+ allow list, but because its allow list cannot express a least-privilege,
26
+ sub-command-scoped grant. The companion skill directory is still
27
+ correct so the symlink step works. (``--yolo`` / ``HERMES_YOLO_MODE``
28
+ exist but bypass *all* approvals globally — never something Logion sets.)
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from pathlib import Path
34
+
35
+ from cli._harness.base import GrantResult, HarnessAdapter
36
+
37
+
38
+ class HermesAdapter(HarnessAdapter):
39
+ """Hermes agent harness.
40
+
41
+ Companion install is supported (symlink into ``~/.hermes/skills``);
42
+ autopost grant is a no-op because Hermes's ``command_allowlist`` is
43
+ keyed on command name and cannot express a sub-command-scoped grant
44
+ without over-granting all ``logion`` commands (see module docstring).
45
+ """
46
+
47
+ name = "hermes"
48
+ display_name = "Hermes"
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ project_dir: Path | None = None, # noqa: ARG002
54
+ home_dir: Path | None = None,
55
+ ) -> None:
56
+ self._home_dir = home_dir
57
+
58
+ def _home(self) -> Path:
59
+ return self._home_dir if self._home_dir is not None else Path.home()
60
+
61
+ def skill_dir(self) -> Path:
62
+ return self._home() / ".hermes" / "skills"
63
+
64
+ def is_present(self) -> bool:
65
+ import shutil
66
+
67
+ return (self._home() / ".hermes").is_dir() or (
68
+ shutil.which("hermes") is not None
69
+ )
70
+
71
+ def config_path(self, scope: str) -> Path: # noqa: ARG002
72
+ return self._home() / ".hermes" / "config.yaml"
73
+
74
+ def is_granted(self, scope: str) -> bool: # noqa: ARG002
75
+ return False
76
+
77
+ def grant(self, scope: str) -> GrantResult:
78
+ return GrantResult(
79
+ self.name,
80
+ scope,
81
+ self.config_path(scope),
82
+ changed=False,
83
+ already=True,
84
+ )
85
+
86
+ def revoke(self, scope: str) -> GrantResult:
87
+ return GrantResult(
88
+ self.name,
89
+ scope,
90
+ self.config_path(scope),
91
+ changed=False,
92
+ already=True,
93
+ )
@@ -0,0 +1,255 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """OpenCode harness adapter.
3
+
4
+ OpenCode stores its configuration in ``opencode.json`` (JSON with
5
+ comments — JSONC) and loads skills from ``~/.config/opencode/skills/``.
6
+ Permission gating uses a ``permission`` object where each tool name
7
+ maps to either a string action (``"allow"``, ``"ask"``, ``"deny"``) or
8
+ a granular object of pattern → action pairs.
9
+
10
+ For the autopost grant, this adapter writes a bash permission rule:
11
+
12
+ ```
13
+ "permission": {
14
+ "bash": {
15
+ "logion courses report-usage*": "allow"
16
+ }
17
+ }
18
+ ```
19
+
20
+ Config file locations (precedence: project > global):
21
+ - ``project`` → ``<cwd>/opencode.json``
22
+ - ``global`` → ``~/.config/opencode/opencode.json``
23
+
24
+ (OpenCode also reads a project ``.opencode/opencode.json``, but this
25
+ adapter writes the top-level ``<cwd>/opencode.json`` only; document the
26
+ path actually used to avoid drift with :meth:`config_path`.)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import shutil
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from cli._harness.base import (
37
+ AUTOPOST_COMMAND,
38
+ GrantResult,
39
+ HarnessAdapter,
40
+ HarnessConfigError,
41
+ )
42
+ from cli._local_state import _atomic_write_text
43
+
44
+
45
+ def _autopost_pattern() -> str:
46
+ """Render :data:`AUTOPOST_COMMAND` as an OpenCode bash pattern.
47
+
48
+ ``("logion", "courses", "report-usage")`` →
49
+ ``logion courses report-usage*``.
50
+ """
51
+ return " ".join(AUTOPOST_COMMAND) + "*"
52
+
53
+
54
+ class OpenCodeAdapter(HarnessAdapter):
55
+ """Grants the autopost command in OpenCode's ``opencode.json``.
56
+
57
+ OpenCode uses a JSON config (JSONC) with a ``permission`` object
58
+ where bash commands are matched by pattern. This adapter inserts
59
+ exactly the autopost grant as a bash permission rule.
60
+ """
61
+
62
+ name = "opencode"
63
+ display_name = "OpenCode"
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ project_dir: Path | None = None,
69
+ home_dir: Path | None = None,
70
+ ) -> None:
71
+ self._project_dir = project_dir
72
+ self._home_dir = home_dir
73
+
74
+ def _project(self) -> Path:
75
+ return (
76
+ self._project_dir if self._project_dir is not None else Path.cwd()
77
+ )
78
+
79
+ def _home(self) -> Path:
80
+ return self._home_dir if self._home_dir is not None else Path.home()
81
+
82
+ def config_path(self, scope: str) -> Path:
83
+ from cli._harness.base import VALID_SCOPES
84
+
85
+ if scope not in VALID_SCOPES:
86
+ raise ValueError(f"unknown scope: {scope!r}")
87
+ # Global config lives under ~/.config/opencode/ (matching
88
+ # skill_dir()/is_present()); the project config is opencode.json
89
+ # at the project root. A bare ~/opencode.json (the old global
90
+ # path) is not where OpenCode reads its settings, so the grant
91
+ # would have been written to a file OpenCode never loads.
92
+ if scope == "global":
93
+ return self._home() / ".config" / "opencode" / "opencode.json"
94
+ return self._project() / "opencode.json"
95
+
96
+ def skill_dir(self) -> Path:
97
+ return self._home() / ".config" / "opencode" / "skills"
98
+
99
+ def is_present(self) -> bool:
100
+ if (self._home() / ".config" / "opencode").is_dir():
101
+ return True
102
+ return shutil.which("opencode") is not None
103
+
104
+ # -- config read/write -------------------------------------------------
105
+
106
+ def _read_config(self, path: Path) -> dict[str, Any]:
107
+ if not path.is_file():
108
+ return {}
109
+ try:
110
+ raw = path.read_text(encoding="utf-8")
111
+ except OSError as exc:
112
+ raise HarnessConfigError(
113
+ f"cannot read {path} — refusing to overwrite: {exc}"
114
+ ) from exc
115
+ # Strip JSONC comments before parsing.
116
+ cleaned = _strip_jsonc_comments(raw)
117
+ try:
118
+ data = json.loads(cleaned)
119
+ except json.JSONDecodeError as exc:
120
+ raise HarnessConfigError(
121
+ f"cannot parse {path} — refusing to overwrite: {exc}"
122
+ ) from exc
123
+ if not isinstance(data, dict):
124
+ raise HarnessConfigError(
125
+ f"{path} is not a JSON object — refusing to overwrite"
126
+ )
127
+ return data
128
+
129
+ def _write_config(self, path: Path, data: dict[str, Any]) -> None:
130
+ _atomic_write_text(
131
+ path,
132
+ json.dumps(data, indent=2, ensure_ascii=False) + "\n",
133
+ )
134
+
135
+ def _bash_perms(
136
+ self, config: dict[str, Any], path: Path
137
+ ) -> dict[str, str]:
138
+ perms = config.setdefault("permission", {})
139
+ if not isinstance(perms, dict):
140
+ raise HarnessConfigError(
141
+ f"{path}: 'permission' is not an object — refusing to edit"
142
+ )
143
+ bash = perms.setdefault("bash", {})
144
+ if isinstance(bash, str):
145
+ # ``"bash": "allow"`` → upgrade to granular object so we
146
+ # can add our specific pattern without losing the global.
147
+ bash = {"*": bash}
148
+ perms["bash"] = bash
149
+ if not isinstance(bash, dict):
150
+ raise HarnessConfigError(
151
+ f"{path}: 'permission.bash' is not an object — "
152
+ "refusing to edit"
153
+ )
154
+ return bash
155
+
156
+ # -- grant / revoke / query -------------------------------------------
157
+
158
+ def is_granted(self, scope: str) -> bool:
159
+ path = self.config_path(scope)
160
+ config = self._read_config(path)
161
+ perms = config.get("permission")
162
+ if not isinstance(perms, dict):
163
+ return False
164
+ bash = perms.get("bash")
165
+ if not isinstance(bash, dict):
166
+ return False
167
+ return bash.get(_autopost_pattern()) == "allow"
168
+
169
+ def grant(self, scope: str) -> GrantResult:
170
+ path = self.config_path(scope)
171
+ config = self._read_config(path)
172
+ bash = self._bash_perms(config, path)
173
+ pattern = _autopost_pattern()
174
+ if bash.get(pattern) == "allow":
175
+ return GrantResult(
176
+ self.name, scope, path, changed=False, already=True
177
+ )
178
+ bash[pattern] = "allow"
179
+ self._write_config(path, config)
180
+ return GrantResult(self.name, scope, path, changed=True, already=False)
181
+
182
+ def revoke(self, scope: str) -> GrantResult:
183
+ path = self.config_path(scope)
184
+ if not path.is_file():
185
+ return GrantResult(
186
+ self.name, scope, path, changed=False, already=True
187
+ )
188
+ config = self._read_config(path)
189
+ bash = self._bash_perms(config, path)
190
+ pattern = _autopost_pattern()
191
+ if bash.get(pattern) != "allow":
192
+ return GrantResult(
193
+ self.name, scope, path, changed=False, already=True
194
+ )
195
+ del bash[pattern]
196
+ self._write_config(path, config)
197
+ return GrantResult(self.name, scope, path, changed=True, already=False)
198
+
199
+
200
+ def _strip_jsonc_comments(text: str) -> str:
201
+ """Remove ``//`` line comments and ``/* */`` block comments.
202
+
203
+ Tracks string literals (single and double quotes) so ``//`` inside
204
+ strings (e.g. URLs like ``https://...``) is preserved.
205
+ """
206
+ result: list[str] = []
207
+ i = 0
208
+ n = len(text)
209
+ in_string = False
210
+ string_char = ""
211
+
212
+ while i < n:
213
+ ch = text[i]
214
+
215
+ if in_string:
216
+ result.append(ch)
217
+ if ch == "\\" and i + 1 < n:
218
+ # Escape — keep the next char literal.
219
+ result.append(text[i + 1])
220
+ i += 2
221
+ continue
222
+ if ch == string_char:
223
+ in_string = False
224
+ i += 1
225
+ continue
226
+
227
+ # Not in a string.
228
+ if ch in ('"', "'"):
229
+ in_string = True
230
+ string_char = ch
231
+ result.append(ch)
232
+ i += 1
233
+ continue
234
+
235
+ if ch == "/" and i + 1 < n:
236
+ nxt = text[i + 1]
237
+ if nxt == "/":
238
+ # Line comment — skip to end of line.
239
+ while i < n and text[i] != "\n":
240
+ i += 1
241
+ continue
242
+ if nxt == "*":
243
+ # Block comment — skip to */.
244
+ i += 2
245
+ while i + 1 < n and not (
246
+ text[i] == "*" and text[i + 1] == "/"
247
+ ):
248
+ i += 1
249
+ i += 2 # skip the */
250
+ continue
251
+
252
+ result.append(ch)
253
+ i += 1
254
+
255
+ return "".join(result)