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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Resolve and materialize the companion bundle source.
|
|
3
|
+
|
|
4
|
+
The companion step accepts three source shapes so the same code path
|
|
5
|
+
works for a checked-out bundle directory, a downloaded release tarball
|
|
6
|
+
(the real ``curl | sh`` installer fetches one), and the dev rig's
|
|
7
|
+
``companion-bundle/`` directory that holds a single built tarball:
|
|
8
|
+
|
|
9
|
+
- a directory containing ``SKILL.md`` — used as-is;
|
|
10
|
+
- a ``*.tar.gz`` file — extracted on demand;
|
|
11
|
+
- a directory holding exactly one
|
|
12
|
+
``logion-marketplace-companion-*.tar.gz`` — that tarball is extracted.
|
|
13
|
+
|
|
14
|
+
``materialize_bundle`` is a context manager so the extracted temp dir
|
|
15
|
+
stays alive for the whole install and is cleaned up afterwards.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import contextlib
|
|
22
|
+
import os
|
|
23
|
+
import tarfile
|
|
24
|
+
import tempfile
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from cli._local_state import get_home
|
|
29
|
+
|
|
30
|
+
_COMPANION_TARBALL_GLOB = "logion-marketplace-companion-*.tar.gz"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_valid_source(path: Path) -> bool:
|
|
34
|
+
"""A directory (validated downstream) or a ``*.tar.gz`` file."""
|
|
35
|
+
if path.is_dir():
|
|
36
|
+
return True
|
|
37
|
+
return path.is_file() and path.name.endswith(".tar.gz")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def locate_bundle_source(args: argparse.Namespace) -> Path | None:
|
|
41
|
+
"""Resolve the companion bundle source (a dir or a tarball).
|
|
42
|
+
|
|
43
|
+
Order: ``--companion-source`` flag →
|
|
44
|
+
``$LOGION_COMPANION_BUNDLE_SOURCE`` env →
|
|
45
|
+
newest dir under ``$LOGION_HOME/companion-bundles/`` → None.
|
|
46
|
+
|
|
47
|
+
The returned path may be a bundle directory, a companion ``*.tar.gz``
|
|
48
|
+
file, or a directory holding exactly one companion tarball;
|
|
49
|
+
``materialize_bundle`` normalizes all three to a bundle directory.
|
|
50
|
+
"""
|
|
51
|
+
source = getattr(args, "companion_source", None)
|
|
52
|
+
if source is not None:
|
|
53
|
+
path = Path(source)
|
|
54
|
+
return path if _is_valid_source(path) else None
|
|
55
|
+
|
|
56
|
+
env_source = os.environ.get("LOGION_COMPANION_BUNDLE_SOURCE")
|
|
57
|
+
if env_source:
|
|
58
|
+
path = Path(env_source)
|
|
59
|
+
if _is_valid_source(path):
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
bundles_root = get_home() / "companion-bundles"
|
|
63
|
+
if not bundles_root.is_dir():
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
# Newest dir by mtime; skip entries that can't be stat'ed (perms,
|
|
67
|
+
# broken mounts, transient FS errors) so onboarding never crashes.
|
|
68
|
+
newest: tuple[float, Path] | None = None
|
|
69
|
+
for entry in bundles_root.iterdir():
|
|
70
|
+
try:
|
|
71
|
+
if not entry.is_dir():
|
|
72
|
+
continue
|
|
73
|
+
mtime = entry.stat().st_mtime
|
|
74
|
+
except OSError:
|
|
75
|
+
continue
|
|
76
|
+
if newest is None or mtime > newest[0]:
|
|
77
|
+
newest = (mtime, entry)
|
|
78
|
+
return newest[1] if newest else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _find_companion_tarball(directory: Path) -> Path | None:
|
|
82
|
+
"""Return the single companion tarball in *directory*, else None."""
|
|
83
|
+
try:
|
|
84
|
+
tarballs = sorted(directory.glob(_COMPANION_TARBALL_GLOB))
|
|
85
|
+
except OSError:
|
|
86
|
+
return None
|
|
87
|
+
return tarballs[0] if len(tarballs) == 1 else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _bundle_root(extracted: Path) -> Path:
|
|
91
|
+
"""Return the dir holding ``SKILL.md`` after extraction.
|
|
92
|
+
|
|
93
|
+
``package_skill.py`` builds tarballs under a top-level
|
|
94
|
+
``logion-marketplace-companion-<version>/`` prefix, so descend into a
|
|
95
|
+
lone prefix subdir when ``SKILL.md`` isn't at the extraction root.
|
|
96
|
+
"""
|
|
97
|
+
if (extracted / "SKILL.md").is_file():
|
|
98
|
+
return extracted
|
|
99
|
+
subdirs = [p for p in extracted.iterdir() if p.is_dir()]
|
|
100
|
+
if len(subdirs) == 1 and (subdirs[0] / "SKILL.md").is_file():
|
|
101
|
+
return subdirs[0]
|
|
102
|
+
return extracted
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _tarball_for(source: Path) -> Path | None:
|
|
106
|
+
"""Return the tarball to extract for *source*, or None for a dir."""
|
|
107
|
+
if source.is_file() and source.name.endswith(".tar.gz"):
|
|
108
|
+
return source
|
|
109
|
+
if source.is_dir() and not (source / "SKILL.md").is_file():
|
|
110
|
+
return _find_companion_tarball(source)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@contextlib.contextmanager
|
|
115
|
+
def materialize_bundle(source: Path) -> Iterator[Path]:
|
|
116
|
+
"""Yield a bundle directory for *source*, extracting a tarball if needed.
|
|
117
|
+
|
|
118
|
+
A directory that already contains ``SKILL.md`` is yielded unchanged.
|
|
119
|
+
A tarball (or a directory holding exactly one companion tarball) is
|
|
120
|
+
extracted into a temporary directory — using ``tarfile``'s ``data``
|
|
121
|
+
filter for path-traversal safety — and the resolved bundle root is
|
|
122
|
+
yielded. The temp dir is removed when the context exits.
|
|
123
|
+
"""
|
|
124
|
+
tarball = _tarball_for(source)
|
|
125
|
+
if tarball is None:
|
|
126
|
+
# Plain directory; the install validates SKILL.md downstream and
|
|
127
|
+
# emits a clear error if it's missing.
|
|
128
|
+
yield source
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
with tempfile.TemporaryDirectory(prefix="logion-companion-") as tmp:
|
|
132
|
+
dest = Path(tmp)
|
|
133
|
+
with tarfile.open(tarball, "r:gz") as tf:
|
|
134
|
+
tf.extractall(dest, filter="data")
|
|
135
|
+
yield _bundle_root(dest)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Harness selection for ``identity onboarding``.
|
|
3
|
+
|
|
4
|
+
Resolves which harness adapters the onboarding flow should configure
|
|
5
|
+
(auto-review grant + companion install). The same selection drives both
|
|
6
|
+
steps so the two never diverge.
|
|
7
|
+
|
|
8
|
+
Resolution order:
|
|
9
|
+
- explicit ``--harness`` (repeatable) → exactly those adapters, no prompt;
|
|
10
|
+
- a TTY with at least one detected harness → an interactive multi-select
|
|
11
|
+
(nothing pre-selected — the user picks);
|
|
12
|
+
- otherwise (non-interactive, or no harness detected) → every detected
|
|
13
|
+
harness, preserving the prior non-interactive behaviour.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from cli._errors import print_err
|
|
22
|
+
from cli._harness import detect_present, get_adapter
|
|
23
|
+
from cli._harness.base import HarnessAdapter
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_indices(answer: str, count: int) -> list[int] | None:
|
|
27
|
+
"""Parse ``"1,3"`` / ``"1 3"`` into 0-based indices, or None if bad."""
|
|
28
|
+
tokens = answer.replace(",", " ").split()
|
|
29
|
+
out: list[int] = []
|
|
30
|
+
for tok in tokens:
|
|
31
|
+
if not tok.isdigit():
|
|
32
|
+
return None
|
|
33
|
+
idx = int(tok) - 1
|
|
34
|
+
if idx < 0 or idx >= count:
|
|
35
|
+
return None
|
|
36
|
+
if idx not in out:
|
|
37
|
+
out.append(idx)
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _prompt_selection(detected: list[HarnessAdapter]) -> list[HarnessAdapter]:
|
|
42
|
+
"""Interactively pick a subset of *detected*; empty input → none."""
|
|
43
|
+
print_err("\nDetected agent harnesses:")
|
|
44
|
+
for i, adapter in enumerate(detected, start=1):
|
|
45
|
+
print_err(f" {i}. {adapter.name}")
|
|
46
|
+
while True:
|
|
47
|
+
# Prompt on stderr (not via input()'s stdout prompt) so --json
|
|
48
|
+
# output on stdout stays machine-readable.
|
|
49
|
+
print_err("Select harness(es) to set up [e.g. 1,3; empty to skip]: ")
|
|
50
|
+
try:
|
|
51
|
+
answer = input().strip()
|
|
52
|
+
except EOFError:
|
|
53
|
+
return []
|
|
54
|
+
if not answer:
|
|
55
|
+
return []
|
|
56
|
+
indices = _parse_indices(answer, len(detected))
|
|
57
|
+
if indices is None:
|
|
58
|
+
print_err("Enter numbers from the list, e.g. 1,3.")
|
|
59
|
+
continue
|
|
60
|
+
return [detected[i] for i in indices]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def select_harnesses(args: argparse.Namespace) -> list[HarnessAdapter]:
|
|
64
|
+
"""Resolve the harness adapters to configure during onboarding.
|
|
65
|
+
|
|
66
|
+
Returns the resolved adapters (possibly empty). An explicit but
|
|
67
|
+
unknown ``--harness`` is validated earlier by
|
|
68
|
+
``validate_explicit_harness``; unknown names are dropped here so the
|
|
69
|
+
caller never crashes.
|
|
70
|
+
"""
|
|
71
|
+
requested = getattr(args, "harness", None)
|
|
72
|
+
if requested:
|
|
73
|
+
return [
|
|
74
|
+
adapter
|
|
75
|
+
for name in requested
|
|
76
|
+
if (adapter := get_adapter(name)) is not None
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
detected = detect_present()
|
|
80
|
+
if not detected:
|
|
81
|
+
return []
|
|
82
|
+
if not sys.stdin.isatty():
|
|
83
|
+
# Non-interactive: keep configuring every detected harness.
|
|
84
|
+
return detected
|
|
85
|
+
return _prompt_selection(detected)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Orchestration helpers for ``identity onboarding``.
|
|
3
|
+
|
|
4
|
+
Extracted from ``onboarding.py`` to keep that module under the CLI's
|
|
5
|
+
per-file source-size budget (250 lines). These functions coordinate
|
|
6
|
+
the companion and harness-validation steps; they are not part of the
|
|
7
|
+
``_companion`` module because they are onboarding-flow glue, not
|
|
8
|
+
companion-install primitives.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from cli._errors import print_err
|
|
17
|
+
from cli._harness import adapter_names, get_adapter
|
|
18
|
+
from cli._harness.base import HarnessAdapter
|
|
19
|
+
|
|
20
|
+
from ._companion import (
|
|
21
|
+
COMPANION_COURSE_ID,
|
|
22
|
+
CompanionInstallError,
|
|
23
|
+
CompanionNotFoundError,
|
|
24
|
+
CompanionResult,
|
|
25
|
+
install_companion,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def empty_companion_summary() -> dict[str, object]:
|
|
30
|
+
return {
|
|
31
|
+
"installed": False,
|
|
32
|
+
"skill_dir": None,
|
|
33
|
+
"course_id": None,
|
|
34
|
+
"version_id": None,
|
|
35
|
+
"already": False,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_explicit_harness(args: argparse.Namespace) -> int | None:
|
|
40
|
+
"""Return an exit code if any explicit ``--harness`` is invalid.
|
|
41
|
+
|
|
42
|
+
``--harness`` is repeatable, so every requested name is validated.
|
|
43
|
+
Validated whenever ``--harness`` is set, *regardless of*
|
|
44
|
+
``--agent-dir``: ``--agent-dir`` only overrides the companion
|
|
45
|
+
target, but ``--harness`` still drives the autopost grant and the
|
|
46
|
+
harness selection.
|
|
47
|
+
"""
|
|
48
|
+
requested = getattr(args, "harness", None) or []
|
|
49
|
+
for name in requested:
|
|
50
|
+
if get_adapter(name) is None:
|
|
51
|
+
print_err(
|
|
52
|
+
f"Error: unknown harness '{name}'. "
|
|
53
|
+
f"Supported: {', '.join(adapter_names())}."
|
|
54
|
+
)
|
|
55
|
+
return 2
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_companion_step(
|
|
60
|
+
args: argparse.Namespace,
|
|
61
|
+
adapters: list[HarnessAdapter],
|
|
62
|
+
) -> tuple[dict[str, object], int | None]:
|
|
63
|
+
"""Run the companion install step.
|
|
64
|
+
|
|
65
|
+
Returns ``(summary_dict, exit_code_or_none)``. A non-None exit
|
|
66
|
+
code means the caller should return it immediately.
|
|
67
|
+
|
|
68
|
+
*adapters* is the harness selection resolved once by
|
|
69
|
+
``select_harnesses``; the companion is installed into each. An
|
|
70
|
+
explicit ``--agent-dir`` overrides the target to that single skill
|
|
71
|
+
dir (a ``CustomPathHarness``), independent of the selection.
|
|
72
|
+
"""
|
|
73
|
+
if getattr(args, "no_companion", False):
|
|
74
|
+
return empty_companion_summary(), None
|
|
75
|
+
|
|
76
|
+
targets = companion_targets(args, adapters)
|
|
77
|
+
|
|
78
|
+
if not targets:
|
|
79
|
+
print_err(
|
|
80
|
+
"No agent harness selected, so the companion bundle was not "
|
|
81
|
+
"installed. Re-run with --harness <name> or --agent-dir <path>."
|
|
82
|
+
)
|
|
83
|
+
return empty_companion_summary(), None
|
|
84
|
+
adapters = targets
|
|
85
|
+
|
|
86
|
+
# Install the companion into each resolved adapter. The canonical
|
|
87
|
+
# install (manifest + content hash) happens once per (course_id,
|
|
88
|
+
# version_id); subsequent adapters just get a symlink.
|
|
89
|
+
summaries: list[dict[str, object]] = []
|
|
90
|
+
for adapter in adapters:
|
|
91
|
+
try:
|
|
92
|
+
companion = install_companion(args, adapter)
|
|
93
|
+
except CompanionNotFoundError as exc:
|
|
94
|
+
print_err(
|
|
95
|
+
f"Warning: companion not installed for {adapter.name}: {exc}"
|
|
96
|
+
)
|
|
97
|
+
companion = CompanionResult(
|
|
98
|
+
installed=False,
|
|
99
|
+
skill_dir=None,
|
|
100
|
+
course_id=None,
|
|
101
|
+
version_id=None,
|
|
102
|
+
already=False,
|
|
103
|
+
)
|
|
104
|
+
except CompanionInstallError as exc:
|
|
105
|
+
print_err(
|
|
106
|
+
f"Error: companion install failed for {adapter.name}: {exc}"
|
|
107
|
+
)
|
|
108
|
+
return empty_companion_summary(), 2
|
|
109
|
+
summaries.append(companion.to_dict())
|
|
110
|
+
|
|
111
|
+
# The JSON summary mirrors the single-harness shape for backwards
|
|
112
|
+
# compatibility: the first adapter's result, with a ``harnesses``
|
|
113
|
+
# list when more than one was targeted.
|
|
114
|
+
result = dict(summaries[0])
|
|
115
|
+
if len(summaries) > 1:
|
|
116
|
+
result["harnesses"] = summaries
|
|
117
|
+
return result, None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def ensure_symlink(
|
|
121
|
+
adapter: HarnessAdapter,
|
|
122
|
+
install_dest: Path,
|
|
123
|
+
skill_name: str | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Symlink ``install_dest`` into ``adapter.skill_dir()``.
|
|
126
|
+
|
|
127
|
+
Uses the existing ``create_symlink`` helper so we share the same
|
|
128
|
+
replace-prior-link/refuse-real-directory behaviour as
|
|
129
|
+
``logion skills install --symlink-dir``.
|
|
130
|
+
|
|
131
|
+
A symlink failure is **non-fatal and never raises**: it is surfaced
|
|
132
|
+
as a warning on stderr (mirroring ``apply_post_install_symlink``),
|
|
133
|
+
so onboarding does not crash — or corrupt ``--json`` with a
|
|
134
|
+
traceback — when the target is a real directory or otherwise
|
|
135
|
+
unwritable. The canonical install under ``$LOGION_HOME/installed/``
|
|
136
|
+
is valid regardless.
|
|
137
|
+
"""
|
|
138
|
+
from cli.commands.skills._agent_symlink import create_symlink
|
|
139
|
+
|
|
140
|
+
skill_name = skill_name or COMPANION_COURSE_ID
|
|
141
|
+
target_skill_dir = adapter.skill_dir()
|
|
142
|
+
try:
|
|
143
|
+
create_symlink(target_skill_dir, skill_name, install_dest)
|
|
144
|
+
except FileExistsError as exc:
|
|
145
|
+
print_err(f"Warning: companion symlink skipped: {exc}")
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
print_err(
|
|
148
|
+
f"Warning: companion symlink failed ({exc}); "
|
|
149
|
+
"canonical install is fine."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def companion_targets(
|
|
154
|
+
args: argparse.Namespace,
|
|
155
|
+
adapters: list[HarnessAdapter],
|
|
156
|
+
) -> list[HarnessAdapter]:
|
|
157
|
+
"""Resolve the adapter(s) for the companion step.
|
|
158
|
+
|
|
159
|
+
``--agent-dir`` overrides the resolved selection with a single
|
|
160
|
+
``CustomPathHarness``; otherwise the shared *adapters* selection is
|
|
161
|
+
used as-is.
|
|
162
|
+
"""
|
|
163
|
+
agent_dir = getattr(args, "agent_dir", None)
|
|
164
|
+
if agent_dir:
|
|
165
|
+
from cli._harness.custom import CustomPathHarness
|
|
166
|
+
|
|
167
|
+
return [CustomPathHarness(Path(agent_dir).expanduser())]
|
|
168
|
+
return adapters
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Handlers for identity commands."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import getpass
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from cli._config import resolve_config_from_args
|
|
12
|
+
from cli._context import make_client
|
|
13
|
+
from cli._credentials import save_user_identity, stored_user_id
|
|
14
|
+
from cli._errors import handle_error, print_err
|
|
15
|
+
from cli._output import emit
|
|
16
|
+
|
|
17
|
+
API_KEY_WARNING = (
|
|
18
|
+
"Important: save the API key now — it will not be shown again."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_password(cli_value: str | None) -> str | None:
|
|
23
|
+
"""Return the resolved password, or ``None`` on validation failure."""
|
|
24
|
+
if cli_value is not None:
|
|
25
|
+
if not cli_value.strip():
|
|
26
|
+
print_err("Error: --password must not be empty.")
|
|
27
|
+
return None
|
|
28
|
+
if cli_value != cli_value.strip():
|
|
29
|
+
print_err(
|
|
30
|
+
"Warning: --password has leading/trailing whitespace — "
|
|
31
|
+
"this is intentional, but may be a shell quoting mistake."
|
|
32
|
+
)
|
|
33
|
+
return cli_value
|
|
34
|
+
raw_env = os.environ.get("LOGION_PASSWORD")
|
|
35
|
+
if raw_env is not None:
|
|
36
|
+
if not raw_env.strip():
|
|
37
|
+
print_err("Error: LOGION_PASSWORD is set but empty/whitespace.")
|
|
38
|
+
return None
|
|
39
|
+
if raw_env != raw_env.strip():
|
|
40
|
+
print_err(
|
|
41
|
+
"Warning: LOGION_PASSWORD has leading/trailing whitespace — "
|
|
42
|
+
"this is intentional, but may be an environment setup mistake."
|
|
43
|
+
)
|
|
44
|
+
return raw_env
|
|
45
|
+
if sys.stdin.isatty():
|
|
46
|
+
password = getpass.getpass("Logion Password: ")
|
|
47
|
+
if not password.strip():
|
|
48
|
+
print_err("Error: password must not be empty.")
|
|
49
|
+
return None
|
|
50
|
+
return password
|
|
51
|
+
print_err(
|
|
52
|
+
"Error: password is required in non-interactive mode "
|
|
53
|
+
"(use --password or set LOGION_PASSWORD)."
|
|
54
|
+
)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _field(obj: object, name: str) -> object:
|
|
59
|
+
"""Read *name* from a dict or attribute-style response object."""
|
|
60
|
+
if isinstance(obj, dict):
|
|
61
|
+
return obj.get(name)
|
|
62
|
+
return getattr(obj, name, None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _save_user_identity_from_result(result: object) -> None:
|
|
66
|
+
"""Persist the created user's id/email; never fail the command."""
|
|
67
|
+
user = _field(result, "user")
|
|
68
|
+
if user is None:
|
|
69
|
+
return
|
|
70
|
+
user_id = _field(user, "id")
|
|
71
|
+
if user_id is None:
|
|
72
|
+
return
|
|
73
|
+
email = _field(user, "email")
|
|
74
|
+
try:
|
|
75
|
+
save_user_identity(
|
|
76
|
+
str(user_id),
|
|
77
|
+
email=str(email) if email is not None else None,
|
|
78
|
+
)
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
print_err(f"Warning: could not save credentials: {exc}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve_user_id(cli_value: str | None) -> str | None:
|
|
84
|
+
"""Return ``--user-id`` or the stored credential, ``None`` if neither."""
|
|
85
|
+
if cli_value is not None:
|
|
86
|
+
return cli_value
|
|
87
|
+
stored = stored_user_id()
|
|
88
|
+
if stored is not None:
|
|
89
|
+
return stored
|
|
90
|
+
print_err(
|
|
91
|
+
"Error: --user-id is required (no stored user found — run "
|
|
92
|
+
"`logion identity users-create` or pass --user-id)."
|
|
93
|
+
)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def handle_users_create(args: argparse.Namespace) -> int:
|
|
98
|
+
"""Execute the identity users-create command."""
|
|
99
|
+
password = _resolve_password(args.password)
|
|
100
|
+
if password is None:
|
|
101
|
+
return 2
|
|
102
|
+
config = resolve_config_from_args(args)
|
|
103
|
+
client = make_client(config)
|
|
104
|
+
try:
|
|
105
|
+
result = client.v1.identity.create_user_with_agent(
|
|
106
|
+
email=args.email,
|
|
107
|
+
user_password=password,
|
|
108
|
+
agent_name=args.agent_name,
|
|
109
|
+
user_name=args.user_name,
|
|
110
|
+
agent_description=args.agent_description,
|
|
111
|
+
)
|
|
112
|
+
_save_user_identity_from_result(result)
|
|
113
|
+
print_err(API_KEY_WARNING)
|
|
114
|
+
emit(result, json_output=config.json_output)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
return handle_error(exc)
|
|
117
|
+
else:
|
|
118
|
+
return 0
|
|
119
|
+
finally:
|
|
120
|
+
client.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def handle_agents_add(args: argparse.Namespace) -> int:
|
|
124
|
+
"""Execute the identity agents-add command."""
|
|
125
|
+
user_id = _resolve_user_id(args.user_id)
|
|
126
|
+
if user_id is None:
|
|
127
|
+
return 2
|
|
128
|
+
password = _resolve_password(args.password)
|
|
129
|
+
if password is None:
|
|
130
|
+
return 2
|
|
131
|
+
config = resolve_config_from_args(args)
|
|
132
|
+
client = make_client(config)
|
|
133
|
+
try:
|
|
134
|
+
result = client.v1.identity.add_agent_to_user(
|
|
135
|
+
user_id=user_id,
|
|
136
|
+
agent_name=args.agent_name,
|
|
137
|
+
user_password=password,
|
|
138
|
+
agent_description=args.agent_description,
|
|
139
|
+
)
|
|
140
|
+
print_err(API_KEY_WARNING)
|
|
141
|
+
emit(result, json_output=config.json_output)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
return handle_error(exc)
|
|
144
|
+
else:
|
|
145
|
+
return 0
|
|
146
|
+
finally:
|
|
147
|
+
client.close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def handle_agents_rotate_key(args: argparse.Namespace) -> int:
|
|
151
|
+
"""Execute the identity agents-rotate-key command."""
|
|
152
|
+
user_id = _resolve_user_id(args.user_id)
|
|
153
|
+
if user_id is None:
|
|
154
|
+
return 2
|
|
155
|
+
password = _resolve_password(args.password)
|
|
156
|
+
if password is None:
|
|
157
|
+
return 2
|
|
158
|
+
config = resolve_config_from_args(args)
|
|
159
|
+
client = make_client(config)
|
|
160
|
+
try:
|
|
161
|
+
result = client.v1.identity.rotate_api_key(
|
|
162
|
+
user_id=user_id,
|
|
163
|
+
agent_id=args.agent_id,
|
|
164
|
+
user_password=password,
|
|
165
|
+
)
|
|
166
|
+
print_err(API_KEY_WARNING)
|
|
167
|
+
emit(result, json_output=config.json_output)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
return handle_error(exc)
|
|
170
|
+
else:
|
|
171
|
+
return 0
|
|
172
|
+
finally:
|
|
173
|
+
client.close()
|