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,490 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Bounties commands — create, list, get, lifecycle, submissions."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from cli._config import resolve_config_from_args
12
+ from cli._confirm import require_yes
13
+ from cli._context import make_client
14
+ from cli._errors import (
15
+ handle_error,
16
+ print_err,
17
+ validate_uuid_id,
18
+ )
19
+ from cli._options import COMMON_PARSER
20
+ from cli._output import emit, emit_json, to_data
21
+ from cli._utils import only_not_none
22
+ from cli.commands import workspace as _workspace
23
+
24
+
25
+ def parse_datetime(value: str | None) -> datetime | None:
26
+ """Parse an ISO-8601 datetime string, treating trailing Z as UTC."""
27
+ if value is None:
28
+ return None
29
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
30
+
31
+
32
+ def load_evidence(path: Path | None) -> dict[str, object] | None:
33
+ """Load a JSON evidence file, returning None if *path* is None.
34
+
35
+ Returns ``None`` and prints a user-facing error when the file
36
+ is missing or contains invalid JSON.
37
+ """
38
+ if path is None:
39
+ return None
40
+ try:
41
+ return json.loads(path.read_text()) # type: ignore[no-any-return]
42
+ except (OSError, json.JSONDecodeError) as exc:
43
+ print_err(f"Error: evidence JSON must be valid: {exc}")
44
+ return None
45
+
46
+
47
+ def register(subparsers: argparse._SubParsersAction) -> None:
48
+ """Register the ``bounties`` subcommand group."""
49
+ parser = subparsers.add_parser(
50
+ "bounties",
51
+ help="Manage bounties",
52
+ )
53
+ sub = parser.add_subparsers(
54
+ dest="bounties_command",
55
+ required=True,
56
+ )
57
+
58
+ # ── create ──────────────────────────────────────────────────
59
+ create = sub.add_parser(
60
+ "create",
61
+ help="Create a new bounty",
62
+ parents=[COMMON_PARSER],
63
+ )
64
+ create.add_argument("--course-id", required=True)
65
+ create.add_argument("--title", required=True)
66
+ create.add_argument("--description", required=True)
67
+ create.add_argument("--reward-cents", required=True, type=int)
68
+ create.add_argument("--currency")
69
+ create.add_argument("--submission-deadline")
70
+ create.set_defaults(handler=handle_create)
71
+
72
+ # ── list ────────────────────────────────────────────────────
73
+ ls = sub.add_parser(
74
+ "list",
75
+ help="List bounties",
76
+ parents=[COMMON_PARSER],
77
+ )
78
+ ls.add_argument("--scope", choices=["mine", "open", "funded"])
79
+ ls.set_defaults(handler=handle_list)
80
+
81
+ # ── get ──────────────────────────────────────────────────────
82
+ get = sub.add_parser(
83
+ "get",
84
+ help="Get bounty details",
85
+ parents=[COMMON_PARSER],
86
+ )
87
+ get.add_argument("bounty_id", metavar="BOUNTY_ID")
88
+ get.set_defaults(handler=handle_get)
89
+
90
+ # ── lifecycle commands (open / fund / cancel) ───────────────
91
+ # `logion bounties payout` is gone — accept accrues a
92
+ # creator-payable balance directly, and contributors cash out via
93
+ # `logion payments cash-out`. No separate payout step.
94
+ for cmd, sdk_method, action in [
95
+ ("open", "update_status", "open this bounty"),
96
+ (
97
+ "fund",
98
+ "update_funding",
99
+ "fund this bounty (credits will be debited)",
100
+ ),
101
+ ("cancel", "delete", "cancel this bounty"),
102
+ ]:
103
+ p = sub.add_parser(
104
+ cmd,
105
+ help=f"{cmd.capitalize()} a bounty",
106
+ parents=[COMMON_PARSER],
107
+ )
108
+ p.add_argument("bounty_id", metavar="BOUNTY_ID")
109
+ p.add_argument("--yes", action="store_true")
110
+ handler = _make_lifecycle_handler(cmd, sdk_method, action)
111
+ p.set_defaults(handler=handler)
112
+
113
+ # ── submissions sub-group ────────────────────────────────────
114
+ submissions = sub.add_parser(
115
+ "submissions",
116
+ help="Manage bounty submissions",
117
+ )
118
+ sub_sub = submissions.add_subparsers(
119
+ dest="bounties_submissions_command",
120
+ required=True,
121
+ )
122
+
123
+ # submissions create
124
+ sc = sub_sub.add_parser(
125
+ "create",
126
+ help="Create a submission for a bounty",
127
+ parents=[COMMON_PARSER],
128
+ )
129
+ sc.add_argument("bounty_id", metavar="BOUNTY_ID")
130
+ sc.add_argument("--title", required=True)
131
+ sc.add_argument("--description")
132
+ sc.add_argument("--evidence-json", type=Path, metavar="PATH")
133
+ sc.add_argument("--proposed-course-version-id")
134
+ sc.set_defaults(handler=handle_submissions_create)
135
+
136
+ # submissions list
137
+ sl = sub_sub.add_parser(
138
+ "list",
139
+ help="List submissions for a bounty",
140
+ parents=[COMMON_PARSER],
141
+ )
142
+ sl.add_argument("bounty_id", metavar="BOUNTY_ID")
143
+ sl.set_defaults(handler=handle_submissions_list)
144
+
145
+ # submissions get
146
+ sg = sub_sub.add_parser(
147
+ "get",
148
+ help="Get submission details",
149
+ parents=[COMMON_PARSER],
150
+ )
151
+ sg.add_argument("bounty_id", metavar="BOUNTY_ID")
152
+ sg.add_argument("submission_id", metavar="SUBMISSION_ID")
153
+ sg.set_defaults(handler=handle_submissions_get)
154
+
155
+ # submissions accept
156
+ sa = sub_sub.add_parser(
157
+ "accept",
158
+ help="Accept a submission",
159
+ parents=[COMMON_PARSER],
160
+ )
161
+ sa.add_argument("bounty_id", metavar="BOUNTY_ID")
162
+ sa.add_argument("submission_id", metavar="SUBMISSION_ID")
163
+ sa.add_argument("--yes", action="store_true")
164
+ sa.set_defaults(handler=handle_submissions_accept)
165
+
166
+ # submissions reject
167
+ sr = sub_sub.add_parser(
168
+ "reject",
169
+ help="Reject a submission",
170
+ parents=[COMMON_PARSER],
171
+ )
172
+ sr.add_argument("bounty_id", metavar="BOUNTY_ID")
173
+ sr.add_argument("submission_id", metavar="SUBMISSION_ID")
174
+ sr.add_argument("--yes", action="store_true")
175
+ sr.set_defaults(handler=handle_submissions_reject)
176
+
177
+ # submissions withdraw
178
+ sw = sub_sub.add_parser(
179
+ "withdraw",
180
+ help="Withdraw a submission",
181
+ parents=[COMMON_PARSER],
182
+ )
183
+ sw.add_argument("bounty_id", metavar="BOUNTY_ID")
184
+ sw.add_argument("submission_id", metavar="SUBMISSION_ID")
185
+ sw.add_argument("--yes", action="store_true")
186
+ sw.set_defaults(handler=handle_submissions_withdraw)
187
+
188
+ # ── workspace sub-group ──────────────────────────────────────
189
+ _workspace.register(sub)
190
+
191
+
192
+ # ── Lifecycle handler factory ────────────────────────────────────
193
+
194
+
195
+ def _make_lifecycle_handler(cmd: str, sdk_method: str, action: str):
196
+ """Return a handler function for a bounty lifecycle command."""
197
+
198
+ def handler(args: argparse.Namespace) -> int:
199
+ bad_id = validate_uuid_id(args.bounty_id, "BOUNTY_ID")
200
+ if bad_id is not None:
201
+ return bad_id
202
+ refusal = require_yes(args.yes, action)
203
+ if refusal is not None:
204
+ return refusal
205
+ config = resolve_config_from_args(args)
206
+ client = make_client(config)
207
+ try:
208
+ method = getattr(client.v1.bounties, sdk_method)
209
+ result = method(bounty_id=args.bounty_id)
210
+ emit(result, json_output=config.json_output)
211
+ except Exception as exc:
212
+ return handle_error(exc)
213
+ else:
214
+ return 0
215
+ finally:
216
+ client.close()
217
+
218
+ handler.__name__ = f"handle_{cmd}"
219
+ handler.__doc__ = f"Execute the bounties {cmd} command."
220
+ return handler
221
+
222
+
223
+ # ── Handlers ──────────────────────────────────────────────────────
224
+
225
+
226
+ def handle_create(args: argparse.Namespace) -> int:
227
+ """Execute the bounties create command."""
228
+ bad_id = validate_uuid_id(args.course_id, "--course-id")
229
+ if bad_id is not None:
230
+ return bad_id
231
+ # Validate --submission-deadline format early
232
+ if args.submission_deadline is not None:
233
+ try:
234
+ parse_datetime(args.submission_deadline)
235
+ except (ValueError, TypeError) as exc:
236
+ print_err(f"Error: --submission-deadline: {exc}")
237
+ return 2
238
+ config = resolve_config_from_args(args)
239
+ client = make_client(config)
240
+ try:
241
+ kwargs = only_not_none(
242
+ {
243
+ "course_id": args.course_id,
244
+ "title": args.title,
245
+ "description": args.description,
246
+ "reward_amount_cents": args.reward_cents,
247
+ },
248
+ currency=args.currency,
249
+ submission_deadline=parse_datetime(args.submission_deadline),
250
+ )
251
+ result = client.v1.bounties.create(**kwargs)
252
+ emit(result, json_output=config.json_output)
253
+ except Exception as exc:
254
+ return handle_error(exc)
255
+ else:
256
+ return 0
257
+ finally:
258
+ client.close()
259
+
260
+
261
+ def handle_list(args: argparse.Namespace) -> int:
262
+ """Execute the bounties list command."""
263
+ config = resolve_config_from_args(args)
264
+ client = make_client(config)
265
+ try:
266
+ kwargs = only_not_none(
267
+ {},
268
+ scope=args.scope,
269
+ )
270
+ result = client.v1.bounties.list(**kwargs)
271
+ if config.json_output:
272
+ data = (
273
+ result.model_dump(mode="json")
274
+ if hasattr(result, "model_dump")
275
+ else to_data(result)
276
+ )
277
+ emit_json("logion.bounties.list", data)
278
+ else:
279
+ emit(result, json_output=False)
280
+ except Exception as exc:
281
+ return handle_error(exc)
282
+ else:
283
+ return 0
284
+ finally:
285
+ client.close()
286
+
287
+
288
+ def handle_get(args: argparse.Namespace) -> int:
289
+ """Execute the bounties get command."""
290
+ bad_id = validate_uuid_id(args.bounty_id, "BOUNTY_ID")
291
+ if bad_id is not None:
292
+ return bad_id
293
+ config = resolve_config_from_args(args)
294
+ client = make_client(config)
295
+ try:
296
+ result = client.v1.bounties.get(bounty_id=args.bounty_id)
297
+ if config.json_output:
298
+ data = (
299
+ result.model_dump(mode="json")
300
+ if hasattr(result, "model_dump")
301
+ else to_data(result)
302
+ )
303
+ emit_json("logion.bounties.get", data)
304
+ else:
305
+ emit(result, json_output=False)
306
+ except Exception as exc:
307
+ return handle_error(exc)
308
+ else:
309
+ return 0
310
+ finally:
311
+ client.close()
312
+
313
+
314
+ # ── Submission handlers ──────────────────────────────────────────
315
+
316
+
317
+ def handle_submissions_create(args: argparse.Namespace) -> int:
318
+ """Execute the bounties submissions create command."""
319
+ bad_id = validate_uuid_id(args.bounty_id, "BOUNTY_ID")
320
+ if bad_id is not None:
321
+ return bad_id
322
+ if args.proposed_course_version_id is not None:
323
+ bad_id = validate_uuid_id(
324
+ args.proposed_course_version_id,
325
+ "--proposed-course-version-id",
326
+ )
327
+ if bad_id is not None:
328
+ return bad_id
329
+ evidence = load_evidence(args.evidence_json)
330
+ if args.evidence_json is not None and evidence is None:
331
+ return 2
332
+ config = resolve_config_from_args(args)
333
+ client = make_client(config)
334
+ try:
335
+ kwargs = only_not_none(
336
+ {
337
+ "bounty_id": args.bounty_id,
338
+ "title": args.title,
339
+ },
340
+ description=args.description,
341
+ evidence=evidence,
342
+ proposed_course_version_id=args.proposed_course_version_id,
343
+ )
344
+ result = client.v1.bounties.create_submission(**kwargs)
345
+ emit(result, json_output=config.json_output)
346
+ except Exception as exc:
347
+ return handle_error(exc)
348
+ else:
349
+ return 0
350
+ finally:
351
+ client.close()
352
+
353
+
354
+ def handle_submissions_list(args: argparse.Namespace) -> int:
355
+ """Execute the bounties submissions list command."""
356
+ bad_id = validate_uuid_id(args.bounty_id, "BOUNTY_ID")
357
+ if bad_id is not None:
358
+ return bad_id
359
+ config = resolve_config_from_args(args)
360
+ client = make_client(config)
361
+ try:
362
+ result = client.v1.bounties.list_submissions(bounty_id=args.bounty_id)
363
+ if config.json_output:
364
+ data = (
365
+ result.model_dump(mode="json")
366
+ if hasattr(result, "model_dump")
367
+ else to_data(result)
368
+ )
369
+ emit_json("logion.bounties.submissions.list", data)
370
+ else:
371
+ emit(result, json_output=False)
372
+ except Exception as exc:
373
+ return handle_error(exc)
374
+ else:
375
+ return 0
376
+ finally:
377
+ client.close()
378
+
379
+
380
+ def handle_submissions_get(args: argparse.Namespace) -> int:
381
+ """Execute the bounties submissions get command."""
382
+ bad_id = validate_uuid_id(args.bounty_id, "BOUNTY_ID")
383
+ if bad_id is not None:
384
+ return bad_id
385
+ bad_id = validate_uuid_id(args.submission_id, "SUBMISSION_ID")
386
+ if bad_id is not None:
387
+ return bad_id
388
+ config = resolve_config_from_args(args)
389
+ client = make_client(config)
390
+ try:
391
+ result = client.v1.bounties.get_submission(
392
+ bounty_id=args.bounty_id,
393
+ submission_id=args.submission_id,
394
+ )
395
+ if config.json_output:
396
+ data = (
397
+ result.model_dump(mode="json")
398
+ if hasattr(result, "model_dump")
399
+ else to_data(result)
400
+ )
401
+ emit_json("logion.bounties.submissions.get", data)
402
+ else:
403
+ emit(result, json_output=False)
404
+ except Exception as exc:
405
+ return handle_error(exc)
406
+ else:
407
+ return 0
408
+ finally:
409
+ client.close()
410
+
411
+
412
+ def handle_submissions_accept(args: argparse.Namespace) -> int:
413
+ """Execute the bounties submissions accept command."""
414
+ bad_id = validate_uuid_id(args.bounty_id, "BOUNTY_ID")
415
+ if bad_id is not None:
416
+ return bad_id
417
+ bad_id = validate_uuid_id(args.submission_id, "SUBMISSION_ID")
418
+ if bad_id is not None:
419
+ return bad_id
420
+ refusal = require_yes(args.yes, "accept this submission")
421
+ if refusal is not None:
422
+ return refusal
423
+ config = resolve_config_from_args(args)
424
+ client = make_client(config)
425
+ try:
426
+ result = client.v1.bounties.accept_submission(
427
+ bounty_id=args.bounty_id,
428
+ submission_id=args.submission_id,
429
+ )
430
+ emit(result, json_output=config.json_output)
431
+ except Exception as exc:
432
+ return handle_error(exc)
433
+ else:
434
+ return 0
435
+ finally:
436
+ client.close()
437
+
438
+
439
+ def handle_submissions_reject(args: argparse.Namespace) -> int:
440
+ """Execute the bounties submissions reject command."""
441
+ bad_id = validate_uuid_id(args.bounty_id, "BOUNTY_ID")
442
+ if bad_id is not None:
443
+ return bad_id
444
+ bad_id = validate_uuid_id(args.submission_id, "SUBMISSION_ID")
445
+ if bad_id is not None:
446
+ return bad_id
447
+ refusal = require_yes(args.yes, "reject this submission")
448
+ if refusal is not None:
449
+ return refusal
450
+ config = resolve_config_from_args(args)
451
+ client = make_client(config)
452
+ try:
453
+ result = client.v1.bounties.reject_submission(
454
+ bounty_id=args.bounty_id,
455
+ submission_id=args.submission_id,
456
+ )
457
+ emit(result, json_output=config.json_output)
458
+ except Exception as exc:
459
+ return handle_error(exc)
460
+ else:
461
+ return 0
462
+ finally:
463
+ client.close()
464
+
465
+
466
+ def handle_submissions_withdraw(args: argparse.Namespace) -> int:
467
+ """Execute the bounties submissions withdraw command."""
468
+ bad_id = validate_uuid_id(args.bounty_id, "BOUNTY_ID")
469
+ if bad_id is not None:
470
+ return bad_id
471
+ bad_id = validate_uuid_id(args.submission_id, "SUBMISSION_ID")
472
+ if bad_id is not None:
473
+ return bad_id
474
+ refusal = require_yes(args.yes, "withdraw this submission")
475
+ if refusal is not None:
476
+ return refusal
477
+ config = resolve_config_from_args(args)
478
+ client = make_client(config)
479
+ try:
480
+ result = client.v1.bounties.delete_submission(
481
+ bounty_id=args.bounty_id,
482
+ submission_id=args.submission_id,
483
+ )
484
+ emit(result, json_output=config.json_output)
485
+ except Exception as exc:
486
+ return handle_error(exc)
487
+ else:
488
+ return 0
489
+ finally:
490
+ client.close()
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Course-reviews command package."""
3
+
4
+ from .parser import register
5
+
6
+ __all__ = ["register"]
@@ -0,0 +1,104 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """``course-reviews download`` — fetch the bundle under review.
3
+
4
+ Fetches the bundle manifest through the typed SDK
5
+ (``client.v1.course_reviews.get_bundle``), then streams each file from
6
+ its presigned URL and reconstructs the bundle's directory tree under
7
+ TARGET so the reviewer can read SKILL.md and references before
8
+ approve/reject.
9
+
10
+ The manifest call goes through the SDK like every other API call. The
11
+ per-file downloads hit short-lived presigned object-storage URLs
12
+ (carried in the manifest), which have no SDK wrapper — those use httpx
13
+ directly and are allowlisted in ``scripts/check_cli_http.lock``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import httpx
24
+
25
+ from cli._config import resolve_config_from_args
26
+ from cli._context import make_client
27
+ from cli._errors import handle_error, print_err, validate_uuid_id
28
+ from cli._output import emit
29
+
30
+
31
+ def handle_download(args: argparse.Namespace) -> int:
32
+ """Execute course-reviews download — fetch bundle for review."""
33
+ bad_id = validate_uuid_id(args.review_id, "REVIEW_ID")
34
+ if bad_id is not None:
35
+ return bad_id
36
+
37
+ config = resolve_config_from_args(args)
38
+ target = Path(
39
+ args.target if args.target else f"./review-bundles/{args.review_id}"
40
+ ).resolve()
41
+ target.mkdir(parents=True, exist_ok=True)
42
+
43
+ client = make_client(config)
44
+ try:
45
+ bundle = client.v1.course_reviews.get_bundle(review_id=args.review_id)
46
+ except Exception as exc:
47
+ return handle_error(exc)
48
+ finally:
49
+ client.close()
50
+
51
+ files = bundle.files
52
+ if not files:
53
+ print_err("Error: review has no assets")
54
+ return 1
55
+
56
+ # Presigned object-storage URLs (download_url) are external and have
57
+ # no SDK wrapper — stream them directly. See scripts/check_cli_http.lock.
58
+ try:
59
+ with httpx.Client(timeout=config.timeout) as http:
60
+ rc = _download_files(http, files, target)
61
+ if rc != 0:
62
+ return rc
63
+ except Exception as exc:
64
+ return handle_error(exc)
65
+
66
+ _emit_result(bundle, files, target, json_output=config.json_output)
67
+ return 0
68
+
69
+
70
+ def _download_files(http: httpx.Client, files: list[Any], target: Path) -> int:
71
+ for f in files:
72
+ filename = f.filename
73
+ dest = target / filename
74
+ if not dest.resolve().is_relative_to(target):
75
+ print_err(f"Error: refusing path escape: {filename}")
76
+ return 1
77
+ dest.parent.mkdir(parents=True, exist_ok=True)
78
+ with http.stream("GET", f.download_url) as r:
79
+ r.raise_for_status()
80
+ with open(dest, "wb") as out:
81
+ for chunk in r.iter_bytes():
82
+ out.write(chunk)
83
+ return 0
84
+
85
+
86
+ def _emit_result(
87
+ bundle: Any, files: list[Any], target: Path, *, json_output: bool
88
+ ) -> None:
89
+ if json_output:
90
+ emit(
91
+ {
92
+ "review_id": bundle.review_id,
93
+ "target": str(target),
94
+ "files": [f.filename for f in files],
95
+ },
96
+ json_output=True,
97
+ )
98
+ return
99
+ sys.stdout.write(f"Downloaded {len(files)} file(s) to {target}\n")
100
+ for f in files:
101
+ sys.stdout.write(f" {f.filename}\n")
102
+ sys.stdout.write(
103
+ "\nNext: read SKILL.md and references/* before approve/reject.\n"
104
+ )