agentkit-sdk-python 0.6.2__py3-none-any.whl → 0.6.4__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 (43) hide show
  1. agentkit/auth/session.py +95 -0
  2. agentkit/auth/sso.py +9 -1
  3. agentkit/platform/configuration.py +13 -6
  4. agentkit/sdk/skills/client.py +12 -0
  5. agentkit/sdk/skills/types.py +21 -1
  6. agentkit/toolkit/cli/cli.py +4 -0
  7. agentkit/toolkit/cli/cli_delete.py +102 -0
  8. agentkit/toolkit/cli/cli_deploy.py +52 -11
  9. agentkit/toolkit/cli/cli_invoke.py +250 -4
  10. agentkit/toolkit/cli/cli_list.py +201 -0
  11. agentkit/toolkit/cli/cli_logs.py +253 -0
  12. agentkit/toolkit/cli/cli_skill.py +476 -0
  13. agentkit/toolkit/cli/sandbox/__init__.py +2 -0
  14. agentkit/toolkit/cli/sandbox/cli.py +4 -0
  15. agentkit/toolkit/cli/sandbox/cli_create.py +77 -74
  16. agentkit/toolkit/cli/sandbox/cli_exec.py +133 -14
  17. agentkit/toolkit/cli/sandbox/cli_file.py +4 -2
  18. agentkit/toolkit/cli/sandbox/cli_get.py +1 -1
  19. agentkit/toolkit/cli/sandbox/cli_mount.py +361 -0
  20. agentkit/toolkit/cli/sandbox/cli_run.py +365 -0
  21. agentkit/toolkit/cli/sandbox/cli_shell.py +23 -9
  22. agentkit/toolkit/cli/sandbox/cli_web.py +11 -19
  23. agentkit/toolkit/cli/sandbox/git_config.py +189 -0
  24. agentkit/toolkit/cli/sandbox/model_config.py +218 -42
  25. agentkit/toolkit/cli/sandbox/session_create.py +72 -60
  26. agentkit/toolkit/cli/sandbox/session_sync.py +1 -1
  27. agentkit/toolkit/cli/sandbox/tool_resolve.py +68 -4
  28. agentkit/toolkit/cli/sandbox/tos_config.py +141 -0
  29. agentkit/toolkit/config/utils.py +17 -0
  30. agentkit/toolkit/executors/init_executor.py +13 -3
  31. agentkit/toolkit/harness/__init__.py +2 -1
  32. agentkit/toolkit/harness/config_builder.py +7 -1
  33. agentkit/toolkit/harness/deploy.py +146 -5
  34. agentkit/toolkit/sdk/__init__.py +2 -1
  35. agentkit/toolkit/volcengine/apmplus_logs.py +184 -0
  36. agentkit/version.py +1 -1
  37. {agentkit_sdk_python-0.6.2.dist-info → agentkit_sdk_python-0.6.4.dist-info}/METADATA +2 -1
  38. {agentkit_sdk_python-0.6.2.dist-info → agentkit_sdk_python-0.6.4.dist-info}/RECORD +43 -36
  39. /agentkit/toolkit/cli/sandbox/{utils.py → sandbox_client.py} +0 -0
  40. {agentkit_sdk_python-0.6.2.dist-info → agentkit_sdk_python-0.6.4.dist-info}/WHEEL +0 -0
  41. {agentkit_sdk_python-0.6.2.dist-info → agentkit_sdk_python-0.6.4.dist-info}/entry_points.txt +0 -0
  42. {agentkit_sdk_python-0.6.2.dist-info → agentkit_sdk_python-0.6.4.dist-info}/licenses/LICENSE +0 -0
  43. {agentkit_sdk_python-0.6.2.dist-info → agentkit_sdk_python-0.6.4.dist-info}/top_level.txt +0 -0
@@ -16,6 +16,8 @@
16
16
 
17
17
  from pathlib import Path
