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,246 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """``logion identity onboarding`` — one command from zero to ready."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from cli._config import resolve_config_from_args
11
+ from cli._context import make_client
12
+ from cli._credentials import save_user_identity, stored_user_id
13
+ from cli._errors import handle_error, print_err
14
+ from cli._harness import adapter_names
15
+ from cli._output import emit_json
16
+
17
+ from . import _autopost
18
+ from ._closing_copy import CLOSING_COPY, ONBOARDING_NEXT_STEPS
19
+ from ._harness_select import select_harnesses
20
+ from ._onboarding_helpers import run_companion_step, validate_explicit_harness
21
+ from .handlers import API_KEY_WARNING, _field, _resolve_password
22
+
23
+
24
+ def _prompt(question: str, default: str | None = None) -> str:
25
+ """Prompt for free text; return *default* on empty input."""
26
+ suffix = f" [{default}]" if default else ""
27
+ try:
28
+ answer = input(f"{question}{suffix}: ").strip()
29
+ except EOFError:
30
+ return default or ""
31
+ return answer or (default or "")
32
+
33
+
34
+ def _resolve_email(cli_value: str | None) -> str | None:
35
+ """Return the email from the flag, an interactive prompt, or None."""
36
+ if cli_value:
37
+ return cli_value
38
+ if sys.stdin.isatty():
39
+ email = _prompt("Email")
40
+ return email or None
41
+ print_err("Error: --email is required in non-interactive mode.")
42
+ return None
43
+
44
+
45
+ def _provision_identity(
46
+ args: argparse.Namespace,
47
+ config: object,
48
+ ) -> dict[str, object] | None:
49
+ """Create a user + first agent and persist identity context.
50
+
51
+ Returns a summary dict (including the one-time API key), or ``None``
52
+ on a handled error.
53
+ """
54
+ email = _resolve_email(args.email)
55
+ if email is None:
56
+ return None
57
+
58
+ agent_name = args.agent_name
59
+ if not agent_name:
60
+ agent_name = (
61
+ _prompt("Agent name", default="default-agent")
62
+ if sys.stdin.isatty()
63
+ else "default-agent"
64
+ )
65
+
66
+ password = _resolve_password(args.password)
67
+ if password is None:
68
+ return None
69
+
70
+ client = make_client(config) # type: ignore[arg-type]
71
+ try:
72
+ result = client.v1.identity.create_user_with_agent(
73
+ email=email,
74
+ user_password=password,
75
+ agent_name=agent_name,
76
+ user_name=args.user_name,
77
+ agent_description=None,
78
+ )
79
+ except Exception as exc:
80
+ handle_error(exc)
81
+ return None
82
+ finally:
83
+ client.close()
84
+
85
+ user = _field(result, "user")
86
+ agent = _field(result, "agent")
87
+ user_id = _field(user, "id")
88
+ agent_id = _field(agent, "id")
89
+ resolved_email = _field(user, "email")
90
+
91
+ if user_id is not None:
92
+ try:
93
+ save_user_identity(
94
+ str(user_id),
95
+ email=str(resolved_email)
96
+ if resolved_email is not None
97
+ else None,
98
+ agent_id=str(agent_id) if agent_id is not None else None,
99
+ )
100
+ except OSError as exc:
101
+ print_err(f"Warning: could not save credentials: {exc}")
102
+
103
+ print_err(API_KEY_WARNING)
104
+ return {
105
+ "user_id": str(user_id) if user_id is not None else None,
106
+ "agent_id": str(agent_id) if agent_id is not None else None,
107
+ "email": str(resolved_email) if resolved_email is not None else None,
108
+ "api_key": _field(result, "api_key"),
109
+ "api_key_prefix": _field(result, "api_key_prefix"),
110
+ "created": True,
111
+ }
112
+
113
+
114
+ def handle_onboarding(args: argparse.Namespace) -> int:
115
+ """Execute the identity onboarding command."""
116
+ config = resolve_config_from_args(args)
117
+ summary: dict[str, object] = {}
118
+
119
+ existing = stored_user_id()
120
+ if existing is None:
121
+ identity = _provision_identity(args, config)
122
+ if identity is None:
123
+ return 2
124
+ summary.update(identity)
125
+ else:
126
+ print_err(f"Already onboarded (user {existing}).")
127
+ summary.update({"user_id": existing, "created": False})
128
+
129
+ # Validate an explicitly-requested harness up-front so an unknown
130
+ # name is a hard error before autopost or the companion step runs.
131
+ rc = validate_explicit_harness(args)
132
+ if rc is not None:
133
+ return rc
134
+
135
+ # Decide auto-review first, then resolve the harness(es) once and
136
+ # share that choice between the grant and the companion install.
137
+ # Only prompt for a harness when a step actually needs one — skip it
138
+ # entirely when auto-review is off and --no-companion is set.
139
+ autopost_enabled = _autopost.resolve_optin(args)
140
+ companion_will_run = not getattr(args, "no_companion", False)
141
+
142
+ if autopost_enabled or companion_will_run:
143
+ adapters = select_harnesses(args)
144
+ else:
145
+ adapters = []
146
+
147
+ if autopost_enabled:
148
+ autopost = _autopost.apply(args, adapters)
149
+ if autopost is None:
150
+ return 2
151
+ summary["autopost"] = autopost
152
+ else:
153
+ print_err(
154
+ "Auto-review not enabled. Enable later with "
155
+ "`logion identity onboarding --enable-autopost`."
156
+ )
157
+ summary["autopost"] = {"enabled": False}
158
+
159
+ companion_summary, rc = run_companion_step(args, adapters)
160
+ if rc is not None:
161
+ return rc
162
+ summary["companion"] = companion_summary
163
+
164
+ print_err(CLOSING_COPY)
165
+
166
+ if config.json_output:
167
+ # Stable machine-readable next steps so agents can drive the
168
+ # marketplace loop without parsing human-readable copy.
169
+ summary["next_steps"] = ONBOARDING_NEXT_STEPS
170
+ emit_json("logion.identity.onboarding", summary)
171
+ return 0
172
+
173
+
174
+ def register_onboarding(sub: argparse._SubParsersAction) -> None:
175
+ """Register the ``identity onboarding`` subcommand."""
176
+ from cli._options import COMMON_PARSER
177
+
178
+ parser = sub.add_parser(
179
+ "onboarding",
180
+ help="Set up user, agent, and (optionally) auto-review in one step",
181
+ parents=[COMMON_PARSER],
182
+ )
183
+ parser.add_argument("--email", help="Email for the new user.")
184
+ parser.add_argument("--agent-name", help="Name for the first agent.")
185
+ parser.add_argument("--user-name", help="Display name (optional).")
186
+ parser.add_argument(
187
+ "--password",
188
+ help=(
189
+ "User credential (passing it on the CLI is unsafe — leaves "
190
+ "shell history; omit to use a hidden interactive prompt)"
191
+ ),
192
+ )
193
+ autopost = parser.add_mutually_exclusive_group()
194
+ autopost.add_argument(
195
+ "--enable-autopost",
196
+ action="store_true",
197
+ default=None,
198
+ dest="enable_autopost",
199
+ help="Allow agents to auto-post usage reviews (writes a "
200
+ "harness permission rule).",
201
+ )
202
+ autopost.add_argument(
203
+ "--no-enable-autopost",
204
+ action="store_false",
205
+ default=None,
206
+ dest="enable_autopost",
207
+ help="Do not enable auto-review (and revoke nothing).",
208
+ )
209
+ parser.add_argument(
210
+ "--autopost-scope",
211
+ choices=["project", "global"],
212
+ default="global",
213
+ help="Where to write the permission (default: global).",
214
+ )
215
+ parser.add_argument(
216
+ "--harness",
217
+ action="append",
218
+ default=None,
219
+ metavar="NAME",
220
+ help=(
221
+ "Target a specific harness; repeat for several "
222
+ "(e.g. --harness claude-code --harness codex). Omit in an "
223
+ "interactive terminal to pick from detected harnesses. "
224
+ f"Supported: {', '.join(adapter_names())}."
225
+ ),
226
+ )
227
+ parser.add_argument(
228
+ "--agent-dir",
229
+ default=None,
230
+ help="Write the companion into this skill dir (a CustomPathHarness). "
231
+ "Overrides --harness detection for the companion step.",
232
+ )
233
+ parser.add_argument(
234
+ "--companion-source",
235
+ type=Path,
236
+ default=None,
237
+ help="Companion bundle source directory (default: auto-locate).",
238
+ )
239
+ parser.add_argument(
240
+ "--no-companion",
241
+ action="store_true",
242
+ help="Skip the companion install/sync step.",
243
+ )
244
+ # ``--no-onboarding`` is inherited from COMMON_PARSER; no need to
245
+ # re-declare it here.
246
+ parser.set_defaults(handler=handle_onboarding)
@@ -0,0 +1,72 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Parser registration for identity 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_agents_add,
12
+ handle_agents_rotate_key,
13
+ handle_users_create,
14
+ )
15
+ from .onboarding import register_onboarding
16
+
17
+ _USER_ID_HELP = (
18
+ "User id (defaults to the one saved in ~/.logion/credentials.json "
19
+ "by users-create)"
20
+ )
21
+
22
+ _CREDENTIAL_HELP = (
23
+ "User credential (passing it on the CLI is unsafe — "
24
+ "leaves shell history; omit to use a hidden interactive prompt)"
25
+ )
26
+
27
+
28
+ def register(subparsers: argparse._SubParsersAction) -> None:
29
+ """Register the ``identity`` subcommand group."""
30
+ parser = subparsers.add_parser(
31
+ "identity",
32
+ help="Manage users and agents",
33
+ )
34
+ sub = parser.add_subparsers(
35
+ dest="identity_command",
36
+ required=True,
37
+ )
38
+
39
+ register_onboarding(sub)
40
+
41
+ users_create = sub.add_parser(
42
+ "users-create",
43
+ help="Create a user with a first agent",
44
+ parents=[COMMON_PARSER],
45
+ )
46
+ users_create.add_argument("--email", required=True)
47
+ users_create.add_argument("--password", help=_CREDENTIAL_HELP)
48
+ users_create.add_argument("--agent-name", required=True)
49
+ users_create.add_argument("--user-name")
50
+ users_create.add_argument("--agent-description")
51
+ users_create.set_defaults(handler=handle_users_create)
52
+
53
+ agents_add = sub.add_parser(
54
+ "agents-add",
55
+ help="Add an agent to an existing user",
56
+ parents=[COMMON_PARSER],
57
+ )
58
+ agents_add.add_argument("--user-id", help=_USER_ID_HELP)
59
+ agents_add.add_argument("--agent-name", required=True)
60
+ agents_add.add_argument("--password", help=_CREDENTIAL_HELP)
61
+ agents_add.add_argument("--agent-description")
62
+ agents_add.set_defaults(handler=handle_agents_add)
63
+
64
+ rotate_key = sub.add_parser(
65
+ "agents-rotate-key",
66
+ help="Rotate an agent API key",
67
+ parents=[COMMON_PARSER],
68
+ )
69
+ rotate_key.add_argument("--user-id", help=_USER_ID_HELP)
70
+ rotate_key.add_argument("--agent-id", required=True)
71
+ rotate_key.add_argument("--password", help=_CREDENTIAL_HELP)
72
+ rotate_key.set_defaults(handler=handle_agents_rotate_key)
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Listings command package."""
3
+
4
+ from .parser import register
5
+
6
+ __all__ = ["register"]
@@ -0,0 +1,135 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Handlers for listings commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from cli._config import resolve_config_from_args
9
+ from cli._context import make_client
10
+ from cli._errors import handle_error
11
+ from cli._output import emit_json, to_data, truncate_summary
12
+
13
+ _DEFAULT_LIMIT = 5
14
+ _MAX_LIMIT = 50
15
+
16
+
17
+ def _compact_match(item: dict[str, object]) -> dict[str, object]:
18
+ """Build the compact listing payload for default JSON output."""
19
+ summary_source = item.get("short_summary") or item.get("summary")
20
+ tags_value = item.get("tags")
21
+ tags = tags_value[:5] if isinstance(tags_value, list) else []
22
+ return {
23
+ "id": item.get("id"),
24
+ "title": item.get("title"),
25
+ "summary": truncate_summary(
26
+ summary_source if isinstance(summary_source, str) else None
27
+ ),
28
+ "tags": tags,
29
+ "price": {
30
+ "amount_cents": item.get("price_cents"),
31
+ "currency": item.get("currency"),
32
+ },
33
+ "status": item.get("status", "published"),
34
+ }
35
+
36
+
37
+ def _format_search_payload(
38
+ result: object, *, limit: int, verbose: bool
39
+ ) -> dict[str, object]:
40
+ """Normalise the SDK response into the CLI's stable payload shape."""
41
+ data = to_data(result)
42
+ if isinstance(data, dict):
43
+ raw_items = data.get("items", [])
44
+ next_cursor = data.get("next_cursor")
45
+ else:
46
+ raw_items = []
47
+ next_cursor = None
48
+
49
+ items = [item for item in raw_items if isinstance(item, dict)]
50
+ matches = items if verbose else [_compact_match(item) for item in items]
51
+ payload: dict[str, object] = {
52
+ "matches": matches,
53
+ "total": len(items),
54
+ "limit": limit,
55
+ }
56
+ if next_cursor is not None:
57
+ payload["next_cursor"] = next_cursor
58
+ return payload
59
+
60
+
61
+ def _print_human(payload: dict[str, object]) -> None:
62
+ """Render a compact human-readable search result list."""
63
+ matches = payload.get("matches")
64
+ if not isinstance(matches, list) or not matches:
65
+ from sys import stdout
66
+
67
+ stdout.write("No listings found.\n")
68
+ return
69
+
70
+ from sys import stdout
71
+
72
+ stdout.write(f"Listings ({payload.get('total', len(matches))}):\n")
73
+ for match in matches:
74
+ if not isinstance(match, dict):
75
+ continue
76
+ listing_id = match.get("id", "?")
77
+ title = match.get("title", "")
78
+ summary = match.get("summary", "")
79
+ price_value = match.get("price")
80
+ price = price_value if isinstance(price_value, dict) else {}
81
+ amount = price.get("amount_cents")
82
+ currency = price.get("currency")
83
+ status = match.get("status", "unknown")
84
+
85
+ line = f" {listing_id}"
86
+ if title:
87
+ line += f" — {title}"
88
+ if amount is not None and currency:
89
+ line += f" [{amount} {currency}]"
90
+ line += f" [{status}]"
91
+ stdout.write(f"{line}\n")
92
+ if isinstance(summary, str) and summary:
93
+ stdout.write(f" {summary}\n")
94
+
95
+
96
+ def handle_search(args: argparse.Namespace) -> int:
97
+ """Execute the listings search."""
98
+ config = resolve_config_from_args(args)
99
+ client = make_client(config)
100
+ requested_limit = getattr(args, "limit", _DEFAULT_LIMIT) or _DEFAULT_LIMIT
101
+ limit = min(max(requested_limit, 1), _MAX_LIMIT)
102
+ # The API accepts a comma-separated tags string. --tag (repeatable)
103
+ # is the preferred CLI form; --tags is the legacy comma form.
104
+ tag_filters = getattr(args, "tag_filters", None)
105
+ tags_param = args.tags
106
+ if tag_filters:
107
+ tags_param = ",".join(tag_filters)
108
+ category = getattr(args, "category", None)
109
+ try:
110
+ result = client.v1.listings.search(
111
+ query=args.query,
112
+ tags=tags_param,
113
+ category=category,
114
+ language=getattr(args, "language", None),
115
+ price_min=getattr(args, "price_min", None),
116
+ price_max=getattr(args, "price_max", None),
117
+ sort=args.sort,
118
+ limit=limit,
119
+ cursor=getattr(args, "cursor", None),
120
+ )
121
+ payload = _format_search_payload(
122
+ result,
123
+ limit=limit,
124
+ verbose=getattr(args, "verbose", False),
125
+ )
126
+ if config.json_output:
127
+ emit_json("logion.listings.search", payload)
128
+ else:
129
+ _print_human(payload)
130
+ except Exception as exc:
131
+ return handle_error(exc)
132
+ else:
133
+ return 0
134
+ finally:
135
+ client.close()
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Parser registration for listings commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from cli._options import COMMON_PARSER
9
+
10
+ from .handlers import handle_search
11
+
12
+ _SORT_CHOICES = [
13
+ "relevance",
14
+ "newest",
15
+ "recently_updated",
16
+ "price_low",
17
+ "price_high",
18
+ "most_useful",
19
+ ]
20
+
21
+
22
+ def register(subparsers: argparse._SubParsersAction) -> None:
23
+ """Register the ``listings`` subcommand group."""
24
+ parser = subparsers.add_parser("listings", help="Search course listings")
25
+ sub = parser.add_subparsers(
26
+ dest="listings_command",
27
+ required=True,
28
+ )
29
+
30
+ search = sub.add_parser(
31
+ "search",
32
+ help="Search course listings",
33
+ parents=[COMMON_PARSER],
34
+ )
35
+ search.add_argument("--query")
36
+ tag_group = search.add_mutually_exclusive_group()
37
+ tag_group.add_argument(
38
+ "--tag",
39
+ action="append",
40
+ dest="tag_filters",
41
+ default=None,
42
+ help="Filter by tag (repeatable; AND semantics)",
43
+ )
44
+ tag_group.add_argument(
45
+ "--tags",
46
+ dest="tags",
47
+ help="Filter by tags (comma-separated; use --tag instead)",
48
+ )
49
+ search.add_argument("--category")
50
+ search.add_argument("--language")
51
+ search.add_argument("--price-min", type=int)
52
+ search.add_argument("--price-max", type=int)
53
+ search.add_argument("--sort", choices=_SORT_CHOICES)
54
+ search.add_argument("--limit", type=int, default=5)
55
+ search.add_argument("--verbose", action="store_true", default=False)
56
+ search.add_argument("--cursor")
57
+ search.set_defaults(handler=handle_search)
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Notifications command package."""
3
+
4
+ from .parser import register
5
+
6
+ __all__ = ["register"]
@@ -0,0 +1,120 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Handlers for notifications commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from cli._config import resolve_config_from_args
9
+ from cli._context import make_client
10
+ from cli._errors import handle_error
11
+ from cli._output import emit, emit_json, to_data
12
+
13
+
14
+ def _extract_count(raw: object) -> int:
15
+ """Extract an integer count from whatever the SDK returns."""
16
+
17
+ def _coerce(value: object) -> int:
18
+ if isinstance(value, bool):
19
+ return int(value)
20
+ if isinstance(value, int):
21
+ return value
22
+ if isinstance(value, str):
23
+ return int(value)
24
+ return 0
25
+
26
+ if isinstance(raw, int):
27
+ return raw
28
+ if isinstance(raw, dict):
29
+ return _coerce(raw.get("unread_count", raw.get("count", 0)))
30
+ return _coerce(getattr(raw, "unread_count", getattr(raw, "count", 0)))
31
+
32
+
33
+ def handle_unread_count(args: argparse.Namespace) -> int:
34
+ """Execute the unread-count command."""
35
+ config = resolve_config_from_args(args)
36
+ client = make_client(config)
37
+ try:
38
+ result = client.v1.notifications.get_unread_count()
39
+ if config.json_output:
40
+ emit_json(
41
+ "logion.notifications.unread-count",
42
+ {"unread_count": _extract_count(result)},
43
+ )
44
+ else:
45
+ emit(result, json_output=False)
46
+ except Exception as exc:
47
+ return handle_error(exc)
48
+ else:
49
+ return 0
50
+ finally:
51
+ client.close()
52
+
53
+
54
+ def handle_list(args: argparse.Namespace) -> int:
55
+ """Execute the notifications list command."""
56
+ config = resolve_config_from_args(args)
57
+ client = make_client(config)
58
+ try:
59
+ result = client.v1.notifications.list(
60
+ unread_only=args.unread_only,
61
+ notification_type=getattr(args, "notification_type", None),
62
+ limit=args.limit,
63
+ cursor=getattr(args, "cursor", None),
64
+ )
65
+ if config.json_output:
66
+ emit_json("logion.notifications.list", to_data(result))
67
+ else:
68
+ emit(result, json_output=False)
69
+ except Exception as exc:
70
+ return handle_error(exc)
71
+ else:
72
+ return 0
73
+ finally:
74
+ client.close()
75
+
76
+
77
+ def handle_peek(args: argparse.Namespace) -> int:
78
+ """Quick check: show unread count, list recent if any."""
79
+ config = resolve_config_from_args(args)
80
+ client = make_client(config)
81
+ try:
82
+ raw_count = client.v1.notifications.get_unread_count()
83
+ unread_count = _extract_count(raw_count)
84
+ if unread_count == 0:
85
+ if config.json_output:
86
+ emit_json(
87
+ "logion.notifications.peek",
88
+ {"unread_count": 0, "items": []},
89
+ )
90
+ else:
91
+ print("No unread notifications.")
92
+ return 0
93
+ items_raw = client.v1.notifications.list(
94
+ unread_only=True,
95
+ limit=5,
96
+ )
97
+ items_data = to_data(items_raw)
98
+ if isinstance(items_data, dict) and "items" in items_data:
99
+ items = items_data["items"]
100
+ elif isinstance(items_data, list):
101
+ items = items_data
102
+ else:
103
+ items = []
104
+ if config.json_output:
105
+ emit_json(
106
+ "logion.notifications.peek",
107
+ {"unread_count": unread_count, "items": items},
108
+ )
109
+ else:
110
+ print(f"You have {unread_count} unread notification(s):")
111
+ for item in items:
112
+ title = item.get("title", "") if isinstance(item, dict) else ""
113
+ nid = item.get("id", "") if isinstance(item, dict) else ""
114
+ print(f" {nid}: {title}")
115
+ except Exception as exc:
116
+ return handle_error(exc)
117
+ else:
118
+ return 0
119
+ finally:
120
+ client.close()
@@ -0,0 +1,49 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Parser registration for notifications commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from cli._options import COMMON_PARSER
9
+
10
+ from .handlers import handle_list, handle_peek, handle_unread_count
11
+
12
+
13
+ def register(subparsers: argparse._SubParsersAction) -> None:
14
+ """Register the ``notifications`` subcommand group."""
15
+ parser = subparsers.add_parser(
16
+ "notifications",
17
+ help="List notifications and check unread count",
18
+ )
19
+ sub = parser.add_subparsers(
20
+ dest="notifications_command",
21
+ required=True,
22
+ )
23
+
24
+ unread_count = sub.add_parser(
25
+ "unread-count",
26
+ help="Get unread notification count",
27
+ parents=[COMMON_PARSER],
28
+ )
29
+ unread_count.set_defaults(handler=handle_unread_count)
30
+
31
+ list_parser = sub.add_parser(
32
+ "list",
33
+ help="List notifications",
34
+ parents=[COMMON_PARSER],
35
+ )
36
+ list_parser.add_argument(
37
+ "--unread-only", action="store_true", default=None
38
+ )
39
+ list_parser.add_argument("--notification-type")
40
+ list_parser.add_argument("--limit", type=int)
41
+ list_parser.add_argument("--cursor")
42
+ list_parser.set_defaults(handler=handle_list)
43
+
44
+ peek = sub.add_parser(
45
+ "peek",
46
+ help="Quick check: show unread count, list recent if any",
47
+ parents=[COMMON_PARSER],
48
+ )
49
+ peek.set_defaults(handler=handle_peek)