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/_errors.py ADDED
@@ -0,0 +1,82 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """CLI error handling — map SDK errors to exit codes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from uuid import UUID
9
+
10
+ from logion import APIError, LogionError
11
+
12
+ ALLOWED_ERROR_CODES = frozenset({
13
+ "auth_missing",
14
+ "entitlement_missing",
15
+ "entitlement_expired",
16
+ "unsafe_identifier",
17
+ "not_found",
18
+ "validation_failed",
19
+ "server_error",
20
+ "confirmation_required",
21
+ "top_up_timeout",
22
+ })
23
+
24
+
25
+ def handle_error(exc: Exception) -> int:
26
+ """Map an exception to an exit code and print a user-facing message."""
27
+ if isinstance(exc, APIError):
28
+ detail = getattr(exc, "detail", str(exc))
29
+ if isinstance(detail, list):
30
+ detail = "; ".join(str(d) for d in detail)
31
+ status_code = getattr(exc, "status_code", "?")
32
+ print(f"API error {status_code}: {detail}", file=sys.stderr)
33
+ return 1
34
+ if isinstance(exc, LogionError):
35
+ print(f"Logion error: {exc}", file=sys.stderr)
36
+ return 1
37
+ raise exc
38
+
39
+
40
+ def print_err(msg: str) -> None:
41
+ """Print a user-facing message to stderr."""
42
+ print(msg, file=sys.stderr)
43
+
44
+
45
+ def emit_error_json(code: str, message: str, exit_code: int) -> None:
46
+ """Emit a v1 JSON error envelope to stderr."""
47
+ payload = {
48
+ "version": "v1",
49
+ "kind": "logion.error",
50
+ "data": {"code": code, "message": message, "exit_code": exit_code},
51
+ }
52
+ print(json.dumps(payload, indent=2, sort_keys=True), file=sys.stderr)
53
+
54
+
55
+ def require_non_empty_id(value: str, label: str) -> int | None:
56
+ """Return ``2`` if *value* is empty/whitespace, else ``None``."""
57
+ if not value or not value.strip():
58
+ print_err(f"Error: {label} must not be empty.")
59
+ return 2
60
+ return None
61
+
62
+
63
+ def validate_uuid(value: str, label: str) -> int | None:
64
+ """Return ``2`` if *value* is not a valid UUID, else ``None``."""
65
+ try:
66
+ UUID(value)
67
+ except ValueError:
68
+ print_err(f"Error: {label} must be a valid UUID (got: {value!r}).")
69
+ return 2
70
+ return None
71
+
72
+
73
+ def validate_uuid_id(value: str, label: str) -> int | None:
74
+ """Check *value* is non-empty and a valid UUID.
75
+
76
+ Combines :func:`require_non_empty_id` and :func:`validate_uuid`
77
+ into a single call for positional-ID validation.
78
+ """
79
+ empty = require_non_empty_id(value, label)
80
+ if empty is not None:
81
+ return empty
82
+ return validate_uuid(value, label)
cli/_first_run.py ADDED
@@ -0,0 +1,90 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """First-run onboarding trigger.
3
+
4
+ Decides whether to run onboarding before dispatching a command. The
5
+ matrix is deliberately conservative: never hijack help/version/docs,
6
+ never prompt in CI/non-interactive shells, always respect --no-onboarding.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+ from dataclasses import dataclass
15
+
16
+ from cli._credentials import is_onboarded
17
+
18
+ # Commands that must never trigger onboarding (read-only / informational).
19
+ SKIP_COMMANDS: frozenset[str] = frozenset({"docs", "onboarding"})
20
+
21
+ # Commands that need identity/companion/local-state to be useful.
22
+ # ``listings`` is excluded because search/browse is public and read-only —
23
+ # forcing onboarding before a prospective user can explore the marketplace
24
+ # adds friction without value.
25
+ NEEDS_SETUP_COMMANDS: frozenset[str] = frozenset({
26
+ "courses",
27
+ "credits",
28
+ "payments",
29
+ "referrals",
30
+ "reports",
31
+ "course-reviews",
32
+ "bounties",
33
+ "skills",
34
+ "recall",
35
+ })
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TriggerDecision:
40
+ should_run: bool
41
+ reason: str # "already-onboarded" | "no-tty" | "noninteractive-env" |
42
+ # "no-onboarding-flag" | "skip-command" | "help-version" |
43
+ # "command-needs-setup" | "unknown-command"
44
+
45
+
46
+ def is_noninteractive() -> bool:
47
+ """True in CI / piped / explicitly-flagged non-interactive shells."""
48
+ if os.getenv("LOGION_NONINTERACTIVE"):
49
+ return True
50
+ return not sys.stdin.isatty() or not sys.stdout.isatty()
51
+
52
+
53
+ def _is_help_or_version(argv: list[str]) -> bool:
54
+ """True if argv contains a flag-level ``--help``/``-h``/``--version``.
55
+
56
+ Only tokens that start with ``-`` are considered, so a positional
57
+ value (e.g. a search query literally equal to ``--help``) does not
58
+ trigger a false positive.
59
+ """
60
+ help_flags = {"--help", "-h", "--version"}
61
+ return any(tok in help_flags for tok in argv if tok.startswith("-"))
62
+
63
+
64
+ def decide(argv: list[str], args: argparse.Namespace) -> TriggerDecision:
65
+ """Pure decision: given parsed args + raw argv, run onboarding?"""
66
+ if _is_help_or_version(argv):
67
+ return TriggerDecision(should_run=False, reason="help-version")
68
+ # ``--no-onboarding`` may appear before or after the subcommand;
69
+ # argparse only populates ``args.no_onboarding`` when it precedes
70
+ # the subcommand, so check the raw argv too.
71
+ if "--no-onboarding" in argv or getattr(args, "no_onboarding", False):
72
+ return TriggerDecision(should_run=False, reason="no-onboarding-flag")
73
+ if os.getenv("LOGION_NO_ONBOARDING"):
74
+ return TriggerDecision(should_run=False, reason="no-onboarding-flag")
75
+ command = getattr(args, "command", None)
76
+ if not isinstance(command, str):
77
+ return TriggerDecision(should_run=False, reason="unknown-command")
78
+ if command in SKIP_COMMANDS:
79
+ return TriggerDecision(should_run=False, reason="skip-command")
80
+ if command not in NEEDS_SETUP_COMMANDS:
81
+ return TriggerDecision(should_run=False, reason="unknown-command")
82
+ if is_onboarded():
83
+ return TriggerDecision(should_run=False, reason="already-onboarded")
84
+ if is_noninteractive():
85
+ return TriggerDecision(should_run=False, reason="noninteractive-env")
86
+ # ``--json`` means a machine consumer is piping stdout; never
87
+ # hijack it with onboarding prompts or JSON of our own.
88
+ if getattr(args, "json_output", False):
89
+ return TriggerDecision(should_run=False, reason="json-output")
90
+ return TriggerDecision(should_run=True, reason="command-needs-setup")
@@ -0,0 +1,68 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Harness adapter registry.
3
+
4
+ To support a new agent harness (Codex, OpenCode, Amp, ...), implement a
5
+ :class:`~cli._harness.base.HarnessAdapter` and add it to
6
+ :data:`_ADAPTER_TYPES`. Nothing else changes — the onboarding flow and
7
+ ``--harness`` selection discover adapters through this registry.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from cli._harness.base import (
13
+ AUTOPOST_COMMAND,
14
+ GrantResult,
15
+ HarnessAdapter,
16
+ HarnessConfigError,
17
+ )
18
+ from cli._harness.claude_code import ClaudeCodeAdapter
19
+ from cli._harness.codex import CodexAdapter
20
+ from cli._harness.hermes import HermesAdapter
21
+ from cli._harness.opencode import OpenCodeAdapter
22
+
23
+ # Ordered registry of known adapter types. Append new harnesses here.
24
+ _ADAPTER_TYPES: tuple[type[HarnessAdapter], ...] = (
25
+ ClaudeCodeAdapter,
26
+ CodexAdapter,
27
+ OpenCodeAdapter,
28
+ HermesAdapter,
29
+ )
30
+
31
+
32
+ def all_adapters() -> list[HarnessAdapter]:
33
+ """Instantiate every registered adapter (production defaults)."""
34
+ return [cls() for cls in _ADAPTER_TYPES]
35
+
36
+
37
+ def adapter_names() -> list[str]:
38
+ """Return the stable names of all registered harnesses."""
39
+ return [a.name for a in all_adapters()]
40
+
41
+
42
+ def get_adapter(name: str) -> HarnessAdapter | None:
43
+ """Return the adapter with *name*, or ``None`` if unknown."""
44
+ for adapter in all_adapters():
45
+ if adapter.name == name:
46
+ return adapter
47
+ return None
48
+
49
+
50
+ def detect_present() -> list[HarnessAdapter]:
51
+ """Return adapters whose harness appears installed on this machine."""
52
+ return [a for a in all_adapters() if a.is_present()]
53
+
54
+
55
+ __all__ = [
56
+ "AUTOPOST_COMMAND",
57
+ "ClaudeCodeAdapter",
58
+ "CodexAdapter",
59
+ "GrantResult",
60
+ "HarnessAdapter",
61
+ "HarnessConfigError",
62
+ "HermesAdapter",
63
+ "OpenCodeAdapter",
64
+ "adapter_names",
65
+ "all_adapters",
66
+ "detect_present",
67
+ "get_adapter",
68
+ ]
cli/_harness/base.py ADDED
@@ -0,0 +1,106 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Harness adapter contract.
3
+
4
+ A *harness* is whatever agent runtime the user drives Logion from —
5
+ Claude Code, Codex, OpenCode, Amp, and so on. Each one gates (or does
6
+ not gate) the commands an agent may run, and each expresses those
7
+ permissions in its own config format.
8
+
9
+ Logion cannot pre-authorize an outward-facing command across every
10
+ harness from one place — that protection belongs to each tool's
11
+ operator. What Logion *can* do is translate a single, well-scoped
12
+ grant ("let the agent run ``logion courses report-usage`` without
13
+ prompting") into whatever native config the harness on this machine
14
+ understands.
15
+
16
+ Each harness gets one :class:`HarnessAdapter`. Supporting a new harness
17
+ is a new adapter added to the registry in ``__init__.py`` — no caller
18
+ changes. The grant itself is defined once, here, as
19
+ :data:`AUTOPOST_COMMAND`; adapters render it into their own syntax.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from abc import ABC, abstractmethod
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ # The command whose autonomous execution the autopost grant authorizes.
29
+ # Defined once; each adapter renders it into its harness's permission
30
+ # syntax (e.g. Claude Code's ``Bash(logion courses report-usage:*)``).
31
+ AUTOPOST_COMMAND: tuple[str, ...] = ("logion", "courses", "report-usage")
32
+
33
+ # Permission scopes an adapter must understand.
34
+ VALID_SCOPES: frozenset[str] = frozenset({"project", "global"})
35
+
36
+
37
+ class HarnessConfigError(RuntimeError):
38
+ """Raised when a harness config exists but cannot be safely edited.
39
+
40
+ Surfaced (never swallowed) so the caller refuses to clobber a config
41
+ file it could not parse, rather than overwriting the user's settings.
42
+ """
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class GrantResult:
47
+ """Outcome of a grant/revoke on one harness at one scope."""
48
+
49
+ harness: str # adapter ``name`` (e.g. "claude-code")
50
+ scope: str # "project" | "global"
51
+ path: Path # config file inspected/edited
52
+ changed: bool # True if the file was actually written
53
+ already: bool # grant: rule was already present; revoke: already absent
54
+
55
+ def to_dict(self) -> dict[str, object]:
56
+ """JSON-safe view for ``--json`` output."""
57
+ return {
58
+ "harness": self.harness,
59
+ "scope": self.scope,
60
+ "path": str(self.path),
61
+ "changed": self.changed,
62
+ "already": self.already,
63
+ }
64
+
65
+
66
+ class HarnessAdapter(ABC):
67
+ """Bridges the Logion autopost grant to one harness's permission model."""
68
+
69
+ #: Stable machine id, used by ``--harness`` (e.g. "claude-code").
70
+ name: str = "unnamed"
71
+ #: Human-facing label (e.g. "Claude Code").
72
+ display_name: str = "Unnamed harness"
73
+
74
+ @abstractmethod
75
+ def is_present(self) -> bool:
76
+ """True if this harness appears installed/configured for the user."""
77
+ ...
78
+
79
+ @abstractmethod
80
+ def config_path(self, scope: str) -> Path:
81
+ """Resolve the settings file for *scope* ("project" | "global")."""
82
+ ...
83
+
84
+ @abstractmethod
85
+ def is_granted(self, scope: str) -> bool:
86
+ """True if the autopost grant is already present at *scope*."""
87
+ ...
88
+
89
+ @abstractmethod
90
+ def grant(self, scope: str) -> GrantResult:
91
+ """Add the autopost permission at *scope*. Idempotent."""
92
+ ...
93
+
94
+ @abstractmethod
95
+ def revoke(self, scope: str) -> GrantResult:
96
+ """Remove the autopost permission at *scope*. Idempotent."""
97
+ ...
98
+
99
+ @abstractmethod
100
+ def skill_dir(self) -> Path:
101
+ """Absolute dir this harness loads skills from.
102
+
103
+ e.g. Claude Code → ``~/.claude/skills``. The onboarding
104
+ companion step writes/symlinks the companion SKILL bundle here.
105
+ """
106
+ ...
@@ -0,0 +1,168 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Claude Code harness adapter.
3
+
4
+ Claude Code gates a model's tool calls with an auto-mode classifier and
5
+ a ``permissions.allow`` list in ``settings.json``. An explicit ``allow``
6
+ rule that matches a command pre-approves it, so the classifier never has
7
+ to judge it. This adapter inserts exactly the autopost grant — nothing
8
+ broader — into that list.
9
+
10
+ Scopes map to the two settings files Claude Code reads:
11
+
12
+ * ``project`` → ``<cwd>/.claude/settings.json``
13
+ * ``global`` → ``~/.claude/settings.json``
14
+
15
+ Claude Code is the only harness using this ``permissions.allow`` + Bash
16
+ matcher format (Codex/Hermes use no per-command allow list; OpenCode
17
+ uses its own ``permission.bash`` patterns), so this adapter owns the
18
+ JSON read/write/grant/revoke logic directly rather than sharing a base.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import shutil
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from cli._harness.base import (
29
+ AUTOPOST_COMMAND,
30
+ VALID_SCOPES,
31
+ GrantResult,
32
+ HarnessAdapter,
33
+ HarnessConfigError,
34
+ )
35
+ from cli._local_state import _atomic_write_text
36
+
37
+
38
+ def _autopost_matcher() -> str:
39
+ """Render :data:`AUTOPOST_COMMAND` as a Bash matcher.
40
+
41
+ ``("logion", "courses", "report-usage")`` →
42
+ ``Bash(logion courses report-usage:*)``.
43
+ """
44
+ return f"Bash({' '.join(AUTOPOST_COMMAND)}:*)"
45
+
46
+
47
+ class ClaudeCodeAdapter(HarnessAdapter):
48
+ """Grants the autopost command in Claude Code's ``settings.json``."""
49
+
50
+ name = "claude-code"
51
+ display_name = "Claude Code"
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ project_dir: Path | None = None,
57
+ home_dir: Path | None = None,
58
+ ) -> None:
59
+ self._project_dir = project_dir
60
+ self._home_dir = home_dir
61
+
62
+ # -- path resolution ---------------------------------------------------
63
+
64
+ def _project(self) -> Path:
65
+ return (
66
+ self._project_dir if self._project_dir is not None else Path.cwd()
67
+ )
68
+
69
+ def _home(self) -> Path:
70
+ return self._home_dir if self._home_dir is not None else Path.home()
71
+
72
+ def config_path(self, scope: str) -> Path:
73
+ if scope not in VALID_SCOPES:
74
+ raise ValueError(f"unknown scope: {scope!r}")
75
+ base = self._home() if scope == "global" else self._project()
76
+ return base / ".claude" / "settings.json"
77
+
78
+ def skill_dir(self) -> Path:
79
+ return self._home() / ".claude" / "skills"
80
+
81
+ # -- detection ---------------------------------------------------------
82
+
83
+ def is_present(self) -> bool:
84
+ """True if a ``.claude`` dir (home/project) or ``claude`` on PATH."""
85
+ if (self._home() / ".claude").is_dir():
86
+ return True
87
+ if (self._project() / ".claude").is_dir():
88
+ return True
89
+ return shutil.which("claude") is not None
90
+
91
+ # -- config read/write -------------------------------------------------
92
+
93
+ def _read_settings(self, path: Path) -> dict[str, Any]:
94
+ if not path.is_file():
95
+ return {}
96
+ try:
97
+ raw = json.loads(path.read_text(encoding="utf-8"))
98
+ except (json.JSONDecodeError, OSError) as exc:
99
+ raise HarnessConfigError(
100
+ f"cannot parse {path} — refusing to overwrite: {exc}"
101
+ ) from exc
102
+ if not isinstance(raw, dict):
103
+ raise HarnessConfigError(
104
+ f"{path} is not a JSON object — refusing to overwrite"
105
+ )
106
+ return raw
107
+
108
+ def _allow_list(self, settings: dict[str, Any], path: Path) -> list[Any]:
109
+ perms = settings.setdefault("permissions", {})
110
+ if not isinstance(perms, dict):
111
+ raise HarnessConfigError(
112
+ f"{path}: 'permissions' is not an object — refusing to edit"
113
+ )
114
+ allow = perms.setdefault("allow", [])
115
+ if not isinstance(allow, list):
116
+ raise HarnessConfigError(
117
+ f"{path}: 'permissions.allow' is not a list — refusing to edit"
118
+ )
119
+ return allow
120
+
121
+ def _write_settings(self, path: Path, settings: dict[str, Any]) -> None:
122
+ _atomic_write_text(
123
+ path,
124
+ json.dumps(settings, indent=2, ensure_ascii=False) + "\n",
125
+ )
126
+
127
+ # -- grant / revoke / query -------------------------------------------
128
+
129
+ def is_granted(self, scope: str) -> bool:
130
+ path = self.config_path(scope)
131
+ settings = self._read_settings(path)
132
+ perms = settings.get("permissions")
133
+ if not isinstance(perms, dict):
134
+ return False
135
+ allow = perms.get("allow")
136
+ if not isinstance(allow, list):
137
+ return False
138
+ return _autopost_matcher() in allow
139
+
140
+ def grant(self, scope: str) -> GrantResult:
141
+ path = self.config_path(scope)
142
+ settings = self._read_settings(path)
143
+ allow = self._allow_list(settings, path)
144
+ matcher = _autopost_matcher()
145
+ if matcher in allow:
146
+ return GrantResult(
147
+ self.name, scope, path, changed=False, already=True
148
+ )
149
+ allow.append(matcher)
150
+ self._write_settings(path, settings)
151
+ return GrantResult(self.name, scope, path, changed=True, already=False)
152
+
153
+ def revoke(self, scope: str) -> GrantResult:
154
+ path = self.config_path(scope)
155
+ if not path.is_file():
156
+ return GrantResult(
157
+ self.name, scope, path, changed=False, already=True
158
+ )
159
+ settings = self._read_settings(path)
160
+ allow = self._allow_list(settings, path)
161
+ matcher = _autopost_matcher()
162
+ if matcher not in allow:
163
+ return GrantResult(
164
+ self.name, scope, path, changed=False, already=True
165
+ )
166
+ settings["permissions"]["allow"] = [m for m in allow if m != matcher]
167
+ self._write_settings(path, settings)
168
+ return GrantResult(self.name, scope, path, changed=True, already=False)
cli/_harness/codex.py ADDED
@@ -0,0 +1,79 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Codex harness adapter.
3
+
4
+ Codex stores its configuration in ``~/.codex/config.toml`` (TOML, not
5
+ JSON) and loads skills from ``~/.codex/skills/``. Permission gating in
6
+ Codex is controlled by ``approval_policy`` and ``sandbox_mode`` /
7
+ ``default_permissions`` — there is no per-command allow list like
8
+ Claude Code's ``permissions.allow``. Codex uses a sandbox model that
9
+ controls *what* can run (filesystem + network), not *which* specific
10
+ commands are pre-approved.
11
+
12
+ Therefore the autopost grant is a **no-op** for Codex: ``grant`` and
13
+ ``revoke`` report ``already=True`` without writing anything, and
14
+ ``is_granted`` always returns ``False``. The companion skill directory
15
+ is correct so the symlink step works.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ from cli._harness.base import GrantResult, HarnessAdapter
23
+
24
+
25
+ class CodexAdapter(HarnessAdapter):
26
+ """Codex agent harness.
27
+
28
+ Companion install is supported (symlink into ``~/.codex/skills``);
29
+ autopost grant is a no-op because Codex has no per-command
30
+ permission list.
31
+ """
32
+
33
+ name = "codex"
34
+ display_name = "Codex"
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ project_dir: Path | None = None, # noqa: ARG002
40
+ home_dir: Path | None = None,
41
+ ) -> None:
42
+ self._home_dir = home_dir
43
+
44
+ def _home(self) -> Path:
45
+ return self._home_dir if self._home_dir is not None else Path.home()
46
+
47
+ def skill_dir(self) -> Path:
48
+ return self._home() / ".codex" / "skills"
49
+
50
+ def is_present(self) -> bool:
51
+ import shutil
52
+
53
+ return (self._home() / ".codex").is_dir() or (
54
+ shutil.which("codex") is not None
55
+ )
56
+
57
+ def config_path(self, scope: str) -> Path: # noqa: ARG002
58
+ return self._home() / ".codex" / "config.toml"
59
+
60
+ def is_granted(self, scope: str) -> bool: # noqa: ARG002
61
+ return False
62
+
63
+ def grant(self, scope: str) -> GrantResult:
64
+ return GrantResult(
65
+ self.name,
66
+ scope,
67
+ self.config_path(scope),
68
+ changed=False,
69
+ already=True,
70
+ )
71
+
72
+ def revoke(self, scope: str) -> GrantResult:
73
+ return GrantResult(
74
+ self.name,
75
+ scope,
76
+ self.config_path(scope),
77
+ changed=False,
78
+ already=True,
79
+ )
cli/_harness/custom.py ADDED
@@ -0,0 +1,55 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Custom-path harness for explicit ``--agent-dir`` targets.
3
+
4
+ A ``CustomPathHarness`` is constructed on demand with an explicit
5
+ ``skill_dir`` path (from ``--agent-dir`` or a prompt). It is never
6
+ auto-detected, and its grant/revoke are no-ops because a bare directory
7
+ has no known permission format.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from cli._harness.base import GrantResult, HarnessAdapter
15
+
16
+
17
+ class CustomPathHarness(HarnessAdapter):
18
+ """Harness backed by an explicit skill directory."""
19
+
20
+ name = "custom"
21
+ display_name = "Custom path"
22
+
23
+ def __init__(self, skill_dir_path: Path) -> None:
24
+ self._skill_dir = Path(skill_dir_path)
25
+
26
+ def skill_dir(self) -> Path:
27
+ return self._skill_dir
28
+
29
+ def is_present(self) -> bool:
30
+ return False
31
+
32
+ def config_path(self, scope: str) -> Path: # noqa: ARG002
33
+ # No settings file for a custom directory.
34
+ return self._skill_dir / "settings.json"
35
+
36
+ def is_granted(self, scope: str) -> bool: # noqa: ARG002
37
+ return False
38
+
39
+ def grant(self, scope: str) -> GrantResult:
40
+ return GrantResult(
41
+ self.name,
42
+ scope,
43
+ self.config_path(scope),
44
+ changed=False,
45
+ already=True,
46
+ )
47
+
48
+ def revoke(self, scope: str) -> GrantResult:
49
+ return GrantResult(
50
+ self.name,
51
+ scope,
52
+ self.config_path(scope),
53
+ changed=False,
54
+ already=True,
55
+ )