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,142 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Upload handlers for courses commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import collections
8
+ import mimetypes
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from cli._config import resolve_config_from_args
13
+ from cli._context import make_client
14
+ from cli._errors import handle_error, print_err, validate_uuid_id
15
+ from cli._output import emit, emit_json, to_data
16
+ from cli.commands.courses._capability_render import (
17
+ append_capability_summary_lines,
18
+ )
19
+
20
+
21
+ def _parse_upload_file_spec(spec: str) -> tuple[str, Path]:
22
+ """Return ``(upload_path, file_path)`` from a ``--file`` argument."""
23
+ if "=" not in spec:
24
+ file_path = Path(spec)
25
+ return (file_path.name, file_path)
26
+
27
+ upload_path, file_path_str = spec.split("=", 1)
28
+ if not upload_path.strip():
29
+ raise ValueError("upload path before '=' must not be empty")
30
+ return (upload_path, Path(file_path_str))
31
+
32
+
33
+ def _resolve_upload_files(
34
+ file_specs: list[str],
35
+ ) -> list[tuple[str, Path]] | None:
36
+ resolved: list[tuple[str, Path]] = []
37
+ for file_spec in file_specs:
38
+ try:
39
+ upload_path, path = _parse_upload_file_spec(file_spec)
40
+ except ValueError as exc:
41
+ print_err(f"Error: {exc}")
42
+ return None
43
+ if not path.is_file():
44
+ print_err(f"file not found: {path}")
45
+ return None
46
+ resolved.append((upload_path, path))
47
+
48
+ duplicates = [
49
+ name
50
+ for name, count in collections.Counter(
51
+ upload_path for upload_path, _ in resolved
52
+ ).items()
53
+ if count > 1
54
+ ]
55
+ if duplicates:
56
+ print_err(
57
+ f"duplicate file names not allowed: {sorted(set(duplicates))}"
58
+ )
59
+ return None
60
+ return resolved
61
+
62
+
63
+ def handle_uploads_create(args: argparse.Namespace) -> int:
64
+ """Execute the courses uploads create command."""
65
+ bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
66
+ if bad_id is not None:
67
+ return bad_id
68
+ if not args.files:
69
+ print_err("Error: at least one --file is required")
70
+ return 2
71
+
72
+ resolved = _resolve_upload_files(args.files)
73
+ if resolved is None:
74
+ return 2
75
+
76
+ config = resolve_config_from_args(args)
77
+ client = make_client(config)
78
+ try:
79
+ files = [
80
+ {
81
+ "filename": upload_path,
82
+ "size_bytes": path.stat().st_size,
83
+ "content_type": mimetypes.guess_type(str(path))[0]
84
+ or "application/octet-stream",
85
+ }
86
+ for upload_path, path in resolved
87
+ ]
88
+ result = client.v1.courses.create_upload_session(
89
+ course_id=args.course_id,
90
+ files=files,
91
+ )
92
+ if config.json_output:
93
+ emit_json("logion.courses.uploads.create", to_data(result))
94
+ else:
95
+ emit(result, json_output=False)
96
+ except Exception as exc:
97
+ return handle_error(exc)
98
+ else:
99
+ return 0
100
+ finally:
101
+ client.close()
102
+
103
+
104
+ def handle_uploads_complete(args: argparse.Namespace) -> int:
105
+ """Execute the courses uploads complete command."""
106
+ bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
107
+ if bad_id is not None:
108
+ return bad_id
109
+ bad_id = validate_uuid_id(args.version_id, "VERSION_ID")
110
+ if bad_id is not None:
111
+ return bad_id
112
+ config = resolve_config_from_args(args)
113
+ client = make_client(config)
114
+ try:
115
+ result = client.v1.courses.complete_upload_session(
116
+ course_id=args.course_id,
117
+ version_id=args.version_id,
118
+ )
119
+ if config.json_output:
120
+ emit_json("logion.courses.uploads.complete", to_data(result))
121
+ else:
122
+ data = to_data(result)
123
+ lines: list[str] = [
124
+ f"version_id: {data['version_id']}",
125
+ f"version_number: {data['version_number']}",
126
+ f"status: {data['status']}",
127
+ ]
128
+ manifest_s3_key = data.get("manifest_s3_key")
129
+ if manifest_s3_key:
130
+ lines.append(f"manifest_s3_key: {manifest_s3_key}")
131
+ content_hash = data.get("content_hash")
132
+ if content_hash:
133
+ lines.append(f"content_hash: {content_hash}")
134
+ append_capability_summary_lines(lines, data)
135
+ sys.stdout.write("\n".join(lines))
136
+ sys.stdout.write("\n")
137
+ except Exception as exc:
138
+ return handle_error(exc)
139
+ else:
140
+ return 0
141
+ finally:
142
+ client.close()
@@ -0,0 +1,65 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Version handlers for courses commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+
9
+ from cli._config import resolve_config_from_args
10
+ from cli._context import make_client
11
+ from cli._errors import handle_error, validate_uuid_id
12
+ from cli._output import emit, to_data
13
+ from cli.commands.courses._capability_render import (
14
+ append_approved_capability_summary_lines,
15
+ append_capability_summary_lines,
16
+ )
17
+
18
+
19
+ def handle_versions_get(args: argparse.Namespace) -> int:
20
+ """Execute the courses versions get command."""
21
+ bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
22
+ if bad_id is not None:
23
+ return bad_id
24
+ bad_id = validate_uuid_id(args.version_id, "VERSION_ID")
25
+ if bad_id is not None:
26
+ return bad_id
27
+ config = resolve_config_from_args(args)
28
+ client = make_client(config)
29
+ try:
30
+ result = client.v1.courses.get_version(
31
+ course_id=args.course_id,
32
+ version_id=args.version_id,
33
+ )
34
+ if config.json_output:
35
+ emit(result, json_output=True)
36
+ else:
37
+ data = to_data(result)
38
+ lines: list[str] = [
39
+ f"id: {data['id']}",
40
+ f"course_id: {data['course_id']}",
41
+ f"version_number: {data['version_number']}",
42
+ f"status: {data['status']}",
43
+ ]
44
+ manifest_s3_key = data.get("manifest_s3_key")
45
+ if manifest_s3_key:
46
+ lines.append(f"manifest_s3_key: {manifest_s3_key}")
47
+ content_hash = data.get("content_hash")
48
+ if content_hash:
49
+ lines.append(f"content_hash: {content_hash}")
50
+ created_at = data.get("created_at")
51
+ if created_at:
52
+ lines.append(f"created_at: {created_at}")
53
+ created_by = data.get("created_by_agent_id")
54
+ if created_by:
55
+ lines.append(f"created_by_agent_id: {created_by}")
56
+ append_capability_summary_lines(lines, data)
57
+ append_approved_capability_summary_lines(lines, data)
58
+ sys.stdout.write("\n".join(lines))
59
+ sys.stdout.write("\n")
60
+ except Exception as exc:
61
+ return handle_error(exc)
62
+ else:
63
+ return 0
64
+ finally:
65
+ client.close()
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Credits command package."""
3
+
4
+ from .parser import register
5
+
6
+ __all__ = ["register"]
@@ -0,0 +1,153 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Shared helpers for credits top-up commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ import time
9
+ from typing import Any
10
+ from uuid import UUID
11
+
12
+ from cli._config import CliConfig
13
+ from cli._errors import emit_error_json
14
+ from cli._output import emit_json
15
+
16
+ TERMINAL_STATUSES = frozenset({
17
+ "paid",
18
+ "failed",
19
+ "expired",
20
+ "cancelled",
21
+ "disputed",
22
+ "reversed",
23
+ })
24
+
25
+
26
+ def invalid_identifier(
27
+ args: argparse.Namespace,
28
+ label: str,
29
+ value: str,
30
+ exit_code: int = 2,
31
+ ) -> int:
32
+ """Emit an unsafe identifier error envelope when JSON was requested."""
33
+ message = f"{label} must be a valid UUID (got: {value!r})."
34
+ if getattr(args, "json_output", False):
35
+ emit_error_json("unsafe_identifier", message, exit_code)
36
+ else:
37
+ sys.stderr.write(f"Error: {message}\n")
38
+ return exit_code
39
+
40
+
41
+ def validate_uuid_arg(
42
+ args: argparse.Namespace, value: str, label: str
43
+ ) -> int | None:
44
+ """Validate a UUID positional argument."""
45
+ if not value or not value.strip():
46
+ return invalid_identifier(args, label, value)
47
+ try:
48
+ UUID(value)
49
+ except ValueError:
50
+ return invalid_identifier(args, label, value)
51
+ return None
52
+
53
+
54
+ def top_up_to_payload(result: Any) -> dict[str, Any]:
55
+ """Normalize a CreateCreditTopUpResponse to the CLI shape."""
56
+ raw = (
57
+ result.model_dump(mode="json")
58
+ if hasattr(result, "model_dump")
59
+ else dict(result)
60
+ )
61
+ payload: dict[str, Any] = {
62
+ "top_up_id": raw.get("top_up_id"),
63
+ "status": raw.get("status"),
64
+ "amount_cents": raw.get("amount_cents"),
65
+ "credit_cents_granted": raw.get("credit_cents_granted"),
66
+ "checkout_url": raw.get("checkout_url"),
67
+ "stripe_checkout_session_id": raw.get("stripe_checkout_session_id"),
68
+ }
69
+ for key, value in raw.items():
70
+ payload.setdefault(key, value)
71
+ return payload
72
+
73
+
74
+ def emit_top_up_human(payload: dict[str, Any]) -> None:
75
+ """Render a top-up payload as human-readable key: value lines."""
76
+ lines = [
77
+ f"top_up_id: {payload.get('top_up_id')}",
78
+ f"status: {payload.get('status')}",
79
+ f"amount_cents: {payload.get('amount_cents')}",
80
+ f"credit_cents_granted: {payload.get('credit_cents_granted')}",
81
+ ]
82
+ if payload.get("checkout_url"):
83
+ lines.append(f"checkout_url: {payload.get('checkout_url')}")
84
+ if payload.get("stripe_checkout_session_id"):
85
+ val = payload.get("stripe_checkout_session_id")
86
+ lines.append(f"stripe_checkout_session_id: {val}")
87
+ sys.stdout.write("\n".join(lines))
88
+ sys.stdout.write("\n")
89
+
90
+
91
+ def emit_wait_result(
92
+ config: CliConfig,
93
+ payload: dict[str, Any],
94
+ elapsed: float,
95
+ *,
96
+ final: bool,
97
+ ) -> None:
98
+ """Emit the current wait-state payload for a top-up."""
99
+ wait_payload: dict[str, Any] = {
100
+ **payload,
101
+ "elapsed_seconds": round(elapsed, 2),
102
+ "terminal": payload.get("status") in TERMINAL_STATUSES,
103
+ "final": final,
104
+ }
105
+ if config.json_output:
106
+ emit_json("logion.credits.top-ups.wait", wait_payload)
107
+ sys.stdout.write("\n")
108
+ return
109
+ summary = (
110
+ f"Top-up {payload.get('top_up_id')}: "
111
+ f"status={payload.get('status')} (elapsed {int(elapsed)}s)"
112
+ )
113
+ sys.stdout.write(summary)
114
+ sys.stdout.write("\n")
115
+
116
+
117
+ def timeout_payload(top_up_id: str) -> dict[str, Any]:
118
+ """Return the fallback payload for an unfinished wait call."""
119
+ return {
120
+ "top_up_id": top_up_id,
121
+ "status": "unknown",
122
+ "amount_cents": None,
123
+ "credit_cents_granted": None,
124
+ "checkout_url": None,
125
+ "stripe_checkout_session_id": None,
126
+ }
127
+
128
+
129
+ def resolve_wait(
130
+ config: CliConfig,
131
+ last_payload: dict[str, Any] | None,
132
+ start: float,
133
+ top_up_id: str,
134
+ timeout: int,
135
+ ) -> int:
136
+ """Determine the final wait result and emit it."""
137
+ elapsed = time.monotonic() - start
138
+ payload = last_payload or timeout_payload(top_up_id)
139
+
140
+ if payload.get("status") == "paid":
141
+ emit_wait_result(config, payload, elapsed, final=True)
142
+ return 0
143
+
144
+ if payload.get("status") in TERMINAL_STATUSES:
145
+ emit_wait_result(config, payload, elapsed, final=True)
146
+ return 1
147
+
148
+ msg = f"Top-up {top_up_id} did not reach terminal state within {timeout}s"
149
+ if config.json_output:
150
+ emit_error_json("top_up_timeout", msg, 2)
151
+ else:
152
+ sys.stderr.write(f"ERROR: {msg}\n")
153
+ return 2
@@ -0,0 +1,218 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Handlers for credits commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ import time
9
+ from typing import Any
10
+
11
+ from cli._config import CliConfig, resolve_config_from_args
12
+ from cli._confirm import require_yes
13
+ from cli._context import make_client
14
+ from cli._errors import handle_error
15
+ from cli._output import emit_json, to_data
16
+ from logion import LogionClient
17
+
18
+ from ._helpers import (
19
+ TERMINAL_STATUSES,
20
+ emit_top_up_human,
21
+ emit_wait_result,
22
+ resolve_wait,
23
+ top_up_to_payload,
24
+ validate_uuid_arg,
25
+ )
26
+
27
+
28
+ def _run(
29
+ args: argparse.Namespace,
30
+ fn,
31
+ kind: str,
32
+ *,
33
+ json_output: bool,
34
+ render=None,
35
+ ):
36
+ """Call *fn* on the credits resource and emit the result."""
37
+ config = resolve_config_from_args(args)
38
+ client = make_client(config)
39
+ try:
40
+ result = fn(client.v1.credits)
41
+ if json_output:
42
+ emit_json(f"logion.credits.{kind}", to_data(result))
43
+ elif render:
44
+ render(result)
45
+ except Exception as exc:
46
+ return handle_error(exc)
47
+ else:
48
+ return 0
49
+ finally:
50
+ client.close()
51
+
52
+
53
+ def _render_balance(result: object) -> None:
54
+ data = to_data(result)
55
+ lines = [
56
+ f"balance_cents: {data.get('balance_cents')}",
57
+ f"currency_code: {data.get('currency_code', 'USD_CREDIT')}",
58
+ ]
59
+ sys.stdout.write("\n".join(lines) + "\n")
60
+
61
+
62
+ def handle_credits_balance(args: argparse.Namespace) -> int:
63
+ """Execute the credits balance command."""
64
+ config = resolve_config_from_args(args)
65
+ return _run(
66
+ args,
67
+ lambda c: c.get_balance(),
68
+ "balance",
69
+ json_output=config.json_output,
70
+ render=_render_balance,
71
+ )
72
+
73
+
74
+ def handle_credits_top_up(args: argparse.Namespace) -> int:
75
+ """Execute the credits top-up command."""
76
+ refusal = require_yes(
77
+ args.yes,
78
+ "create this credit top-up checkout session",
79
+ )
80
+ if refusal is not None:
81
+ return refusal
82
+ config = resolve_config_from_args(args)
83
+ client = make_client(config)
84
+ try:
85
+ result = client.v1.credits.create_top_up(
86
+ amount_cents=args.amount_cents
87
+ )
88
+ payload = top_up_to_payload(result)
89
+ if config.json_output:
90
+ emit_json("logion.credits.top-up", payload)
91
+ else:
92
+ emit_top_up_human(payload)
93
+ except Exception as exc:
94
+ client.close()
95
+ return handle_error(exc)
96
+ if args.wait:
97
+ return _poll_top_up(args, config, client, payload)
98
+ client.close()
99
+ return 0
100
+
101
+
102
+ def _poll_top_up(
103
+ args: argparse.Namespace,
104
+ config: CliConfig,
105
+ client: LogionClient,
106
+ initial_payload: dict[str, Any],
107
+ ) -> int:
108
+ """Poll a top-up until terminal state or timeout."""
109
+ timeout = min(max(args.wait_timeout, 1), 600)
110
+ interval = max(getattr(args, "interval", 5), 1)
111
+ top_up_id = str(initial_payload.get("top_up_id"))
112
+ last_status: str | None = str(initial_payload.get("status", "unknown"))
113
+ last_payload: dict[str, Any] = initial_payload
114
+ start = time.monotonic()
115
+ try:
116
+ while True:
117
+ result = client.v1.credits.get_top_up(top_up_id=top_up_id)
118
+ pl = top_up_to_payload(result)
119
+ status = str(pl.get("status"))
120
+ elapsed = time.monotonic() - start
121
+ if status != last_status:
122
+ emit_wait_result(config, pl, elapsed, final=False)
123
+ last_status = status
124
+ last_payload = pl
125
+ if status in TERMINAL_STATUSES or elapsed >= timeout:
126
+ break
127
+ time.sleep(interval)
128
+ except Exception as exc:
129
+ client.close()
130
+ return handle_error(exc)
131
+ client.close()
132
+ return resolve_wait(config, last_payload, start, top_up_id, timeout)
133
+
134
+
135
+ def handle_credits_top_ups_get(args: argparse.Namespace) -> int:
136
+ """Execute the credits top-ups get command."""
137
+ bad_id = validate_uuid_arg(args, args.top_up_id, "TOP_UP_ID")
138
+ if bad_id is not None:
139
+ return bad_id
140
+ config = resolve_config_from_args(args)
141
+ client = make_client(config)
142
+ try:
143
+ result = client.v1.credits.get_top_up(top_up_id=args.top_up_id)
144
+ payload = top_up_to_payload(result)
145
+ if config.json_output:
146
+ emit_json("logion.credits.top-ups.get", payload)
147
+ else:
148
+ emit_top_up_human(payload)
149
+ except Exception as exc:
150
+ return handle_error(exc)
151
+ else:
152
+ return 0
153
+ finally:
154
+ client.close()
155
+
156
+
157
+ def handle_credits_top_ups_wait(args: argparse.Namespace) -> int:
158
+ """Poll until a credit top-up reaches a terminal state."""
159
+ bad_id = validate_uuid_arg(args, args.top_up_id, "TOP_UP_ID")
160
+ if bad_id is not None:
161
+ return bad_id
162
+ config = resolve_config_from_args(args)
163
+ client = make_client(config)
164
+ timeout = min(max(args.wait_timeout, 1), 600)
165
+ interval = max(args.interval, 1)
166
+ try:
167
+ last_status: str | None = None
168
+ last_payload: dict[str, Any] | None = None
169
+ start = time.monotonic()
170
+ while True:
171
+ result = client.v1.credits.get_top_up(top_up_id=args.top_up_id)
172
+ pl = top_up_to_payload(result)
173
+ status = str(pl.get("status"))
174
+ elapsed = time.monotonic() - start
175
+ if status != last_status:
176
+ emit_wait_result(config, pl, elapsed, final=False)
177
+ last_status = status
178
+ last_payload = pl
179
+ if status in TERMINAL_STATUSES or elapsed >= timeout:
180
+ break
181
+ time.sleep(interval)
182
+ except Exception as exc:
183
+ return handle_error(exc)
184
+ finally:
185
+ client.close()
186
+ return resolve_wait(config, last_payload, start, args.top_up_id, timeout)
187
+
188
+
189
+ def _render_ledger(result: object) -> None:
190
+ items = to_data(result)
191
+ if not items:
192
+ sys.stdout.write("No ledger entries.\n")
193
+ else:
194
+ for item in items:
195
+ lines = [
196
+ f"id: {item.get('id')}",
197
+ f"kind: {item.get('kind')}",
198
+ f"direction: {item.get('direction')}",
199
+ f"amount_cents: {item.get('amount_cents')}",
200
+ f"balance_after_cents: {item.get('balance_after_cents')}",
201
+ f"posted_at: {item.get('posted_at')}",
202
+ ]
203
+ if item.get("related_top_up_id"):
204
+ rtup = item.get("related_top_up_id")
205
+ lines.append(f"related_top_up_id: {rtup}")
206
+ sys.stdout.write("\n".join(lines) + "\n---\n")
207
+
208
+
209
+ def handle_credits_ledger(args: argparse.Namespace) -> int:
210
+ """Execute the credits ledger command."""
211
+ config = resolve_config_from_args(args)
212
+ return _run(
213
+ args,
214
+ lambda c: c.list_ledger(),
215
+ "ledger",
216
+ json_output=config.json_output,
217
+ render=_render_ledger,
218
+ )
@@ -0,0 +1,115 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Parser registration for credits commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from cli._options import COMMON_PARSER
9
+
10
+ from .handlers import (
11
+ handle_credits_balance,
12
+ handle_credits_ledger,
13
+ handle_credits_top_up,
14
+ handle_credits_top_ups_get,
15
+ handle_credits_top_ups_wait,
16
+ )
17
+
18
+
19
+ def register(subparsers: argparse._SubParsersAction) -> None:
20
+ """Register the ``credits`` subcommand group."""
21
+ parser = subparsers.add_parser(
22
+ "credits",
23
+ help="Manage credit balance, top-ups, and ledger",
24
+ )
25
+ sub = parser.add_subparsers(
26
+ dest="credits_command",
27
+ required=True,
28
+ )
29
+
30
+ # credits balance
31
+ balance = sub.add_parser(
32
+ "balance",
33
+ help="Show current credit balance",
34
+ parents=[COMMON_PARSER],
35
+ )
36
+ balance.set_defaults(handler=handle_credits_balance)
37
+
38
+ # credits top-up
39
+ top_up = sub.add_parser(
40
+ "top-up",
41
+ help="Create a credit top-up checkout session",
42
+ parents=[COMMON_PARSER],
43
+ )
44
+ top_up.add_argument(
45
+ "--amount",
46
+ dest="amount_cents",
47
+ required=True,
48
+ type=int,
49
+ help="Amount in cents for the credit top-up.",
50
+ )
51
+ top_up.add_argument(
52
+ "--yes",
53
+ action="store_true",
54
+ help="Confirm creating a Stripe Checkout session for this top-up.",
55
+ )
56
+ top_up.add_argument(
57
+ "--wait",
58
+ action="store_true",
59
+ default=False,
60
+ help="Poll until the top-up reaches a terminal state.",
61
+ )
62
+ top_up.add_argument(
63
+ "--wait-timeout",
64
+ dest="wait_timeout",
65
+ type=int,
66
+ default=300,
67
+ help="Max seconds to poll (capped at 600).",
68
+ )
69
+ top_up.set_defaults(handler=handle_credits_top_up)
70
+
71
+ # credits top-ups (nested sub-group)
72
+ top_ups = sub.add_parser("top-ups", help="Query credit top-ups")
73
+ top_ups_sub = top_ups.add_subparsers(
74
+ dest="credits_top_ups_command",
75
+ required=True,
76
+ )
77
+
78
+ # credits top-ups get
79
+ top_ups_get = top_ups_sub.add_parser(
80
+ "get",
81
+ help="Get a credit top-up by ID",
82
+ parents=[COMMON_PARSER],
83
+ )
84
+ top_ups_get.add_argument("top_up_id", metavar="TOP_UP_ID")
85
+ top_ups_get.set_defaults(handler=handle_credits_top_ups_get)
86
+
87
+ # credits top-ups wait
88
+ top_ups_wait = top_ups_sub.add_parser(
89
+ "wait",
90
+ help="Poll until a top-up reaches a terminal state",
91
+ parents=[COMMON_PARSER],
92
+ )
93
+ top_ups_wait.add_argument("top_up_id", metavar="TOP_UP_ID")
94
+ top_ups_wait.add_argument(
95
+ "--wait-timeout",
96
+ dest="wait_timeout",
97
+ type=int,
98
+ default=300,
99
+ help="Max seconds to poll (capped at 600).",
100
+ )
101
+ top_ups_wait.add_argument(
102
+ "--interval",
103
+ type=int,
104
+ default=5,
105
+ help="Seconds between polls.",
106
+ )
107
+ top_ups_wait.set_defaults(handler=handle_credits_top_ups_wait)
108
+
109
+ # credits ledger
110
+ ledger = sub.add_parser(
111
+ "ledger",
112
+ help="List credit ledger entries",
113
+ parents=[COMMON_PARSER],
114
+ )
115
+ ledger.set_defaults(handler=handle_credits_ledger)