18
18
  from typing import Optional, Any
19
+ import base64
20
+ import binascii
19
21
  import json
20
22
  import typer
21
23
  from typer.core import TyperGroup
@@ -627,6 +629,160 @@ def build_harness_overrides(
627
629
  return overrides
628
630
 
629
631
 
632
+ # Fixed ADK app name for the run_sse path. The harness loader serves its single
633
+ # agent under any app name, so a stable constant keeps the CLI decoupled from the
634
+ # deployed HARNESS_NAME.
635
+ _HARNESS_RUN_SSE_APP = "harness"
636
+
637
+
638
+ def _user_id_from_token(token: str) -> str | None:
639
+ """Return the OIDC ``sub`` claim from a JWT bearer token, else ``None``.
640
+
641
+ A custom_jwt harness is called with an OIDC id_token whose ``sub`` is the
642
+ authenticated user's stable id — use it as the run's user_id so sessions are
643
+ tied to the real identity. A key_auth token is an opaque api key (not a JWT,
644
+ no ``sub``), so this returns ``None`` and the caller falls back to a random id.
645
+ """
646
+ parts = token.split(".")
647
+ if len(parts) != 3:
648
+ return None # not a JWT (e.g. a key_auth api key)
649
+ payload = parts[1]
650
+ payload += "=" * (-len(payload) % 4) # restore base64 padding
651
+ try:
652
+ claims = json.loads(base64.urlsafe_b64decode(payload))
653
+ except (ValueError, binascii.Error):
654
+ return None # malformed JWT payload → fall back to random
655
+ sub = claims.get("sub")
656
+ return sub if isinstance(sub, str) and sub else None
657
+
658
+
659
+ def _harness_run_sse(
660
+ *,
661
+ base_url: str,
662
+ token: str,
663
+ prompt: str,
664
+ session_id: str,
665
+ overrides: dict,
666
+ raw: bool,
667
+ ) -> Any:
668
+ """Invoke a deployed harness via the ADK ``/run_sse`` endpoint (streaming).
669
+
670
+ app_name is the fixed ``"harness"``; user_id is the JWT ``sub`` when the token
671
+ is an OIDC id_token, else a random id; session_id is the caller's. When
672
+ ``overrides`` is non-empty it is sent as the ``harness`` field so the runtime
673
+ streams a spawned (overridden) agent; otherwise the base agent.
674
+ """
675
+ import requests
676
+
677
+ app_name = _HARNESS_RUN_SSE_APP
678
+ sub = _user_id_from_token(token)
679
+ user_id = sub or f"u-{uuid.uuid4().hex[:12]}"
680
+ user_id_origin = "jwt sub" if sub else "random"
681
+ headers = {"Content-Type": "application/json"}
682
+ if token:
683
+ headers["Authorization"] = f"Bearer {token}"
684
+ console.print(
685
+ f"[blue]run_sse: app_name={app_name}, user_id={user_id} ({user_id_origin}), "
686
+ f"session_id={session_id}[/blue]"
687
+ )
688
+
689
+ # ADK /run_sse requires an existing session; create it (ignore "exists").
690
+ session_url = f"{base_url}/apps/{app_name}/users/{user_id}/sessions/{session_id}"
691
+ try:
692
+ sr = requests.post(session_url, json={}, headers=headers, timeout=60)
693
+ except requests.RequestException as e:
694
+ console.print(f"[red]❌ Session create failed: {e}[/red]")
695
+ raise typer.Exit(1)
696
+ if sr.status_code not in (200, 400, 409):
697
+ console.print(
698
+ f"[red]❌ Session create HTTP {sr.status_code}: {sr.text[:300]}[/red]"
699
+ )
700
+ raise typer.Exit(1)
701
+
702
+ body: dict[str, Any] = {
703
+ "app_name": app_name,
704
+ "user_id": user_id,
705
+ "session_id": session_id,
706
+ "new_message": {"role": "user", "parts": [{"text": prompt}]},
707
+ "streaming": True,
708
+ }
709
+ if overrides:
710
+ body["harness"] = overrides
711
+ console.print(f"[blue]Using one-time overrides: {overrides}[/blue]")
712
+ try:
713
+ resp = requests.post(
714
+ f"{base_url}/run_sse",
715
+ json=body,
716
+ headers=headers,
717
+ timeout=300,
718
+ stream=True,
719
+ )
720
+ except requests.RequestException as e:
721
+ console.print(f"[red]❌ run_sse request failed: {e}[/red]")
722
+ raise typer.Exit(1)
723
+ if resp.status_code != 200:
724
+ console.print(
725
+ f"[red]❌ run_sse HTTP {resp.status_code}: {resp.text[:500]}[/red]"
726
+ )
727
+ raise typer.Exit(1)
728
+
729
+ def _answer_text(event: dict) -> str:
730
+ # Only the answer text; the model's "thought" (reasoning) parts stay hidden
731
+ # behind the thinking spinner.
732
+ parts = (event.get("content") or {}).get("parts") or []
733
+ return "".join(
734
+ p["text"]
735
+ for p in parts
736
+ if isinstance(p, dict) and p.get("text") and not p.get("thought")
737
+ )
738
+
739
+ streamed = []
740
+ final_answer = ""
741
+ answer_open = False # first answer token seen → spinner stopped, reply started
742
+ # A spinner covers the wait (model latency + reasoning) until the answer starts.
743
+ status = None if raw else console.status("thinking", spinner="dots")
744
+ if status is not None:
745
+ status.start()
746
+ try:
747
+ for line in resp.iter_lines(decode_unicode=True):
748
+ if not line:
749
+ continue
750
+ if raw:
751
+ print(line) # builtin print: no rich wrapping/markup on raw JSON
752
+ continue
753
+ event = _normalize_stream_event(line)
754
+ if not isinstance(event, dict):
755
+ continue
756
+ if event.get("error"):
757
+ if status is not None and not answer_open:
758
+ status.stop()
759
+ console.print(f"\n[red]Error: {event['error']}[/red]")
760
+ continue
761
+ answer = _answer_text(event)
762
+ # `partial=False` is the final aggregate (repeats everything) — keep it
763
+ # as a fallback but don't print it; partial events stream the deltas.
764
+ if event.get("partial") is False:
765
+ final_answer = answer or final_answer
766
+ continue
767
+ if answer:
768
+ if not answer_open:
769
+ if status is not None:
770
+ status.stop() # leave the thinking phase
771
+ console.print("") # blank line before the reply
772
+ answer_open = True
773
+ console.print(answer, end="", style="green")
774
+ streamed.append(answer)
775
+ finally:
776
+ if status is not None and not answer_open:
777
+ status.stop()
778
+
779
+ if not streamed and final_answer:
780
+ console.print("")
781
+ console.print(final_answer, end="", style="green")
782
+ console.print("")
783
+ return "".join(streamed) or final_answer
784
+
785
+
630
786
  @invoke_app.command("harness")
631
787
  def harness_command(
632
788
  name: str = typer.Argument(
@@ -640,7 +796,9 @@ def harness_command(
640
796
  "agentkit_user", "--user-id", help="user_id for the run."
641
797
  ),
642
798
  session_id: str = typer.Option(
643
- "agentkit_sample_session", "--session-id", help="session_id for the run."
799
+ None,
800
+ "--session-id",
801
+ help="session_id for the run (random if unset).",
644
802
  ),
645
803
  max_llm_calls: int = typer.Option(
646
804
  None,
@@ -675,7 +833,12 @@ def harness_command(
675
833
  help="Bearer token override (e.g. OAuth JWT for custom_jwt harnesses).",
676
834
  ),
677
835
  raw: bool = typer.Option(
678
- False, "--raw", help="Print the raw InvokeHarnessResponse JSON."
836
+ False, "--raw", help="Print the raw response (InvokeHarnessResponse / SSE)."
837
+ ),
838
+ protocol: str = typer.Option(
839
+ "run_sse",
840
+ "--protocol",
841
+ help="Transport: 'run_sse' (ADK /run_sse, default) or 'invoke' (POST /harness/invoke).",
679
842
  ),
680
843
  ) -> Any:
681
844
  """Invoke a deployed harness by name (resolved via the harness.json registry).
@@ -712,8 +875,69 @@ def harness_command(
712
875
  )
713
876
  raise typer.Exit(1)
714
877
 
715
- invoke_url = entry["url"].rstrip("/") + "/harness/invoke"
716
- token = apikey or entry.get("key") or ""
878
+ if protocol not in ("invoke", "run_sse"):
879
+ console.print(
880
+ f"[red]Error: --protocol must be 'invoke' or 'run_sse', got '{protocol}'.[/red]"
881
+ )
882
+ raise typer.Exit(1)
883
+
884
+ base_url = entry["url"].rstrip("/")
885
+
886
+ # Inbound credential selection is auth-type aware — the registry records how each
887
+ # harness was deployed (harness/deploy.py _record_harness): a custom_jwt harness is
888
+ # {auth_type:"custom_jwt", no "key"}; a key_auth harness is {"key": ...}.
889
+ # 1. --apikey always wins (explicit manual override).
890
+ # 2. custom_jwt harness → the `agentkit login` id_token (OIDC JWT, auto-refreshed) —
891
+ # the data plane's JWT path; refresh failure errors (re-login), never silent.
892
+ # 3. key_auth harness → the static "key"; a key_auth authorizer would reject a JWT,
893
+ # so we never force the id_token onto it.
894
+ # Both transports (invoke and run_sse) share this resolution.
895
+ from agentkit.auth.errors import AuthError
896
+ from agentkit.auth.sso import load_session
897
+
898
+ is_jwt_harness = entry.get("auth_type") == "custom_jwt" or not entry.get("key")
899
+ auth_session = None # set only when the login id_token is the credential (enables 401 refresh)
900
+ token = apikey or ""
901
+ if not token and is_jwt_harness:
902
+ try:
903
+ auth_session = load_session()
904
+ except Exception:
905
+ auth_session = None
906
+ if auth_session is not None:
907
+ try:
908
+ token = auth_session.valid_id_token()
909
+ except AuthError as e:
910
+ console.print(f"[red]❌ {e}[/red]")
911
+ if getattr(e, "hint", None):
912
+ console.print(f"[yellow]{e.hint}[/yellow]")
913
+ raise typer.Exit(1)
914
+ if not token:
915
+ token = entry.get("key") or ""
916
+
917
+ # No session given → mint a random one (both transports behave identically;
918
+ # creating it is idempotent).
919
+ session_id = session_id or f"s-{uuid.uuid4().hex[:12]}"
920
+
921
+ if protocol == "run_sse":
922
+ # run_sse supports the same overrides (sent as the `harness` field); only
923
+ # --max-llm-calls is invoke-only (not part of the ADK run_sse request).
924
+ if max_llm_calls is not None:
925
+ console.print(
926
+ "[yellow]Note: --max-llm-calls is ignored with --protocol "
927
+ "run_sse.[/yellow]"
928
+ )
929
+ return _harness_run_sse(
930
+ base_url=base_url,
931
+ token=token,
932
+ prompt=message,
933
+ session_id=session_id,
934
+ overrides=build_harness_overrides(
935
+ system_prompt, model_name, tools, skills, runtime
936
+ ),
937
+ raw=raw,
938
+ )
939
+
940
+ invoke_url = base_url + "/harness/invoke"
717
941
  req_headers = {"Content-Type": "application/json"}
718
942
  if token:
719
943
  req_headers["Authorization"] = f"Bearer {token}"
@@ -739,6 +963,18 @@ def harness_command(
739
963
 
740
964
  try:
741
965
  resp = requests.post(invoke_url, json=body, headers=req_headers, timeout=300)
966
+ # If the harness rejects a locally-valid JWT (clock skew, mid-flight rotation,
967
+ # server-side revocation), force one refresh and retry exactly once.
968
+ if resp.status_code == 401 and auth_session is not None and not apikey:
969
+ try:
970
+ token = auth_session.valid_id_token(force_refresh=True)
971
+ except AuthError as e:
972
+ console.print(f"[red]❌ session refresh failed: {e}[/red]")
973
+ if getattr(e, "hint", None):
974
+ console.print(f"[yellow]{e.hint}[/yellow]")
975
+ raise typer.Exit(1)
976
+ req_headers["Authorization"] = f"Bearer {token}"
977
+ resp = requests.post(invoke_url, json=body, headers=req_headers, timeout=300)
742
978
  except requests.RequestException as e:
743
979
  console.print(f"[red]❌ Request to {invoke_url} failed: {e}[/red]")
744
980
  raise typer.Exit(1)
@@ -755,10 +991,20 @@ def harness_command(
755
991
  console.print(f"[red]❌ Non-JSON response: {resp.text}[/red]")
756
992
  raise typer.Exit(1)
757
993
 
994
+ # The harness returns failures (unsupported tool, skill load failure, or a
995
+ # runtime error) in `error`; surface it verbatim instead of as normal output.
996
+ error = data.get("error") if isinstance(data, dict) else None
997
+
758
998
  if raw:
759
999
  console.print(json.dumps(data, ensure_ascii=False, indent=2))
1000
+ if error:
1001
+ raise typer.Exit(1)
760
1002
  return str(data)
761
1003
 
1004
+ if error:
1005
+ console.print(f"[red]❌ Harness error: {error}[/red]")
1006
+ raise typer.Exit(1)
1007
+
762
1008
  console.print("[green]✅ Invocation successful[/green]")
763
1009
  if data.get("overwrite"):
764
1010
  console.print("[yellow](served with one-time overrides)[/yellow]")
@@ -46,6 +46,39 @@ def _is_harness_runtime(runtime: rt.AgentKitRuntimesForListRuntimes) -> bool:
46
46
  return False
47
47
 
48
48
 
49
+ # Fixed ADK app name the harness loader serves its agent under (the deployed
50
+ # HARNESS_NAME is irrelevant to the ADK app path).
51
+ _HARNESS_APP = "harness"
52
+
53
+
54
+ def _user_id_from_token(token: str) -> Optional[str]:
55
+ """Return the OIDC ``sub`` claim from a JWT bearer token, else ``None``.
56
+
57
+ A custom_jwt harness is reached with an OIDC id_token whose ``sub`` is the
58
+ authenticated user's stable id. A key_auth token is an opaque api key (not a
59
+ JWT, no ``sub``), so this returns ``None`` and the caller must be given an
60
+ explicit ``--user-id``.
61
+
62
+ NOTE: this mirrors ``cli_invoke._user_id_from_token``; consolidate the two
63
+ into one shared helper once the ``invoke harness`` run_sse work lands.
64
+ """
65
+ import base64
66
+ import binascii
67
+ import json
68
+
69
+ parts = token.split(".")
70
+ if len(parts) != 3:
71
+ return None # not a JWT (e.g. a key_auth api key)
72
+ payload = parts[1]
73
+ payload += "=" * (-len(payload) % 4) # restore base64 padding
74
+ try:
75
+ claims = json.loads(base64.urlsafe_b64decode(payload))
76
+ except (ValueError, binascii.Error):
77
+ return None # malformed JWT payload
78
+ sub = claims.get("sub")
79
+ return sub if isinstance(sub, str) and sub else None
80
+
81
+
49
82
  @list_app.command("harness")
50
83
  def list_harness_command(
51
84
  region: Optional[str] = typer.Option(
@@ -117,6 +150,7 @@ def list_harness_command(
117
150
  ("RuntimeId", "RuntimeId", "cyan"),
118
151
  ("Name", "Name", "white"),
119
152
  ("Status", "Status", "green"),
153
+ ("CurrentVersionNumber", "Version", "blue"),
120
154
  ("ProjectName", "ProjectName", "yellow"),
121
155
  ("UpdatedAt", "UpdatedAt", "magenta"),
122
156
  ]
@@ -128,6 +162,173 @@ def list_harness_command(
128
162
  local_console.print(table)
129
163
 
130
164
 
165
+ def _resolve_harness_endpoint(harness: str, directory: str, apikey: Optional[str]):
166
+ """Resolve a deployed harness's data-plane base URL and bearer token.
167
+
168
+ Mirrors ``invoke harness``'s auth-type-aware credential selection:
169
+ 1. ``--apikey`` always wins (explicit override);
170
+ 2. a custom_jwt harness (no stored ``key``) → the ``agentkit login`` OIDC
171
+ id_token (auto-refreshed), whose ``sub`` identifies the caller;
172
+ 3. a key_auth harness → its static ``key`` (an opaque api key, not a JWT).
173
+
174
+ Returns ``(base_url, token)``. Fast-fails when the harness is not in the
175
+ registry, or when a needed login id_token cannot be refreshed.
176
+ """
177
+ from agentkit.toolkit.harness import load_harness_registry
178
+
179
+ registry = load_harness_registry(directory)
180
+ entry = registry.get(harness)
181
+ if not isinstance(entry, dict) or not entry.get("url"):
182
+ from pathlib import Path
183
+
184
+ registry_path = Path(directory).resolve() / "harness.json"
185
+ console.print(
186
+ f"[red]Error: harness '{harness}' not found in registry {registry_path}. "
187
+ f"Deploy it first with `agentkit deploy --harness {harness}`.[/red]"
188
+ )
189
+ raise typer.Exit(1)
190
+
191
+ base_url = entry["url"].rstrip("/")
192
+
193
+ from agentkit.auth.errors import AuthError
194
+ from agentkit.auth.sso import load_session
195
+
196
+ is_jwt_harness = entry.get("auth_type") == "custom_jwt" or not entry.get("key")
197
+ token = apikey or ""
198
+ if not token and is_jwt_harness:
199
+ try:
200
+ auth_session = load_session()
201
+ except Exception:
202
+ auth_session = None
203
+ if auth_session is not None:
204
+ try:
205
+ token = auth_session.valid_id_token()
206
+ except AuthError as e:
207
+ console.print(f"[red]❌ {e}[/red]")
208
+ if getattr(e, "hint", None):
209
+ console.print(f"[yellow]{e.hint}[/yellow]")
210
+ raise typer.Exit(1)
211
+ if not token:
212
+ token = entry.get("key") or ""
213
+
214
+ return base_url, token
215
+
216
+
217
+ @list_app.command("sessions")
218
+ def list_sessions_command(
219
+ harness: str = typer.Option(
220
+ ..., "--harness", help="Harness name (resolved via the harness.json registry)."
221
+ ),
222
+ user_id: Optional[str] = typer.Option(
223
+ None,
224
+ "--user-id",
225
+ help=(
226
+ "user_id whose sessions to list. If omitted, it is taken from the "
227
+ "JWT `sub` of the harness credential; a key_auth harness has no JWT, "
228
+ "so --user-id is required there."
229
+ ),
230
+ ),
231
+ apikey: Optional[str] = typer.Option(
232
+ None,
233
+ "--apikey",
234
+ "-ak",
235
+ help="Bearer token (e.g. a custom_jwt harness's OIDC JWT) overriding the registry credential.",
236
+ ),
237
+ directory: str = typer.Option(
238
+ ".", "--directory", help="Directory containing harness.json."
239
+ ),
240
+ output: str = typer.Option(
241
+ "table", "--output", help="Output format: table|json|yaml"
242
+ ),
243
+ quiet: bool = typer.Option(
244
+ False, "--quiet", "-q", help="Print only session ids"
245
+ ),
246
+ no_color: bool = typer.Option(
247
+ False, "--no-color", "-nc", help="Disable colored output for tables/panels"
248
+ ),
249
+ ):
250
+ """List a user's conversation sessions on a deployed harness runtime.
251
+
252
+ Calls the harness runtime's ADK endpoint
253
+ ``GET /apps/harness/users/{user_id}/sessions``. ADK requires a user_id (there
254
+ is no cross-user listing), so it must be given explicitly via --user-id or be
255
+ derivable from the JWT `sub` of the harness credential.
256
+ """
257
+ import json
258
+ from datetime import datetime
259
+
260
+ import requests
261
+ from rich.table import Table
262
+
263
+ local_console = console if not no_color else Console(no_color=True)
264
+
265
+ base_url, token = _resolve_harness_endpoint(harness, directory, apikey)
266
+
267
+ resolved_user_id = user_id or _user_id_from_token(token)
268
+ if not resolved_user_id:
269
+ local_console.print(
270
+ "[red]Error: cannot determine user_id — no --user-id given and the "
271
+ "harness credential is not a JWT with a `sub` claim (e.g. a key_auth "
272
+ "harness). Pass --user-id explicitly.[/red]"
273
+ )
274
+ raise typer.Exit(1)
275
+
276
+ url = f"{base_url}/apps/{_HARNESS_APP}/users/{resolved_user_id}/sessions"
277
+ headers = {}
278
+ if token:
279
+ headers["Authorization"] = f"Bearer {token}"
280
+
281
+ with local_console.status("[cyan]Fetching sessions...[/cyan]", spinner="dots"):
282
+ try:
283
+ resp = requests.get(url, headers=headers, timeout=60)
284
+ except requests.RequestException as e:
285
+ local_console.print(f"[red]❌ List sessions request failed: {e}[/red]")
286
+ raise typer.Exit(1)
287
+
288
+ if resp.status_code != 200:
289
+ local_console.print(
290
+ f"[red]❌ List sessions HTTP {resp.status_code}: {resp.text[:300]}[/red]"
291
+ )
292
+ raise typer.Exit(1)
293
+
294
+ sessions = resp.json() or []
295
+
296
+ if quiet:
297
+ for s in sessions:
298
+ local_console.print(s.get("id", ""))
299
+ return
300
+
301
+ if output.lower() == "json":
302
+ local_console.print(json.dumps(sessions, indent=2, ensure_ascii=False))
303
+ return
304
+ if output.lower() == "yaml":
305
+ import yaml
306
+
307
+ local_console.print(yaml.safe_dump(sessions, sort_keys=False, allow_unicode=True))
308
+ return
309
+
310
+ table = Table(
311
+ title=f"Sessions for user '{resolved_user_id}' (Count: {len(sessions)})",
312
+ show_lines=False,
313
+ )
314
+ table.add_column("SessionId", style="cyan")
315
+ table.add_column("Events", style="green")
316
+ table.add_column("LastUpdate", style="magenta")
317
+ for s in sessions:
318
+ ts = s.get("lastUpdateTime")
319
+ last_update = (
320
+ datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
321
+ if isinstance(ts, (int, float)) and ts
322
+ else ""
323
+ )
324
+ table.add_row(
325
+ str(s.get("id", "")),
326
+ str(len(s.get("events", []))),
327
+ last_update,
328
+ )
329
+ local_console.print(table)
330
+
331
+
131
332
  @list_app.command("credentials")
132
333
  def list_credentials_command(
133
334
  region: Optional[str] = typer.Option(