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,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,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,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)
|