logion-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. cli/__init__.py +2 -0
  2. cli/_config.py +51 -0
  3. cli/_confirm.py +16 -0
  4. cli/_context.py +17 -0
  5. cli/_course_bundle.py +46 -0
  6. cli/_course_capabilities.py +580 -0
  7. cli/_credentials.py +104 -0
  8. cli/_errors.py +82 -0
  9. cli/_first_run.py +90 -0
  10. cli/_harness/__init__.py +68 -0
  11. cli/_harness/base.py +106 -0
  12. cli/_harness/claude_code.py +168 -0
  13. cli/_harness/codex.py +79 -0
  14. cli/_harness/custom.py +55 -0
  15. cli/_harness/hermes.py +93 -0
  16. cli/_harness/opencode.py +255 -0
  17. cli/_local_state.py +1053 -0
  18. cli/_options.py +36 -0
  19. cli/_output.py +47 -0
  20. cli/_parser.py +73 -0
  21. cli/_recall_calibration.py +90 -0
  22. cli/_recall_ranker.py +74 -0
  23. cli/_taxonomy.py +120 -0
  24. cli/_update_policy.py +152 -0
  25. cli/_utils.py +16 -0
  26. cli/_version.py +26 -0
  27. cli/commands/__init__.py +2 -0
  28. cli/commands/admin.py +535 -0
  29. cli/commands/bounties.py +490 -0
  30. cli/commands/course_reviews/__init__.py +6 -0
  31. cli/commands/course_reviews/_download_handler.py +104 -0
  32. cli/commands/course_reviews/_render.py +129 -0
  33. cli/commands/course_reviews/handlers.py +197 -0
  34. cli/commands/course_reviews/parser.py +93 -0
  35. cli/commands/courses/__init__.py +6 -0
  36. cli/commands/courses/_capability_render.py +183 -0
  37. cli/commands/courses/_cmd_help.py +18 -0
  38. cli/commands/courses/_purchase.py +76 -0
  39. cli/commands/courses/_review_helpers.py +93 -0
  40. cli/commands/courses/_taxonomy_data.py +173 -0
  41. cli/commands/courses/_upload_bundle_validation.py +28 -0
  42. cli/commands/courses/_uploads_push.py +243 -0
  43. cli/commands/courses/capabilities.py +250 -0
  44. cli/commands/courses/capability_frontmatter.py +150 -0
  45. cli/commands/courses/handlers.py +50 -0
  46. cli/commands/courses/mutations.py +217 -0
  47. cli/commands/courses/parser.py +66 -0
  48. cli/commands/courses/parser_capabilities.py +95 -0
  49. cli/commands/courses/parser_sections.py +239 -0
  50. cli/commands/courses/parser_uploads.py +84 -0
  51. cli/commands/courses/parser_utils.py +65 -0
  52. cli/commands/courses/publication.py +60 -0
  53. cli/commands/courses/report_usage.py +131 -0
  54. cli/commands/courses/reviews.py +237 -0
  55. cli/commands/courses/taxonomy_handler.py +61 -0
  56. cli/commands/courses/taxonomy_suggest.py +197 -0
  57. cli/commands/courses/uploads.py +142 -0
  58. cli/commands/courses/versions.py +65 -0
  59. cli/commands/credits/__init__.py +6 -0
  60. cli/commands/credits/_helpers.py +153 -0
  61. cli/commands/credits/handlers.py +218 -0
  62. cli/commands/credits/parser.py +115 -0
  63. cli/commands/docs/__init__.py +6 -0
  64. cli/commands/docs/handlers.py +137 -0
  65. cli/commands/docs/parser.py +27 -0
  66. cli/commands/health/__init__.py +6 -0
  67. cli/commands/health/handlers.py +26 -0
  68. cli/commands/health/parser.py +20 -0
  69. cli/commands/identity/__init__.py +6 -0
  70. cli/commands/identity/_autopost.py +97 -0
  71. cli/commands/identity/_closing_copy.py +89 -0
  72. cli/commands/identity/_companion.py +232 -0
  73. cli/commands/identity/_companion_source.py +135 -0
  74. cli/commands/identity/_harness_select.py +85 -0
  75. cli/commands/identity/_onboarding_helpers.py +168 -0
  76. cli/commands/identity/handlers.py +173 -0
  77. cli/commands/identity/onboarding.py +246 -0
  78. cli/commands/identity/parser.py +72 -0
  79. cli/commands/listings/__init__.py +6 -0
  80. cli/commands/listings/handlers.py +135 -0
  81. cli/commands/listings/parser.py +57 -0
  82. cli/commands/notifications/__init__.py +6 -0
  83. cli/commands/notifications/handlers.py +120 -0
  84. cli/commands/notifications/parser.py +49 -0
  85. cli/commands/payments/__init__.py +6 -0
  86. cli/commands/payments/_orders_helpers.py +114 -0
  87. cli/commands/payments/handlers.py +138 -0
  88. cli/commands/payments/parser.py +97 -0
  89. cli/commands/recall/__init__.py +7 -0
  90. cli/commands/recall/handlers.py +87 -0
  91. cli/commands/recall/parser.py +70 -0
  92. cli/commands/referrals/__init__.py +6 -0
  93. cli/commands/referrals/_helpers.py +63 -0
  94. cli/commands/referrals/handlers.py +100 -0
  95. cli/commands/referrals/parser.py +65 -0
  96. cli/commands/reports/__init__.py +6 -0
  97. cli/commands/reports/handlers.py +57 -0
  98. cli/commands/reports/parser.py +52 -0
  99. cli/commands/skills/__init__.py +7 -0
  100. cli/commands/skills/_agent_symlink.py +161 -0
  101. cli/commands/skills/_finalize.py +112 -0
  102. cli/commands/skills/_inspect_handler.py +218 -0
  103. cli/commands/skills/_install_helpers.py +186 -0
  104. cli/commands/skills/_query_handlers.py +83 -0
  105. cli/commands/skills/_search_handler.py +136 -0
  106. cli/commands/skills/_update_handler.py +110 -0
  107. cli/commands/skills/_verify_handler.py +109 -0
  108. cli/commands/skills/handlers.py +202 -0
  109. cli/commands/skills/parser.py +154 -0
  110. cli/commands/workspace.py +406 -0
  111. cli/docs/README.md +5 -0
  112. cli/docs/__init__.py +1 -0
  113. cli/docs/bounties-and-referrals.md +18 -0
  114. cli/docs/concepts.md +47 -0
  115. cli/docs/creating-courses.md +25 -0
  116. cli/docs/credits-and-purchases.md +30 -0
  117. cli/docs/credits-terms.md +23 -0
  118. cli/docs/getting-started.md +95 -0
  119. cli/docs/marketplace-loop.md +108 -0
  120. cli/docs/privacy.md +30 -0
  121. cli/docs/referral-terms.md +24 -0
  122. cli/docs/reviews.md +47 -0
  123. cli/docs/safety.md +28 -0
  124. cli/docs/terms.md +54 -0
  125. cli/main.py +84 -0
  126. cli/templates/__init__.py +2 -0
  127. cli/templates/course_capabilities.template.yaml +189 -0
  128. cli/templates/course_license_apache-2.0.template.txt +30 -0
  129. cli/templates/course_license_logion-standard-course-v1.template.txt +49 -0
  130. cli/templates/course_license_mit.template.txt +21 -0
  131. logion_cli-0.1.0.dist-info/METADATA +49 -0
  132. logion_cli-0.1.0.dist-info/RECORD +135 -0
  133. logion_cli-0.1.0.dist-info/WHEEL +4 -0
  134. logion_cli-0.1.0.dist-info/entry_points.txt +4 -0
  135. logion_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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()