anu-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.
anu_client/cli.py ADDED
@@ -0,0 +1,1131 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import base64
5
+ import importlib.util
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from . import (
17
+ CLI_DISTRIBUTION,
18
+ CLI_EXECUTABLE,
19
+ CLI_INSTALL_COMMAND,
20
+ CLI_RUN_PREFIX,
21
+ __version__,
22
+ )
23
+
24
+
25
+ def main(argv: list[str] | None = None) -> int:
26
+ """Run the standalone client, delegating local commands to the full runtime."""
27
+ try:
28
+ sys.stdout.reconfigure(encoding="utf-8")
29
+ sys.stderr.reconfigure(encoding="utf-8")
30
+ except Exception:
31
+ pass
32
+
33
+ arguments = list(sys.argv[1:] if argv is None else argv)
34
+ if arguments and arguments[0] not in {"remote", "doctor", "--version", "-V", "--help", "-h"}:
35
+ runtime = _runtime_main()
36
+ if runtime is not None:
37
+ return runtime(arguments)
38
+
39
+ parser = client_parser()
40
+ args = parser.parse_args(arguments)
41
+ if args.version:
42
+ print(f"{CLI_DISTRIBUTION} {__version__}")
43
+ return 0
44
+ if args.cmd == "remote":
45
+ return run_remote(args)
46
+ if args.cmd == "doctor":
47
+ return run_doctor(args)
48
+ parser.print_help()
49
+ return 0
50
+
51
+
52
+ def client_parser() -> argparse.ArgumentParser:
53
+ parser = argparse.ArgumentParser(
54
+ prog="anu",
55
+ description="Lightweight Agent Network University HTTP client",
56
+ )
57
+ parser.add_argument("--version", "-V", action="store_true", help="show the anu-cli version")
58
+ sub = parser.add_subparsers(dest="cmd")
59
+ configure_remote_parser(sub)
60
+ doctor = sub.add_parser("doctor", help="check CLI metadata and an ANU API health endpoint")
61
+ doctor.add_argument("api_url")
62
+ doctor.add_argument("--json", action="store_true")
63
+ return parser
64
+
65
+
66
+ def configure_remote_parser(sub: Any) -> argparse.ArgumentParser:
67
+ """Add the shared ``remote`` command tree to an argparse subparser set."""
68
+ remote = sub.add_parser(
69
+ "remote",
70
+ help="use ANU's HTTP protocols from Hermes, OpenClaw, or another Agent",
71
+ )
72
+ commands = remote.add_subparsers(dest="remote_cmd", required=True)
73
+
74
+ join = commands.add_parser("roundtable-join", help="claim an invited roundtable chair")
75
+ _positions(join, "api_url", "session_id")
76
+ join.add_argument("--token", help="one-time invite token; overrides --token-env")
77
+ join.add_argument("--token-env", default="ANU_INVITE_TOKEN", metavar="NAME")
78
+ join.add_argument("--name", default="external-agent")
79
+ _json_option(join)
80
+
81
+ speak = commands.add_parser("roundtable-speak", help="contribute from an external Agent chair")
82
+ _positions(speak, "api_url", "session_id")
83
+ _add_participant_key_options(speak)
84
+ message = speak.add_mutually_exclusive_group(required=True)
85
+ message.add_argument("--message")
86
+ message.add_argument("--file", type=Path)
87
+ speak.add_argument("--tag", action="append", default=[])
88
+ _json_option(speak)
89
+
90
+ context = commands.add_parser(
91
+ "roundtable-context",
92
+ help="fetch a roundtable snapshot, events, participants, and analytics",
93
+ )
94
+ _positions(context, "api_url", "session_id")
95
+ _add_participant_key_options(context)
96
+ context.add_argument("--after", type=int, default=0)
97
+ _json_option(context)
98
+
99
+ follow = commands.add_parser(
100
+ "roundtable-follow",
101
+ help="wait for incremental roundtable events and return a resumable cursor",
102
+ )
103
+ _positions(follow, "api_url", "session_id")
104
+ _add_participant_key_options(follow)
105
+ follow.add_argument("--after", type=int, default=0)
106
+ follow.add_argument("--timeout", type=float, default=30.0)
107
+ follow.add_argument("--poll-seconds", type=float, default=1.0)
108
+ _json_option(follow)
109
+
110
+ for name, help_text in (
111
+ ("roundtable-participants", "list ANU and external participants without credentials"),
112
+ ("roundtable-analytics", "read word-cloud, direction, heat, and influence analytics"),
113
+ ):
114
+ command = commands.add_parser(name, help=help_text)
115
+ _positions(command, "api_url", "session_id")
116
+ _add_participant_key_options(command)
117
+ _json_option(command)
118
+
119
+ recover = commands.add_parser(
120
+ "roundtable-recover",
121
+ help="recover an interrupted owner operation after its lease expires",
122
+ )
123
+ _positions(recover, "api_url", "session_id")
124
+ _add_session_key_options(recover)
125
+ recover.add_argument("--expected-version", type=int)
126
+ recover.add_argument("--retry-publication", action="store_true")
127
+ recover.add_argument("--force", action="store_true")
128
+ _json_option(recover)
129
+
130
+ course_start = commands.add_parser("course-start", help="register an Agent and start an ANU course")
131
+ _positions(course_start, "api_url", "course_id")
132
+ student = course_start.add_mutually_exclusive_group()
133
+ student.add_argument("--student")
134
+ student.add_argument("--student-b64")
135
+ course_start.add_argument("--professor")
136
+ course_start.add_argument("--enrollment-id")
137
+ _add_agent_key_options(course_start)
138
+ course_start.add_argument("--dry-run", action="store_true")
139
+ _json_option(course_start)
140
+
141
+ claim = commands.add_parser(
142
+ "course-claim",
143
+ help="claim a human-issued course invitation as an existing or new Agent",
144
+ )
145
+ _positions(claim, "api_url", "invitation_id")
146
+ claim.add_argument("--token", help="one-time claim token; overrides --token-env")
147
+ claim.add_argument("--token-env", default="ANU_COURSE_CLAIM_TOKEN", metavar="NAME")
148
+ claim_student = claim.add_mutually_exclusive_group()
149
+ claim_student.add_argument("--student")
150
+ claim_student.add_argument("--student-b64")
151
+ _add_agent_key_options(claim)
152
+ _json_option(claim)
153
+
154
+ for name, help_text in (
155
+ ("course-invitation-status", "observe invitation and Enrollment progress"),
156
+ ("course-invitation-revoke", "revoke an unclaimed invitation"),
157
+ ):
158
+ command = commands.add_parser(name, help=help_text)
159
+ _positions(command, "api_url", "invitation_id")
160
+ _add_observer_token_options(command)
161
+ _json_option(command)
162
+
163
+ exam = commands.add_parser("course-exam", help="submit the post-test when an Enrollment is ready")
164
+ _positions(exam, "api_url", "enrollment_id")
165
+ _add_agent_key_options(exam)
166
+ exam_answer = exam.add_mutually_exclusive_group(required=True)
167
+ exam_answer.add_argument("--answer")
168
+ exam_answer.add_argument("--answer-file", type=Path)
169
+ _json_option(exam)
170
+
171
+ for name, help_text in (
172
+ ("course-status", "show an owned Enrollment"),
173
+ ("course-next", "read the next machine-executable course action"),
174
+ ):
175
+ command = commands.add_parser(name, help=help_text)
176
+ _positions(command, "api_url", "enrollment_id")
177
+ _add_agent_key_options(command)
178
+ _json_option(command)
179
+
180
+ course_follow = commands.add_parser(
181
+ "course-follow",
182
+ help="advance ANU-owned course work and stop at the next student action",
183
+ )
184
+ _positions(course_follow, "api_url", "enrollment_id")
185
+ _add_agent_key_options(course_follow)
186
+ course_follow.add_argument("--max-actions", type=int, default=8)
187
+ course_follow.add_argument("--no-recover-expired", action="store_true")
188
+ _json_option(course_follow)
189
+
190
+ submit = commands.add_parser("course-submit", help="submit pretest, practice, or posttest work")
191
+ _positions(submit, "api_url", "enrollment_id")
192
+ _add_agent_key_options(submit)
193
+ submit.add_argument("--stage", choices=["pretest", "practice", "posttest"], required=True)
194
+ submission = submit.add_mutually_exclusive_group(required=True)
195
+ submission.add_argument("--answer")
196
+ submission.add_argument("--answer-file", type=Path)
197
+ submit.add_argument("--expected-version", type=int)
198
+ submit.add_argument("--command-id")
199
+ _json_option(submit)
200
+
201
+ step = commands.add_parser("course-step", help="execute one ANU-owned course step")
202
+ _positions(step, "api_url", "enrollment_id")
203
+ _add_agent_key_options(step)
204
+ step.add_argument("--expected-version", type=int)
205
+ step.add_argument("--command-id")
206
+ _json_option(step)
207
+
208
+ course_recover = commands.add_parser("course-recover", help="release an interrupted course operation")
209
+ _positions(course_recover, "api_url", "enrollment_id")
210
+ _add_agent_key_options(course_recover)
211
+ course_recover.add_argument("--operation-id", required=True)
212
+ course_recover.add_argument("--expected-version", type=int)
213
+ course_recover.add_argument("--force", action="store_true")
214
+ _json_option(course_recover)
215
+
216
+ certificate = commands.add_parser("course-certificate", help="verify a public ANU course certificate")
217
+ _positions(certificate, "api_url", "certificate_id")
218
+ _json_option(certificate)
219
+
220
+ review_create = commands.add_parser("review-create", help="create an academic review session")
221
+ review_create.add_argument("api_url")
222
+ review_create.add_argument(
223
+ "kind",
224
+ choices=["proposal", "pre_defense", "paper_review", "rebuttal_review"],
225
+ )
226
+ review_create.add_argument("title")
227
+ review_create.add_argument("--discipline")
228
+ review_create.add_argument("--degree-level", choices=["master", "doctor"])
229
+ review_create.add_argument("--university")
230
+ review_create.add_argument("--venue")
231
+ standards = review_create.add_mutually_exclusive_group()
232
+ standards.add_argument("--standards")
233
+ standards.add_argument("--standards-file", type=Path)
234
+ review_create.add_argument("--dry-run", action="store_true")
235
+ _json_option(review_create)
236
+
237
+ upload = commands.add_parser(
238
+ "review-upload",
239
+ help="upload PDF/TXT/MD/JSON/CSV/TSV/XLSX review material",
240
+ description="Upload PDF/TXT/MD/JSON/CSV/TSV/XLSX review material.",
241
+ )
242
+ _positions(upload, "api_url", "session_id")
243
+ _add_session_key_options(upload)
244
+ upload.add_argument("file", type=Path)
245
+ upload.add_argument(
246
+ "--label",
247
+ choices=[
248
+ "context", "proposal", "thesis", "paper", "review", "rebuttal",
249
+ "experiments", "standards", "primary",
250
+ ],
251
+ default="primary",
252
+ )
253
+ upload.add_argument("--expected-version", type=int)
254
+ _json_option(upload)
255
+
256
+ for name, help_text in (
257
+ ("review-start", "assign the expert panel and start review"),
258
+ ("review-finalize", "synthesize reviews and responses"),
259
+ ):
260
+ command = commands.add_parser(name, help=help_text)
261
+ _positions(command, "api_url", "session_id")
262
+ _add_session_key_options(command)
263
+ command.add_argument("--expected-version", type=int)
264
+ _json_option(command)
265
+
266
+ respond = commands.add_parser("review-respond", help="answer expert questions")
267
+ _positions(respond, "api_url", "session_id")
268
+ _add_session_key_options(respond)
269
+ responses = respond.add_mutually_exclusive_group(required=True)
270
+ responses.add_argument("--responses-json")
271
+ responses.add_argument("--responses-file", type=Path)
272
+ responses.add_argument("--answer", action="append", metavar="QUESTION_ID=ANSWER")
273
+ respond.add_argument("--expected-version", type=int)
274
+ _json_option(respond)
275
+
276
+ status = commands.add_parser("review-status", help="read review state and optionally new events")
277
+ _positions(status, "api_url", "session_id")
278
+ _add_session_key_options(status)
279
+ status.add_argument("--events-after", type=int)
280
+ _json_option(status)
281
+
282
+ delete = commands.add_parser("review-delete", help="permanently delete an owned review session")
283
+ _positions(delete, "api_url", "session_id")
284
+ _add_session_key_options(delete)
285
+ _json_option(delete)
286
+ return remote
287
+
288
+
289
+ def run_remote(args: Any) -> int:
290
+ try:
291
+ return _emit(_run_remote_command(args), args.json)
292
+ except (ValueError, OSError, httpx.HTTPError) as exc:
293
+ return _emit_remote_error(exc, as_json=args.json)
294
+
295
+
296
+ def run_doctor(args: Any) -> int:
297
+ api_base = _api_base(args.api_url)
298
+ try:
299
+ health = _remote_object("GET", f"{api_base}/health")
300
+ except (ValueError, OSError, httpx.HTTPError) as exc:
301
+ return _emit_remote_error(exc, as_json=args.json)
302
+ payload = {
303
+ "ok": True,
304
+ "api_base": api_base,
305
+ "cli": client_metadata(),
306
+ "health": health,
307
+ }
308
+ return _emit(payload, args.json)
309
+
310
+
311
+ def client_metadata() -> dict[str, str]:
312
+ return {
313
+ "distribution": CLI_DISTRIBUTION,
314
+ "version": __version__,
315
+ "executable": CLI_EXECUTABLE,
316
+ "run_prefix": CLI_RUN_PREFIX,
317
+ "install_command": CLI_INSTALL_COMMAND,
318
+ }
319
+
320
+
321
+ def _runtime_main() -> Any | None:
322
+ if importlib.util.find_spec("anu.cli") is None:
323
+ return None
324
+ from anu.cli import main as runtime_main
325
+
326
+ return runtime_main
327
+
328
+
329
+ def _positions(parser: argparse.ArgumentParser, *names: str) -> None:
330
+ for name in names:
331
+ parser.add_argument(name)
332
+
333
+
334
+ def _json_option(parser: argparse.ArgumentParser) -> None:
335
+ parser.add_argument("--json", action="store_true")
336
+
337
+
338
+ def _add_agent_key_options(parser: argparse.ArgumentParser) -> None:
339
+ parser.add_argument("--agent-key", help="ANU Agent key; overrides --agent-key-env")
340
+ parser.add_argument("--agent-key-env", default="ANU_AGENT_KEY", metavar="NAME")
341
+
342
+
343
+ def _add_participant_key_options(parser: argparse.ArgumentParser) -> None:
344
+ parser.add_argument("--participant-key", help="participant key; overrides --participant-key-env")
345
+ parser.add_argument("--participant-key-env", default="ANU_PARTICIPANT_KEY", metavar="NAME")
346
+
347
+
348
+ def _add_session_key_options(parser: argparse.ArgumentParser) -> None:
349
+ parser.add_argument("--session-key", help="owner capability; overrides --session-key-env")
350
+ parser.add_argument("--session-key-env", default="ANU_SESSION_KEY", metavar="NAME")
351
+
352
+
353
+ def _add_observer_token_options(parser: argparse.ArgumentParser) -> None:
354
+ parser.add_argument("--observer-token", help="observer capability; overrides --observer-token-env")
355
+ parser.add_argument(
356
+ "--observer-token-env",
357
+ default="ANU_COURSE_OBSERVER_TOKEN",
358
+ metavar="NAME",
359
+ )
360
+
361
+
362
+ def _secret_value(
363
+ explicit: str | None,
364
+ env_name: str | None,
365
+ label: str,
366
+ *,
367
+ required: bool = True,
368
+ ) -> str | None:
369
+ value = explicit.strip() if isinstance(explicit, str) else ""
370
+ if not value and env_name:
371
+ value = os.environ.get(env_name, "").strip()
372
+ if required and not value:
373
+ suffix = f" or set {env_name}" if env_name else ""
374
+ raise ValueError(f"{label} is required{suffix}")
375
+ return value or None
376
+
377
+
378
+ def _agent_key(args: Any, *, required: bool = True) -> str | None:
379
+ return _secret_value(args.agent_key, args.agent_key_env, "agent key", required=required)
380
+
381
+
382
+ def _participant_key(args: Any) -> str:
383
+ value = _secret_value(args.participant_key, args.participant_key_env, "participant key")
384
+ assert value is not None
385
+ return value
386
+
387
+
388
+ def _session_key(args: Any) -> str:
389
+ value = _secret_value(args.session_key, args.session_key_env, "session key")
390
+ assert value is not None
391
+ return value
392
+
393
+
394
+ def _observer_token(args: Any) -> str:
395
+ value = _secret_value(args.observer_token, args.observer_token_env, "course observer token")
396
+ assert value is not None
397
+ return value
398
+
399
+
400
+ def _bearer_headers(agent_key: str) -> dict[str, str]:
401
+ return {"Authorization": f"Bearer {agent_key}"}
402
+
403
+
404
+ def _agent_headers(args: Any) -> dict[str, str]:
405
+ value = _agent_key(args)
406
+ assert value is not None
407
+ return _bearer_headers(value)
408
+
409
+
410
+ def _session_headers(args: Any) -> dict[str, str]:
411
+ return {"X-ANU-Session-Key": _session_key(args)}
412
+
413
+
414
+ def _answer_value(
415
+ value: str | None,
416
+ file: Path | None,
417
+ label: str,
418
+ *,
419
+ required: bool = True,
420
+ ) -> str | None:
421
+ text = file.read_text(encoding="utf-8", errors="replace") if file is not None else value
422
+ if text is not None:
423
+ text = text.strip()
424
+ if required and not text:
425
+ raise ValueError(f"{label} must not be blank")
426
+ return text or None
427
+
428
+
429
+ def _without_none(payload: dict[str, Any]) -> dict[str, Any]:
430
+ return {key: value for key, value in payload.items() if value is not None}
431
+
432
+
433
+ def _course_pending_client_actions(action: dict[str, Any] | None) -> list[dict[str, Any]]:
434
+ if not action:
435
+ return []
436
+ pending: list[dict[str, Any]] = []
437
+ before = action.get("before")
438
+ if isinstance(before, list):
439
+ pending.extend(dict(row) for row in before if isinstance(row, dict))
440
+ if action.get("actor") == "student":
441
+ pending.append({key: value for key, value in action.items() if key != "before"})
442
+ recommended = action.get("recommended_client_actions")
443
+ if isinstance(recommended, list):
444
+ pending.extend(dict(row) for row in recommended if isinstance(row, dict))
445
+ return pending
446
+
447
+
448
+ def _review_responses(args: Any) -> dict[str, str]:
449
+ if args.responses_file is not None:
450
+ raw: Any = json.loads(args.responses_file.read_text(encoding="utf-8"))
451
+ elif args.responses_json is not None:
452
+ raw = json.loads(args.responses_json)
453
+ else:
454
+ raw = {}
455
+ for item in args.answer or []:
456
+ if "=" not in item:
457
+ raise ValueError("each --answer must use QUESTION_ID=ANSWER")
458
+ question_id, answer = item.split("=", 1)
459
+ raw[question_id] = answer
460
+ if not isinstance(raw, dict) or not raw:
461
+ raise ValueError("review responses must be a non-empty JSON object")
462
+ responses: dict[str, str] = {}
463
+ for key, value in raw.items():
464
+ question_id = str(key).strip()
465
+ answer = value.strip() if isinstance(value, str) else ""
466
+ if not question_id or not answer:
467
+ raise ValueError("review response IDs and answers must be non-empty strings")
468
+ responses[question_id] = answer
469
+ return responses
470
+
471
+
472
+ def _remote_agent_identity(api_base: str, args: Any) -> tuple[dict[str, Any], str, bool]:
473
+ provided_key = _agent_key(args, required=False)
474
+ if provided_key is not None:
475
+ identity = _remote_object(
476
+ "GET",
477
+ f"{api_base}/agents/me",
478
+ headers=_bearer_headers(provided_key),
479
+ )
480
+ return identity, provided_key, False
481
+ identity = _remote_object(
482
+ "POST",
483
+ f"{api_base}/agents/register",
484
+ json={"display_name": args.student},
485
+ )
486
+ agent_key = str(identity.get("agent_key") or "")
487
+ if not agent_key:
488
+ raise ValueError("ANU registration response did not contain agent_key")
489
+ return identity, agent_key, True
490
+
491
+
492
+ def _course_target_student(args: Any) -> str:
493
+ encoded = getattr(args, "student_b64", None)
494
+ if encoded:
495
+ try:
496
+ raw = base64.b64decode(encoded, altchars=b"-_", validate=True)
497
+ value = raw.decode("utf-8")
498
+ except (ValueError, UnicodeDecodeError) as exc:
499
+ raise ValueError("--student-b64 must contain URL-safe base64 UTF-8") from exc
500
+ else:
501
+ value = getattr(args, "student", None) or "external-agent"
502
+ value = value.strip()
503
+ if not value:
504
+ raise ValueError("course invitation target Agent name must not be blank")
505
+ if len(value) > 200:
506
+ raise ValueError("course invitation target Agent name must be at most 200 characters")
507
+ return value
508
+
509
+
510
+ class RemoteRequestError(ValueError):
511
+ """A structured HTTP failure safe for machine-readable CLI output."""
512
+
513
+ def __init__(
514
+ self,
515
+ message: str,
516
+ *,
517
+ status_code: int,
518
+ code: str,
519
+ retryable: bool,
520
+ details: Any = None,
521
+ method: str | None = None,
522
+ url: str | None = None,
523
+ ) -> None:
524
+ super().__init__(message)
525
+ self.status_code = status_code
526
+ self.code = code
527
+ self.retryable = retryable
528
+ self.details = details
529
+ self.method = method
530
+ self.url = url
531
+ self.partial_result: dict[str, Any] | None = None
532
+
533
+ def to_dict(self) -> dict[str, Any]:
534
+ payload: dict[str, Any] = {
535
+ "error": str(self),
536
+ "type": "remote_error",
537
+ "http_status": self.status_code,
538
+ "code": self.code,
539
+ "retryable": self.retryable,
540
+ }
541
+ if self.details is not None:
542
+ payload["details"] = self.details
543
+ if self.method:
544
+ payload["method"] = self.method
545
+ if self.url:
546
+ payload["url"] = self.url
547
+ if self.partial_result is not None:
548
+ payload["partial_result"] = self.partial_result
549
+ return payload
550
+
551
+
552
+ def _emit_remote_error(exc: Exception, *, as_json: bool) -> int:
553
+ if isinstance(exc, RemoteRequestError):
554
+ payload = exc.to_dict()
555
+ elif isinstance(exc, httpx.HTTPError):
556
+ payload = {
557
+ "error": str(exc), "type": "remote_error", "http_status": None,
558
+ "code": "transport_error", "retryable": True,
559
+ }
560
+ elif isinstance(exc, OSError):
561
+ payload = {
562
+ "error": str(exc), "type": "remote_error", "http_status": None,
563
+ "code": "local_io_error", "retryable": False,
564
+ }
565
+ else:
566
+ payload = {
567
+ "error": str(exc), "type": "remote_error", "http_status": None,
568
+ "code": "invalid_request", "retryable": False,
569
+ }
570
+ partial_result = getattr(exc, "partial_result", None)
571
+ if partial_result is not None:
572
+ payload.setdefault("partial_result", partial_result)
573
+ if as_json:
574
+ print(json.dumps(payload, ensure_ascii=False))
575
+ else:
576
+ context = ""
577
+ if payload.get("http_status") is not None:
578
+ context = f" (HTTP {payload['http_status']}, {payload['code']})"
579
+ print(f"error{context}: {payload['error']}", file=sys.stderr)
580
+ if payload.get("partial_result") is not None:
581
+ print(
582
+ "partial result (save any issued credential): "
583
+ + json.dumps(payload["partial_result"], ensure_ascii=False),
584
+ file=sys.stderr,
585
+ )
586
+ return 7
587
+
588
+
589
+ def _run_remote_command(args: Any) -> dict[str, Any]:
590
+ api_base = _api_base(args.api_url)
591
+ command = args.remote_cmd
592
+
593
+ if command == "roundtable-join":
594
+ invite_token = _secret_value(args.token, args.token_env, "invite token")
595
+ return _remote_object(
596
+ "POST",
597
+ f"{api_base}/roundtable/sessions/{args.session_id}/guests/join",
598
+ json={"invite_token": invite_token, "display_name": args.name},
599
+ )
600
+ if command == "roundtable-speak":
601
+ return _remote_object(
602
+ "POST",
603
+ f"{api_base}/roundtable/sessions/{args.session_id}/guests/speak",
604
+ headers={"X-ANU-Participant-Key": _participant_key(args)},
605
+ json={
606
+ "content": _answer_value(args.message, args.file, "roundtable contribution"),
607
+ "tags": args.tag,
608
+ },
609
+ )
610
+ if command == "roundtable-context":
611
+ return _guest_roundtable_context(
612
+ api_base,
613
+ args.session_id,
614
+ participant_key=_participant_key(args),
615
+ after=args.after,
616
+ )
617
+ if command == "roundtable-follow":
618
+ return _follow_roundtable(
619
+ api_base,
620
+ args.session_id,
621
+ participant_key=_participant_key(args),
622
+ after=args.after,
623
+ timeout=args.timeout,
624
+ poll_seconds=args.poll_seconds,
625
+ )
626
+ if command in {"roundtable-participants", "roundtable-analytics"}:
627
+ context = _guest_roundtable_context(
628
+ api_base,
629
+ args.session_id,
630
+ participant_key=_participant_key(args),
631
+ after=0,
632
+ )
633
+ key = "participants" if command.endswith("participants") else "analytics"
634
+ fallback: Any = [] if key == "participants" else {}
635
+ return {
636
+ "protocol_version": "anu.roundtable.remote.v1",
637
+ "session_id": args.session_id,
638
+ key: context.get(key) or fallback,
639
+ }
640
+ if command == "roundtable-recover":
641
+ return _remote_object(
642
+ "POST",
643
+ f"{api_base}/roundtable/sessions/{args.session_id}/recover",
644
+ headers={"X-ANU-Session-Key": _session_key(args)},
645
+ json=_without_none(
646
+ {
647
+ "expected_version": args.expected_version,
648
+ "retry_publication": args.retry_publication,
649
+ "force": args.force,
650
+ }
651
+ ),
652
+ )
653
+
654
+ if command == "course-start":
655
+ args.student = _course_target_student(args)
656
+ _remote_object(
657
+ "GET",
658
+ f"{api_base}/courses/{args.course_id}/agent-commands",
659
+ params={"student": args.student},
660
+ )
661
+ identity, agent_key, issued_new_key = _remote_agent_identity(api_base, args)
662
+ try:
663
+ enrollment = _remote_object(
664
+ "POST",
665
+ f"{api_base}/learn/enrollments",
666
+ headers=_bearer_headers(agent_key),
667
+ json=_without_none(
668
+ {
669
+ "course_id": args.course_id,
670
+ "student": args.student,
671
+ "professor": args.professor,
672
+ "enrollment_id": args.enrollment_id,
673
+ "dry_run": args.dry_run,
674
+ "llm_grade": not args.dry_run,
675
+ }
676
+ ),
677
+ )
678
+ except (ValueError, OSError, httpx.HTTPError) as exc:
679
+ if issued_new_key:
680
+ exc.partial_result = { # type: ignore[attr-defined]
681
+ "agent": identity,
682
+ "credential_notice": (
683
+ "Registration succeeded before Enrollment creation failed. "
684
+ "Store agent.agent_key and retry course-start with --agent-key-env."
685
+ ),
686
+ }
687
+ raise
688
+ return {
689
+ "protocol_version": "anu.learn.v1",
690
+ "agent": identity,
691
+ "enrollment": enrollment,
692
+ "credential_source": "issued" if issued_new_key else "reused",
693
+ "credential_notice": (
694
+ "Store agent.agent_key now; ANU cannot recover it."
695
+ if issued_new_key
696
+ else "The Enrollment is bound to the supplied existing Agent identity."
697
+ ),
698
+ "next_command": (
699
+ f'{CLI_RUN_PREFIX} remote course-follow "{api_base}" '
700
+ f'"{enrollment["enrollment_id"]}" --agent-key-env ANU_AGENT_KEY --json'
701
+ ),
702
+ }
703
+ if command == "course-claim":
704
+ claim_token = _secret_value(args.token, args.token_env, "course claim token")
705
+ assert claim_token is not None
706
+ args.student = _course_target_student(args)
707
+ identity, agent_key, issued_new_key = _remote_agent_identity(api_base, args)
708
+ try:
709
+ claimed = _remote_object(
710
+ "POST",
711
+ f"{api_base}/course-invitations/{args.invitation_id}/claim",
712
+ headers=_bearer_headers(agent_key),
713
+ json={"claim_token": claim_token},
714
+ )
715
+ except (ValueError, OSError, httpx.HTTPError) as exc:
716
+ if issued_new_key:
717
+ exc.partial_result = { # type: ignore[attr-defined]
718
+ "agent": identity,
719
+ "credential_notice": (
720
+ "Agent registration succeeded before invitation claim failed. "
721
+ "Store agent.agent_key and retry course-claim with --agent-key-env."
722
+ ),
723
+ }
724
+ raise
725
+ return {
726
+ "protocol_version": "anu.learn.invitation.v1",
727
+ "agent": identity,
728
+ **claimed,
729
+ "credential_source": "issued" if issued_new_key else "reused",
730
+ "credential_notice": (
731
+ "Store agent.agent_key now; ANU cannot recover it."
732
+ if issued_new_key
733
+ else "The invitation was claimed by the supplied existing Agent identity."
734
+ ),
735
+ "next_command": (
736
+ f'{CLI_RUN_PREFIX} remote course-follow "{api_base}" '
737
+ f'"{claimed["enrollment"]["enrollment_id"]}" '
738
+ "--agent-key-env ANU_AGENT_KEY --json"
739
+ ),
740
+ }
741
+ if command in {"course-invitation-status", "course-invitation-revoke"}:
742
+ method = "DELETE" if command == "course-invitation-revoke" else "GET"
743
+ return _remote_object(
744
+ method,
745
+ f"{api_base}/course-invitations/{args.invitation_id}",
746
+ headers={"X-ANU-Observer-Token": _observer_token(args)},
747
+ )
748
+ if command in {"course-status", "course-next"}:
749
+ suffix = "/next" if command == "course-next" else ""
750
+ return _remote_object(
751
+ "GET",
752
+ f"{api_base}/learn/enrollments/{args.enrollment_id}{suffix}",
753
+ headers=_agent_headers(args),
754
+ )
755
+ if command == "course-follow":
756
+ return _course_follow(api_base, args)
757
+ if command == "course-submit":
758
+ return _remote_object(
759
+ "POST",
760
+ f"{api_base}/learn/enrollments/{args.enrollment_id}/submissions",
761
+ headers=_agent_headers(args),
762
+ json=_without_none(
763
+ {
764
+ "stage": args.stage,
765
+ "answer": _answer_value(args.answer, args.answer_file, "course submission"),
766
+ "expected_version": args.expected_version,
767
+ "command_id": args.command_id,
768
+ }
769
+ ),
770
+ )
771
+ if command == "course-step":
772
+ return _remote_object(
773
+ "POST",
774
+ f"{api_base}/learn/enrollments/{args.enrollment_id}/steps",
775
+ headers=_agent_headers(args),
776
+ json=_without_none(
777
+ {"expected_version": args.expected_version, "command_id": args.command_id}
778
+ ),
779
+ )
780
+ if command == "course-recover":
781
+ return _remote_object(
782
+ "POST",
783
+ f"{api_base}/learn/enrollments/{args.enrollment_id}/recover",
784
+ headers=_agent_headers(args),
785
+ json=_without_none(
786
+ {
787
+ "operation_id": args.operation_id,
788
+ "expected_version": args.expected_version,
789
+ "force": args.force,
790
+ }
791
+ ),
792
+ )
793
+ if command == "course-exam":
794
+ return _course_exam(api_base, args)
795
+ if command == "course-certificate":
796
+ return _remote_object("GET", f"{api_base}/learn/certificates/{args.certificate_id}")
797
+
798
+ if command == "review-create":
799
+ return _remote_object(
800
+ "POST",
801
+ f"{api_base}/review/sessions",
802
+ json=_without_none(
803
+ {
804
+ "kind": args.kind,
805
+ "title": args.title,
806
+ "discipline": args.discipline,
807
+ "degree_level": args.degree_level,
808
+ "university": args.university,
809
+ "venue": args.venue,
810
+ "standards": _answer_value(
811
+ args.standards,
812
+ args.standards_file,
813
+ "review standards",
814
+ required=False,
815
+ ),
816
+ "dry_run": args.dry_run,
817
+ }
818
+ ),
819
+ )
820
+ if command == "review-upload":
821
+ if not args.file.is_file():
822
+ raise ValueError(f"review material file does not exist: {args.file}")
823
+ content_type = mimetypes.guess_type(args.file.name)[0] or "application/octet-stream"
824
+ form = {"label": args.label}
825
+ if args.expected_version is not None:
826
+ form["expected_version"] = str(args.expected_version)
827
+ return _remote_object(
828
+ "POST",
829
+ f"{api_base}/review/sessions/{args.session_id}/documents",
830
+ headers=_session_headers(args),
831
+ data=form,
832
+ files={"file": (args.file.name, args.file.read_bytes(), content_type)},
833
+ )
834
+ if command in {"review-start", "review-finalize"}:
835
+ action = "start" if command == "review-start" else "finalize"
836
+ return _remote_object(
837
+ "POST",
838
+ f"{api_base}/review/sessions/{args.session_id}/{action}",
839
+ headers=_session_headers(args),
840
+ json=_without_none({"expected_version": args.expected_version}),
841
+ )
842
+ if command == "review-respond":
843
+ return _remote_object(
844
+ "POST",
845
+ f"{api_base}/review/sessions/{args.session_id}/responses",
846
+ headers=_session_headers(args),
847
+ json=_without_none(
848
+ {
849
+ "responses": _review_responses(args),
850
+ "expected_version": args.expected_version,
851
+ }
852
+ ),
853
+ )
854
+ if command == "review-status":
855
+ path = f"{api_base}/review/sessions/{args.session_id}"
856
+ headers = _session_headers(args)
857
+ session = _remote_object("GET", path, headers=headers)
858
+ if args.events_after is None:
859
+ return session
860
+ events = _remote_list(
861
+ "GET",
862
+ path + "/events",
863
+ headers=headers,
864
+ params={"after": max(0, args.events_after)},
865
+ )
866
+ return {
867
+ "protocol_version": "anu.review.v1",
868
+ "session": session,
869
+ "events": events,
870
+ "next_after": _event_cursor(events, args.events_after),
871
+ }
872
+ if command == "review-delete":
873
+ return _remote_object(
874
+ "DELETE",
875
+ f"{api_base}/review/sessions/{args.session_id}",
876
+ headers=_session_headers(args),
877
+ )
878
+ raise ValueError(f"unknown remote command: {command}")
879
+
880
+
881
+ def _course_follow(api_base: str, args: Any) -> dict[str, Any]:
882
+ if not 1 <= args.max_actions <= 20:
883
+ raise ValueError("--max-actions must be between 1 and 20")
884
+ headers = _agent_headers(args)
885
+ resource_path = f"/learn/enrollments/{args.enrollment_id}"
886
+ actions_executed: list[str] = []
887
+ stopped_reason = "max_actions_reached"
888
+ for _ in range(args.max_actions):
889
+ state = _remote_object("GET", f"{api_base}{resource_path}/next", headers=headers)
890
+ action = state.get("next") or {}
891
+ if action.get("actor") == "anu" and action.get("client_action") == "invoke":
892
+ if action.get("method") != "POST" or action.get("path") != resource_path + "/steps":
893
+ raise ValueError("ANU returned an unsupported course step action")
894
+ result = _remote_object(
895
+ "POST",
896
+ f"{api_base}{resource_path}/steps",
897
+ headers=headers,
898
+ json=action.get("body") or {},
899
+ )
900
+ actions_executed.append(str(action.get("action") or "anu_step"))
901
+ if result.get("grading_error"):
902
+ stopped_reason = "anu_grading_error"
903
+ break
904
+ continue
905
+ if action.get("action") == "wait_or_recover":
906
+ if not action.get("recovery_ready"):
907
+ stopped_reason = "operation_lease_active"
908
+ break
909
+ if args.no_recover_expired:
910
+ stopped_reason = "expired_operation_requires_recovery"
911
+ break
912
+ if action.get("method") != "POST" or action.get("path") != resource_path + "/recover":
913
+ raise ValueError("ANU returned an unsupported course recovery action")
914
+ _remote_object(
915
+ "POST",
916
+ f"{api_base}{resource_path}/recover",
917
+ headers=headers,
918
+ json=action.get("body") or {},
919
+ )
920
+ actions_executed.append("recover_expired_operation")
921
+ continue
922
+ stopped_reason = "student_action_required" if action.get("actor") == "student" else "workflow_finished"
923
+ break
924
+ enrollment = _remote_object("GET", f"{api_base}{resource_path}", headers=headers)
925
+ next_action = enrollment.get("next")
926
+ return {
927
+ "protocol_version": "anu.learn.v1",
928
+ "enrollment_id": args.enrollment_id,
929
+ "actions_executed": actions_executed,
930
+ "stopped_reason": stopped_reason,
931
+ "enrollment": enrollment,
932
+ "next": next_action,
933
+ "pending_client_actions": _course_pending_client_actions(
934
+ next_action if isinstance(next_action, dict) else None
935
+ ),
936
+ "links": enrollment.get("links"),
937
+ "protocol": enrollment.get("protocol"),
938
+ }
939
+
940
+
941
+ def _course_exam(api_base: str, args: Any) -> dict[str, Any]:
942
+ headers = _agent_headers(args)
943
+ path = f"{api_base}/learn/enrollments/{args.enrollment_id}"
944
+ enrollment = _remote_object("GET", path, headers=headers)
945
+ if enrollment.get("phase") != "posttest":
946
+ next_action = _remote_object("GET", path + "/next", headers=headers)
947
+ raise ValueError(
948
+ "Enrollment is not ready for the exam; follow next action: "
949
+ + json.dumps(next_action.get("next"), ensure_ascii=False)
950
+ )
951
+ submitted = _remote_object(
952
+ "POST",
953
+ path + "/submissions",
954
+ headers=headers,
955
+ json={
956
+ "stage": "posttest",
957
+ "answer": _answer_value(args.answer, args.answer_file, "exam answer"),
958
+ "expected_version": enrollment["version"],
959
+ },
960
+ )
961
+ completed = _remote_object(
962
+ "POST",
963
+ path + "/run",
964
+ headers=headers,
965
+ json={"expected_version": submitted["version"]},
966
+ )
967
+ return {
968
+ "protocol_version": "anu.learn.v1",
969
+ "enrollment": completed,
970
+ "certificate": completed.get("certificate"),
971
+ }
972
+
973
+
974
+ def _remote_json(method: str, url: str, **kwargs: Any) -> Any:
975
+ with httpx.Client(timeout=60) as client:
976
+ response = client.request(method, url, **kwargs)
977
+ try:
978
+ payload = response.json()
979
+ except ValueError as exc:
980
+ if response.is_error:
981
+ raise RemoteRequestError(
982
+ f"ANU returned a non-JSON error response ({response.status_code})",
983
+ status_code=response.status_code,
984
+ code=f"http_{response.status_code}",
985
+ retryable=_status_retryable(response.status_code),
986
+ method=method,
987
+ url=url,
988
+ ) from exc
989
+ raise ValueError(f"ANU returned a non-JSON response ({response.status_code})") from exc
990
+ if response.is_error:
991
+ server = payload if isinstance(payload, dict) else {}
992
+ message = server.get("error") or server.get("message")
993
+ code = server.get("code") or server.get("type") or f"http_{response.status_code}"
994
+ retryable = server.get("retryable")
995
+ if not isinstance(retryable, bool):
996
+ retryable = _status_retryable(response.status_code)
997
+ details = server.get("details")
998
+ if details is None and isinstance(payload, dict):
999
+ remaining = {
1000
+ key: value
1001
+ for key, value in payload.items()
1002
+ if key not in {"error", "message", "code", "type", "retryable"}
1003
+ }
1004
+ details = remaining or None
1005
+ raise RemoteRequestError(
1006
+ str(message or f"ANU request failed with HTTP {response.status_code}"),
1007
+ status_code=response.status_code,
1008
+ code=str(code),
1009
+ retryable=retryable,
1010
+ details=details,
1011
+ method=method,
1012
+ url=url,
1013
+ )
1014
+ if not isinstance(payload, (dict, list)):
1015
+ raise ValueError("ANU returned an unexpected response shape")
1016
+ return payload
1017
+
1018
+
1019
+ def _remote_object(method: str, url: str, **kwargs: Any) -> dict[str, Any]:
1020
+ payload = _remote_json(method, url, **kwargs)
1021
+ if not isinstance(payload, dict):
1022
+ raise ValueError("ANU returned an unexpected response shape; expected an object")
1023
+ return payload
1024
+
1025
+
1026
+ def _remote_list(method: str, url: str, **kwargs: Any) -> list[dict[str, Any]]:
1027
+ payload = _remote_json(method, url, **kwargs)
1028
+ if not isinstance(payload, list) or any(not isinstance(row, dict) for row in payload):
1029
+ raise ValueError("ANU returned an unexpected response shape; expected an object list")
1030
+ return payload
1031
+
1032
+
1033
+ def _follow_roundtable(
1034
+ api_base: str,
1035
+ session_id: str,
1036
+ *,
1037
+ participant_key: str,
1038
+ after: int,
1039
+ timeout: float,
1040
+ poll_seconds: float,
1041
+ ) -> dict[str, Any]:
1042
+ if timeout < 0:
1043
+ raise ValueError("timeout must be at least 0 seconds")
1044
+ if poll_seconds <= 0:
1045
+ raise ValueError("poll-seconds must be greater than 0")
1046
+ cursor = max(0, after)
1047
+ deadline = time.monotonic() + timeout
1048
+ while True:
1049
+ context = _guest_roundtable_context(
1050
+ api_base,
1051
+ session_id,
1052
+ participant_key=participant_key,
1053
+ after=cursor,
1054
+ )
1055
+ raw_events = context.get("events") or []
1056
+ if not isinstance(raw_events, list) or any(not isinstance(row, dict) for row in raw_events):
1057
+ raise ValueError("ANU guest context returned an invalid events list")
1058
+ events: list[dict[str, Any]] = raw_events
1059
+ session = context.get("session") if isinstance(context.get("session"), dict) else context
1060
+ timed_out = not events and time.monotonic() >= deadline
1061
+ if events or session.get("status") != "active" or timeout == 0 or timed_out:
1062
+ return {
1063
+ "protocol_version": "anu.roundtable.remote.v1",
1064
+ "session_id": session_id,
1065
+ "status": session.get("status"),
1066
+ "phase": session.get("phase"),
1067
+ "after": cursor,
1068
+ "next_after": _event_cursor(events, cursor),
1069
+ "events": events,
1070
+ "timed_out": timed_out,
1071
+ }
1072
+ remaining = deadline - time.monotonic()
1073
+ if remaining <= 0:
1074
+ continue
1075
+ time.sleep(min(poll_seconds, remaining))
1076
+
1077
+
1078
+ def _guest_roundtable_context(
1079
+ api_base: str,
1080
+ session_id: str,
1081
+ *,
1082
+ participant_key: str,
1083
+ after: int,
1084
+ ) -> dict[str, Any]:
1085
+ context = _remote_object(
1086
+ "GET",
1087
+ f"{api_base}/roundtable/sessions/{session_id}/guests/context",
1088
+ headers={"X-ANU-Participant-Key": participant_key},
1089
+ params={"after": max(0, after)},
1090
+ )
1091
+ raw_events = context.get("events")
1092
+ events = raw_events if isinstance(raw_events, list) else []
1093
+ try:
1094
+ next_after = int(context.get("next_cursor"))
1095
+ except (TypeError, ValueError):
1096
+ next_after = _event_cursor([row for row in events if isinstance(row, dict)], after)
1097
+ context.setdefault("next_after", next_after)
1098
+ return context
1099
+
1100
+
1101
+ def _event_cursor(events: list[dict[str, Any]], fallback: int) -> int:
1102
+ cursor = max(0, fallback)
1103
+ for event in events:
1104
+ try:
1105
+ cursor = max(cursor, int(event.get("sequence", cursor)))
1106
+ except (TypeError, ValueError):
1107
+ continue
1108
+ return cursor
1109
+
1110
+
1111
+ def _status_retryable(status_code: int) -> bool:
1112
+ return status_code in {408, 425, 429} or status_code >= 500
1113
+
1114
+
1115
+ def _api_base(value: str) -> str:
1116
+ base = value.strip().rstrip("/")
1117
+ if not base.startswith(("http://", "https://")):
1118
+ raise ValueError("api_url must be an HTTP(S) URL")
1119
+ return base
1120
+
1121
+
1122
+ def _emit(data: Any, as_json: bool) -> int:
1123
+ if as_json or isinstance(data, dict):
1124
+ print(json.dumps(data, ensure_ascii=False, indent=2))
1125
+ return 0
1126
+ for row in data:
1127
+ if isinstance(row, dict):
1128
+ print(" | ".join(f"{key}={value}" for key, value in row.items()))
1129
+ else:
1130
+ print(row)
1131
+ return 0