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/_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")
|
cli/_harness/__init__.py
ADDED
|
@@ -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
|
+
)
|