glaip-sdk 0.0.5b1__py3-none-any.whl → 0.0.7__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. glaip_sdk/__init__.py +1 -1
  2. glaip_sdk/_version.py +42 -19
  3. glaip_sdk/branding.py +3 -2
  4. glaip_sdk/cli/commands/__init__.py +1 -1
  5. glaip_sdk/cli/commands/agents.py +452 -285
  6. glaip_sdk/cli/commands/configure.py +14 -13
  7. glaip_sdk/cli/commands/mcps.py +30 -20
  8. glaip_sdk/cli/commands/models.py +5 -3
  9. glaip_sdk/cli/commands/tools.py +111 -106
  10. glaip_sdk/cli/display.py +48 -27
  11. glaip_sdk/cli/io.py +1 -1
  12. glaip_sdk/cli/main.py +26 -5
  13. glaip_sdk/cli/resolution.py +5 -4
  14. glaip_sdk/cli/utils.py +437 -188
  15. glaip_sdk/cli/validators.py +7 -2
  16. glaip_sdk/client/agents.py +276 -153
  17. glaip_sdk/client/base.py +69 -27
  18. glaip_sdk/client/tools.py +44 -26
  19. glaip_sdk/client/validators.py +154 -94
  20. glaip_sdk/config/constants.py +0 -2
  21. glaip_sdk/models.py +5 -4
  22. glaip_sdk/utils/__init__.py +7 -7
  23. glaip_sdk/utils/client_utils.py +191 -101
  24. glaip_sdk/utils/display.py +4 -2
  25. glaip_sdk/utils/general.py +8 -6
  26. glaip_sdk/utils/import_export.py +58 -25
  27. glaip_sdk/utils/rendering/formatting.py +12 -6
  28. glaip_sdk/utils/rendering/models.py +1 -1
  29. glaip_sdk/utils/rendering/renderer/base.py +523 -332
  30. glaip_sdk/utils/rendering/renderer/console.py +6 -5
  31. glaip_sdk/utils/rendering/renderer/debug.py +94 -52
  32. glaip_sdk/utils/rendering/renderer/stream.py +93 -48
  33. glaip_sdk/utils/rendering/steps.py +103 -39
  34. glaip_sdk/utils/rich_utils.py +1 -1
  35. glaip_sdk/utils/run_renderer.py +1 -1
  36. glaip_sdk/utils/serialization.py +9 -3
  37. glaip_sdk/utils/validation.py +2 -2
  38. glaip_sdk-0.0.7.dist-info/METADATA +183 -0
  39. glaip_sdk-0.0.7.dist-info/RECORD +55 -0
  40. glaip_sdk-0.0.5b1.dist-info/METADATA +0 -645
  41. glaip_sdk-0.0.5b1.dist-info/RECORD +0 -55
  42. {glaip_sdk-0.0.5b1.dist-info → glaip_sdk-0.0.7.dist-info}/WHEEL +0 -0
  43. {glaip_sdk-0.0.5b1.dist-info → glaip_sdk-0.0.7.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/utils.py CHANGED
@@ -14,6 +14,8 @@ import shlex
14
14
  import shutil
15
15
  import subprocess
16
16
  import tempfile
17
+ from collections.abc import Callable
18
+ from pathlib import Path
17
19
  from typing import TYPE_CHECKING, Any
18
20
 
19
21
  import click
@@ -28,18 +30,16 @@ try:
28
30
  from prompt_toolkit.shortcuts import prompt
29
31
 
30
32
  _HAS_PTK = True
31
- except Exception:
33
+ except Exception: # pragma: no cover - optional dependency
32
34
  _HAS_PTK = False
33
35
 
34
36
  try:
35
37
  import questionary
36
- except Exception:
38
+ except Exception: # pragma: no cover - optional dependency
37
39
  questionary = None
38
40
 
39
- if TYPE_CHECKING:
41
+ if TYPE_CHECKING: # pragma: no cover - import-only during type checking
40
42
  from glaip_sdk import Client
41
-
42
- from glaip_sdk import Client
43
43
  from glaip_sdk.cli.commands.configure import load_config
44
44
  from glaip_sdk.rich_components import AIPPanel, AIPTable
45
45
  from glaip_sdk.utils import is_uuid
@@ -52,6 +52,31 @@ from glaip_sdk.utils.rendering.renderer import (
52
52
  console = Console()
53
53
 
54
54
 
55
+ # ----------------------------- Context helpers ---------------------------- #
56
+
57
+
58
+ def get_ctx_value(ctx: Any, key: str, default: Any = None) -> Any:
59
+ """Safely resolve a value from click's context object."""
60
+ if ctx is None:
61
+ return default
62
+
63
+ obj = getattr(ctx, "obj", None)
64
+ if obj is None:
65
+ return default
66
+
67
+ if isinstance(obj, dict):
68
+ return obj.get(key, default)
69
+
70
+ getter = getattr(obj, "get", None)
71
+ if callable(getter):
72
+ try:
73
+ return getter(key, default)
74
+ except TypeError:
75
+ return default
76
+
77
+ return getattr(obj, key, default) if hasattr(obj, key) else default
78
+
79
+
55
80
  # ----------------------------- Pager helpers ----------------------------- #
56
81
 
57
82
 
@@ -75,8 +100,8 @@ def _prepare_pager_env(
75
100
 
76
101
 
77
102
  def _render_ansi(
78
- renderable,
79
- ) -> str: # pragma: no cover - rendering requires real terminal
103
+ renderable: Any,
104
+ ) -> str:
80
105
  """Render a Rich renderable to an ANSI string suitable for piping to 'less'."""
81
106
  buf = io.StringIO()
82
107
  tmp_console = Console(
@@ -92,7 +117,7 @@ def _render_ansi(
92
117
  return buf.getvalue()
93
118
 
94
119
 
95
- def _pager_header() -> str: # pragma: no cover - terminal UI helper
120
+ def _pager_header() -> str:
96
121
  v = (os.getenv("AIP_PAGER_HEADER", "1") or "1").strip().lower()
97
122
  if v in {"0", "false", "off"}:
98
123
  return ""
@@ -105,15 +130,17 @@ def _pager_header() -> str: # pragma: no cover - terminal UI helper
105
130
  )
106
131
 
107
132
 
108
- def _page_with_system_pager(
109
- ansi_text: str,
110
- ) -> bool: # pragma: no cover - spawns real pager
111
- """Prefer 'less' with a temp file so stdin remains the TTY."""
133
+ def _should_use_pager() -> bool:
134
+ """Check if we should attempt to use a system pager."""
112
135
  if not (console.is_terminal and os.isatty(1)):
113
136
  return False
114
137
  if (os.getenv("TERM") or "").lower() == "dumb":
115
138
  return False
139
+ return True
116
140
 
141
+
142
+ def _resolve_pager_command() -> tuple[list[str] | None, str | None]:
143
+ """Resolve the pager command and path to use."""
117
144
  pager_cmd = None
118
145
  pager_env = os.getenv("PAGER")
119
146
  if pager_env:
@@ -122,60 +149,92 @@ def _page_with_system_pager(
122
149
  pager_cmd = parts
123
150
 
124
151
  less_path = shutil.which("less")
125
- if pager_cmd or less_path:
126
- _prepare_pager_env(clear_on_exit=True)
127
- with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
128
- tmp.write(_pager_header())
129
- tmp.write(ansi_text)
130
- tmp_path = tmp.name
131
- try:
132
- if pager_cmd:
133
- subprocess.run([*pager_cmd, tmp_path], check=False)
134
- else:
135
- flags = os.getenv("LESS", "-RS").split()
136
- subprocess.run([less_path, *flags, tmp_path], check=False)
137
- finally:
138
- try:
139
- os.unlink(tmp_path)
140
- except Exception:
141
- pass
152
+ return pager_cmd, less_path
153
+
154
+
155
+ def _run_less_pager(
156
+ pager_cmd: list[str] | None, less_path: str | None, tmp_path: str
157
+ ) -> None:
158
+ """Run less pager with appropriate command and flags."""
159
+ if pager_cmd:
160
+ subprocess.run([*pager_cmd, tmp_path], check=False)
161
+ else:
162
+ flags = os.getenv("LESS", "-RS").split()
163
+ subprocess.run([less_path, *flags, tmp_path], check=False)
164
+
165
+
166
+ def _run_more_pager(tmp_path: str) -> None:
167
+ """Run more pager as fallback."""
168
+ more_path = shutil.which("more")
169
+ if more_path:
170
+ subprocess.run([more_path, tmp_path], check=False)
171
+ else:
172
+ raise FileNotFoundError("more command not found")
173
+
174
+
175
+ def _run_pager_with_temp_file(
176
+ pager_runner: Callable[[str], None], ansi_text: str
177
+ ) -> bool:
178
+ """Run a pager using a temporary file containing the content."""
179
+ _prepare_pager_env(clear_on_exit=True)
180
+ with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
181
+ tmp.write(_pager_header())
182
+ tmp.write(ansi_text)
183
+ tmp_path = tmp.name
184
+ try:
185
+ pager_runner(tmp_path)
142
186
  return True
187
+ except Exception:
188
+ # If pager fails, return False to indicate paging was not successful
189
+ return False
190
+ finally:
191
+ try:
192
+ os.unlink(tmp_path)
193
+ except Exception:
194
+ pass
195
+
196
+
197
+ def _page_with_system_pager(
198
+ ansi_text: str,
199
+ ) -> bool: # pragma: no cover - spawns real pager
200
+ """Prefer 'less' with a temp file so stdin remains the TTY."""
201
+ if not _should_use_pager():
202
+ return False
203
+
204
+ pager_cmd, less_path = _resolve_pager_command()
205
+
206
+ if pager_cmd or less_path:
207
+ return _run_pager_with_temp_file(
208
+ lambda tmp_path: _run_less_pager(pager_cmd, less_path, tmp_path), ansi_text
209
+ )
143
210
 
144
211
  # Windows 'more' is poor with ANSI; let Rich fallback handle it
145
212
  if platform.system().lower().startswith("win"):
146
213
  return False
147
214
 
148
215
  # POSIX 'more' fallback (may or may not honor ANSI)
149
- more_path = shutil.which("more")
150
- if more_path:
151
- with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
152
- tmp.write(_pager_header())
153
- tmp.write(ansi_text)
154
- tmp_path = tmp.name
155
- try:
156
- subprocess.run([more_path, tmp_path], check=False)
157
- finally:
158
- try:
159
- os.unlink(tmp_path)
160
- except Exception:
161
- pass
162
- return True
216
+ return _run_pager_with_temp_file(_run_more_pager, ansi_text)
163
217
 
164
- return False
165
218
 
219
+ def _get_view(ctx: Any) -> str:
220
+ view = get_ctx_value(ctx, "view")
221
+ if view:
222
+ return view
166
223
 
167
- def _get_view(ctx) -> str:
168
- obj = (ctx.obj or {}) if ctx is not None else {}
169
- return obj.get("view") or obj.get("format") or "rich"
224
+ fallback = get_ctx_value(ctx, "format")
225
+ return fallback or "rich"
170
226
 
171
227
 
172
228
  # ----------------------------- Client config ----------------------------- #
173
229
 
174
230
 
175
- def get_client(ctx) -> Client:
231
+ def get_client(ctx: Any) -> Client: # pragma: no cover
176
232
  """Get configured client from context, env, and config file (ctx > env > file)."""
233
+ from glaip_sdk import Client
234
+
177
235
  file_config = load_config() or {}
178
- context_config = (ctx.obj or {}) if ctx else {}
236
+ context_config_obj = getattr(ctx, "obj", None)
237
+ context_config = context_config_obj or {}
179
238
 
180
239
  raw_timeout = os.getenv("AIP_TIMEOUT", "0") or "0"
181
240
  try:
@@ -269,17 +328,17 @@ def _resolve_mask_fields() -> set[str]:
269
328
  # ----------------------------- Fuzzy palette ----------------------------- #
270
329
 
271
330
 
272
- def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
273
- """
274
- Build a compact text label for the palette.
275
- Prefers: name • type • framework • [id] (when available)
276
- Falls back to first 2 columns + [id].
277
- """
331
+ def _extract_display_fields(row: dict[str, Any]) -> tuple[str, str, str, str]:
332
+ """Extract display fields from row data."""
278
333
  name = str(row.get("name", "")).strip()
279
334
  _id = str(row.get("id", "")).strip()
280
335
  type_ = str(row.get("type", "")).strip()
281
336
  fw = str(row.get("framework", "")).strip()
337
+ return name, _id, type_, fw
282
338
 
339
+
340
+ def _build_primary_parts(name: str, type_: str, fw: str) -> list[str]:
341
+ """Build primary display parts from name, type, and framework."""
283
342
  parts = []
284
343
  if name:
285
344
  parts.append(name)
@@ -287,18 +346,58 @@ def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
287
346
  parts.append(type_)
288
347
  if fw:
289
348
  parts.append(fw)
349
+ return parts
350
+
351
+
352
+ def _get_fallback_columns(columns: list[tuple]) -> list[tuple]:
353
+ """Get first two visible columns for fallback display."""
354
+ return columns[:2]
355
+
356
+
357
+ def _is_standard_field(k: str) -> bool:
358
+ """Check if field is a standard field to skip."""
359
+ return k in ("id", "name", "type", "framework")
360
+
361
+
362
+ def _extract_fallback_values(row: dict[str, Any], columns: list[tuple]) -> list[str]:
363
+ """Extract fallback values from columns."""
364
+ fallback_parts = []
365
+ for k, _hdr, _style, _w in columns:
366
+ if _is_standard_field(k):
367
+ continue
368
+ val = str(row.get(k, "")).strip()
369
+ if val:
370
+ fallback_parts.append(val)
371
+ if len(fallback_parts) >= 2:
372
+ break
373
+ return fallback_parts
374
+
375
+
376
+ def _build_display_parts(
377
+ name: str, _id: str, type_: str, fw: str, row: dict[str, Any], columns: list[tuple]
378
+ ) -> list[str]:
379
+ """Build complete display parts list."""
380
+ parts = _build_primary_parts(name, type_, fw)
381
+
290
382
  if not parts:
291
- # use first two visible columns
292
- for k, _hdr, _style, _w in columns[:2]:
293
- if k in ("id", "name", "type", "framework"):
294
- continue
295
- val = str(row.get(k, "")).strip()
296
- if val:
297
- parts.append(val)
298
- if len(parts) >= 2:
299
- break
383
+ # Use fallback columns
384
+ fallback_columns = _get_fallback_columns(columns)
385
+ parts.extend(_extract_fallback_values(row, fallback_columns))
386
+
300
387
  if _id:
301
388
  parts.append(f"[{_id}]")
389
+
390
+ return parts
391
+
392
+
393
+ def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
394
+ """
395
+ Build a compact text label for the palette.
396
+ Prefers: name • type • framework • [id] (when available)
397
+ Falls back to first 2 columns + [id].
398
+ """
399
+ name, _id, type_, fw = _extract_display_fields(row)
400
+ parts = _build_display_parts(name, _id, type_, fw, row, columns)
302
401
  return " • ".join(parts) if parts else (_id or "(row)")
303
402
 
304
403
 
@@ -332,10 +431,12 @@ def _build_unique_labels(
332
431
  class _FuzzyCompleter:
333
432
  """Fuzzy completer for prompt_toolkit."""
334
433
 
335
- def __init__(self, words: list[str]):
434
+ def __init__(self, words: list[str]) -> None:
336
435
  self.words = words
337
436
 
338
- def get_completions(self, document, _complete_event):
437
+ def get_completions(
438
+ self, document: Any, _complete_event: Any
439
+ ) -> Any: # pragma: no cover
339
440
  word = document.get_word_before_cursor()
340
441
  if not word:
341
442
  return
@@ -346,7 +447,7 @@ class _FuzzyCompleter:
346
447
  if self._fuzzy_match(word_lower, label_lower):
347
448
  yield Completion(label, start_position=-len(word))
348
449
 
349
- def _fuzzy_match(self, search: str, target: str) -> bool:
450
+ def _fuzzy_match(self, search: str, target: str) -> bool: # pragma: no cover
350
451
  """True fuzzy matching: checks if all characters in search appear in order in target."""
351
452
  if not search:
352
453
  return True
@@ -404,47 +505,37 @@ def _fuzzy_pick(
404
505
  complete_in_thread=True,
405
506
  complete_while_typing=True,
406
507
  )
407
- except (KeyboardInterrupt, EOFError):
508
+ except (KeyboardInterrupt, EOFError): # pragma: no cover - user cancelled input
408
509
  return None
409
510
 
410
511
  return _perform_fuzzy_search(answer, labels, by_label) if answer else None
411
512
 
412
513
 
413
- def _fuzzy_score(search: str, target: str) -> int:
414
- """
415
- Calculate fuzzy match score.
416
- Higher score = better match.
417
- Returns -1 if no match possible.
418
- """
514
+ def _is_fuzzy_match(search: str, target: str) -> bool:
515
+ """Check if search string is a fuzzy match for target."""
419
516
  if not search:
420
- return 0
517
+ return True
421
518
 
422
- # Check if it's a fuzzy match first
423
519
  search_idx = 0
424
520
  for char in target:
425
521
  if search_idx < len(search) and search[search_idx] == char:
426
522
  search_idx += 1
427
523
  if search_idx == len(search):
428
- break
524
+ return True
525
+ return False
429
526
 
430
- if search_idx < len(search):
431
- return -1 # Not a fuzzy match
432
527
 
433
- # Calculate score based on:
434
- # 1. Exact substring match gets bonus points
435
- # 2. Consecutive character matches get bonus points
436
- # 3. Shorter search terms get bonus points
528
+ def _calculate_exact_match_bonus(search: str, target: str) -> int:
529
+ """Calculate bonus for exact substring matches."""
530
+ return 100 if search.lower() in target.lower() else 0
437
531
 
438
- score = 0
439
532
 
440
- # Exact substring bonus
441
- if search.lower() in target.lower():
442
- score += 100
443
-
444
- # Consecutive character bonus
533
+ def _calculate_consecutive_bonus(search: str, target: str) -> int:
534
+ """Calculate bonus for consecutive character matches."""
445
535
  consecutive = 0
446
536
  max_consecutive = 0
447
537
  search_idx = 0
538
+
448
539
  for char in target:
449
540
  if search_idx < len(search) and search[search_idx] == char:
450
541
  consecutive += 1
@@ -453,10 +544,31 @@ def _fuzzy_score(search: str, target: str) -> int:
453
544
  else:
454
545
  consecutive = 0
455
546
 
456
- score += max_consecutive * 10
547
+ return max_consecutive * 10
548
+
457
549
 
458
- # Length bonus (shorter searches get higher scores)
459
- score += (len(target) - len(search)) * 2
550
+ def _calculate_length_bonus(search: str, target: str) -> int:
551
+ """Calculate bonus for shorter search terms."""
552
+ return (len(target) - len(search)) * 2
553
+
554
+
555
+ def _fuzzy_score(search: str, target: str) -> int:
556
+ """
557
+ Calculate fuzzy match score.
558
+ Higher score = better match.
559
+ Returns -1 if no match possible.
560
+ """
561
+ if not search:
562
+ return 0
563
+
564
+ if not _is_fuzzy_match(search, target):
565
+ return -1 # Not a fuzzy match
566
+
567
+ # Calculate score based on different factors
568
+ score = 0
569
+ score += _calculate_exact_match_bonus(search, target)
570
+ score += _calculate_consecutive_bonus(search, target)
571
+ score += _calculate_length_bonus(search, target)
460
572
 
461
573
  return score
462
574
 
@@ -511,12 +623,12 @@ def _render_markdown_output(data: Any) -> None:
511
623
 
512
624
 
513
625
  def output_result(
514
- ctx,
626
+ ctx: Any,
515
627
  result: Any,
516
628
  title: str = "Result",
517
629
  panel_title: str | None = None,
518
630
  success_message: str | None = None,
519
- ):
631
+ ) -> None:
520
632
  fmt = _get_view(ctx)
521
633
 
522
634
  data = _coerce_result_payload(result)
@@ -557,7 +669,9 @@ def output_result(
557
669
  # _PICK_THRESHOLD = int(os.getenv("AIP_PICK_THRESHOLD", "5") or "5")
558
670
 
559
671
 
560
- def _normalise_rows(items: list[Any], transform_func) -> list[dict[str, Any]]:
672
+ def _normalise_rows(
673
+ items: list[Any], transform_func: Callable[[Any], dict[str, Any]] | None
674
+ ) -> list[dict[str, Any]]:
561
675
  try:
562
676
  rows: list[dict[str, Any]] = []
563
677
  for item in items:
@@ -620,7 +734,7 @@ def _should_sort_rows(rows: list[dict[str, Any]]) -> bool:
620
734
  )
621
735
 
622
736
 
623
- def _create_table(columns: list[tuple], title: str):
737
+ def _create_table(columns: list[tuple[str, str, str, int | None]], title: str) -> Any:
624
738
  table = AIPTable(title=title, expand=True)
625
739
  for _key, header, style, width in columns:
626
740
  table.add_column(header, style=style, width=width)
@@ -651,37 +765,93 @@ def _should_page_output(row_count: int, is_tty: bool) -> bool:
651
765
  return is_tty
652
766
 
653
767
 
768
+ def _handle_json_output(items: list[Any], rows: list[dict[str, Any]]) -> None:
769
+ """Handle JSON output format."""
770
+ data = (
771
+ rows
772
+ if rows
773
+ else [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
774
+ )
775
+ click.echo(json.dumps(data, indent=2, default=str))
776
+
777
+
778
+ def _handle_plain_output(
779
+ rows: list[dict[str, Any]], title: str, columns: list[tuple]
780
+ ) -> None:
781
+ """Handle plain text output format."""
782
+ _render_plain_list(rows, title, columns)
783
+
784
+
785
+ def _handle_markdown_output(
786
+ rows: list[dict[str, Any]], title: str, columns: list[tuple]
787
+ ) -> None:
788
+ """Handle markdown output format."""
789
+ _render_markdown_list(rows, title, columns)
790
+
791
+
792
+ def _handle_empty_items(title: str) -> None:
793
+ """Handle case when no items are found."""
794
+ console.print(Text(f"[yellow]No {title.lower()} found.[/yellow]"))
795
+
796
+
797
+ def _handle_fuzzy_pick_selection(
798
+ rows: list[dict[str, Any]], columns: list[tuple], title: str
799
+ ) -> bool:
800
+ """Handle fuzzy picker selection, returns True if selection was made."""
801
+ picked = (
802
+ _fuzzy_pick(rows, columns, title)
803
+ if console.is_terminal and os.isatty(1)
804
+ else None
805
+ )
806
+ if picked:
807
+ table = _create_table(columns, title)
808
+ table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
809
+ console.print(table)
810
+ console.print(Text("\n[dim]Tip: use `aip agents get <ID>` for details[/dim]"))
811
+ return True
812
+ return False
813
+
814
+
815
+ def _handle_table_output(
816
+ rows: list[dict[str, Any]], columns: list[tuple], title: str
817
+ ) -> None:
818
+ """Handle table output with paging."""
819
+ content = _build_table_group(rows, columns, title)
820
+ if _should_page_output(len(rows), console.is_terminal and os.isatty(1)):
821
+ ansi = _render_ansi(content)
822
+ if not _page_with_system_pager(ansi):
823
+ with console.pager(styles=True):
824
+ console.print(content)
825
+ else:
826
+ console.print(content)
827
+
828
+
654
829
  def output_list(
655
- ctx,
830
+ ctx: Any,
656
831
  items: list[Any],
657
832
  title: str,
658
- columns: list[tuple],
659
- transform_func=None,
660
- ):
833
+ columns: list[tuple[str, str, str, int | None]],
834
+ transform_func: Callable | None = None,
835
+ ) -> None:
661
836
  """Display a list with fuzzy palette by default on TTY, Rich table as fallback."""
662
837
  fmt = _get_view(ctx)
663
838
  rows = _normalise_rows(items, transform_func)
664
839
  rows = _mask_rows_if_configured(rows)
665
840
 
666
841
  if fmt == "json":
667
- data = (
668
- rows
669
- if rows
670
- else [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
671
- )
672
- click.echo(json.dumps(data, indent=2, default=str))
842
+ _handle_json_output(items, rows)
673
843
  return
674
844
 
675
845
  if fmt == "plain":
676
- _render_plain_list(rows, title, columns)
846
+ _handle_plain_output(rows, title, columns)
677
847
  return
678
848
 
679
849
  if fmt == "md":
680
- _render_markdown_list(rows, title, columns)
850
+ _handle_markdown_output(rows, title, columns)
681
851
  return
682
852
 
683
853
  if not items:
684
- console.print(Text(f"[yellow]No {title.lower()} found.[/yellow]"))
854
+ _handle_empty_items(title)
685
855
  return
686
856
 
687
857
  if _should_sort_rows(rows):
@@ -690,50 +860,33 @@ def output_list(
690
860
  except Exception:
691
861
  pass
692
862
 
693
- picked = (
694
- _fuzzy_pick(rows, columns, title)
695
- if console.is_terminal and os.isatty(1)
696
- else None
697
- )
698
- if picked:
699
- table = _create_table(columns, title)
700
- table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
701
- console.print(table)
702
- console.print(Text("\n[dim]Tip: use `aip agents get <ID>` for details[/dim]"))
863
+ if _handle_fuzzy_pick_selection(rows, columns, title):
703
864
  return
704
865
 
705
- content = _build_table_group(rows, columns, title)
706
- if _should_page_output(len(rows), console.is_terminal and os.isatty(1)):
707
- ansi = _render_ansi(content)
708
- if not _page_with_system_pager(ansi):
709
- with console.pager(styles=True):
710
- console.print(content)
711
- return
712
-
713
- console.print(content)
866
+ _handle_table_output(rows, columns, title)
714
867
 
715
868
 
716
869
  # ------------------------- Output flags decorator ------------------------ #
717
870
 
718
871
 
719
- def _set_view(ctx, _param, value):
872
+ def _set_view(ctx: Any, _param: Any, value: str) -> None:
720
873
  if not value:
721
874
  return
722
875
  ctx.ensure_object(dict)
723
876
  ctx.obj["view"] = value
724
877
 
725
878
 
726
- def _set_json(ctx, _param, value):
879
+ def _set_json(ctx: Any, _param: Any, value: bool) -> None:
727
880
  if not value:
728
881
  return
729
882
  ctx.ensure_object(dict)
730
883
  ctx.obj["view"] = "json"
731
884
 
732
885
 
733
- def output_flags():
886
+ def output_flags() -> Callable[[Callable[..., Any]], Callable[..., Any]]:
734
887
  """Decorator to allow output format flags on any subcommand."""
735
888
 
736
- def decorator(f):
889
+ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
737
890
  f = click.option(
738
891
  "--json",
739
892
  "json_mode",
@@ -760,7 +913,7 @@ def output_flags():
760
913
  # ------------------------- Ambiguity handling --------------------------- #
761
914
 
762
915
 
763
- def coerce_to_row(item, keys: list[str]) -> dict[str, Any]:
916
+ def coerce_to_row(item: Any, keys: list[str]) -> dict[str, Any]:
764
917
  """Coerce an item (dict or object) to a row dict with specified keys.
765
918
 
766
919
  Args:
@@ -781,15 +934,15 @@ def coerce_to_row(item, keys: list[str]) -> dict[str, Any]:
781
934
 
782
935
 
783
936
  def build_renderer(
784
- _ctx,
937
+ _ctx: Any,
785
938
  *,
786
- save_path,
787
- theme="dark",
788
- verbose=False,
789
- _tty_enabled=True,
790
- live=None,
791
- snapshots=None,
792
- ):
939
+ save_path: str | os.PathLike[str] | None,
940
+ theme: str = "dark",
941
+ verbose: bool = False,
942
+ _tty_enabled: bool = True,
943
+ live: bool | None = None,
944
+ snapshots: bool | None = None,
945
+ ) -> tuple[RichStreamRenderer, Console | CapturingConsole]:
793
946
  """Build renderer and capturing console for CLI commands.
794
947
 
795
948
  Args:
@@ -901,16 +1054,49 @@ def _fuzzy_pick_for_resources(
901
1054
  return _perform_fuzzy_search(answer, labels, by_label) if answer else None
902
1055
 
903
1056
 
1057
+ def _resolve_by_id(ref: str, get_by_id: Callable) -> Any | None:
1058
+ """Resolve resource by UUID if ref is a valid UUID."""
1059
+ if is_uuid(ref):
1060
+ return get_by_id(ref)
1061
+ return None
1062
+
1063
+
1064
+ def _resolve_by_name_multiple_with_select(matches: list[Any], select: int) -> Any:
1065
+ """Resolve multiple matches using select parameter."""
1066
+ idx = int(select) - 1
1067
+ if not (0 <= idx < len(matches)):
1068
+ raise click.ClickException(f"--select must be 1..{len(matches)}")
1069
+ return matches[idx]
1070
+
1071
+
1072
+ def _resolve_by_name_multiple_fuzzy(
1073
+ ctx: Any, ref: str, matches: list[Any], label: str
1074
+ ) -> Any:
1075
+ """Resolve multiple matches using fuzzy picker interface."""
1076
+ picked = _fuzzy_pick_for_resources(matches, label.lower(), ref)
1077
+ if picked:
1078
+ return picked
1079
+ # Fallback to original ambiguity handler if fuzzy picker fails
1080
+ return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
1081
+
1082
+
1083
+ def _resolve_by_name_multiple_questionary(
1084
+ ctx: Any, ref: str, matches: list[Any], label: str
1085
+ ) -> Any:
1086
+ """Resolve multiple matches using questionary interface."""
1087
+ return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
1088
+
1089
+
904
1090
  def resolve_resource(
905
- ctx,
1091
+ ctx: Any,
906
1092
  ref: str,
907
1093
  *,
908
- get_by_id,
909
- find_by_name,
1094
+ get_by_id: Callable,
1095
+ find_by_name: Callable,
910
1096
  label: str,
911
1097
  select: int | None = None,
912
1098
  interface_preference: str = "fuzzy",
913
- ):
1099
+ ) -> Any | None:
914
1100
  """Resolve resource reference (ID or name) with ambiguity handling.
915
1101
 
916
1102
  Args:
@@ -925,8 +1111,14 @@ def resolve_resource(
925
1111
  Returns:
926
1112
  Resolved resource object
927
1113
  """
1114
+ # Try to resolve by ID first
1115
+ result = _resolve_by_id(ref, get_by_id)
1116
+ if result is not None:
1117
+ return result
1118
+
1119
+ # If get_by_id returned None, the resource doesn't exist
928
1120
  if is_uuid(ref):
929
- return get_by_id(ref)
1121
+ raise click.ClickException(f"{label} '{ref}' not found")
930
1122
 
931
1123
  # Find resources by name
932
1124
  matches = find_by_name(name=ref)
@@ -936,59 +1128,66 @@ def resolve_resource(
936
1128
  if len(matches) == 1:
937
1129
  return matches[0]
938
1130
 
939
- # Multiple matches - handle ambiguity
1131
+ # Multiple matches found, handle ambiguity
940
1132
  if select:
941
- idx = int(select) - 1
942
- if not (0 <= idx < len(matches)):
943
- raise click.ClickException(f"--select must be 1..{len(matches)}")
944
- return matches[idx]
1133
+ return _resolve_by_name_multiple_with_select(matches, select)
945
1134
 
946
1135
  # Choose interface based on preference
947
1136
  if interface_preference == "fuzzy":
948
- # Use fuzzy picker for modern UX
949
- picked = _fuzzy_pick_for_resources(matches, label.lower(), ref)
950
- if picked:
951
- return picked
952
- # Fallback to original ambiguity handler if fuzzy picker fails
953
- return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
1137
+ return _resolve_by_name_multiple_fuzzy(ctx, ref, matches, label)
954
1138
  else:
955
- # Use questionary interface for traditional up/down selection
956
- return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
1139
+ return _resolve_by_name_multiple_questionary(ctx, ref, matches, label)
957
1140
 
958
1141
 
959
- def handle_ambiguous_resource(
960
- ctx, resource_type: str, ref: str, matches: list[Any]
1142
+ def _handle_json_view_ambiguity(matches: list[Any]) -> Any:
1143
+ """Handle ambiguity in JSON view by returning first match."""
1144
+ return matches[0]
1145
+
1146
+
1147
+ def _handle_questionary_ambiguity(
1148
+ resource_type: str, ref: str, matches: list[Any]
961
1149
  ) -> Any:
962
- """Handle multiple resource matches gracefully."""
963
- if _get_view(ctx) == "json":
964
- return matches[0]
1150
+ """Handle ambiguity using questionary interactive interface."""
1151
+ if not (questionary and os.getenv("TERM") and os.isatty(0) and os.isatty(1)):
1152
+ raise click.ClickException("Interactive selection not available")
1153
+
1154
+ # Escape special characters for questionary
1155
+ safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
1156
+ safe_ref = ref.replace("{", "{{").replace("}", "}}")
1157
+
1158
+ picked_idx = questionary.select(
1159
+ f"Multiple {safe_resource_type}s match '{safe_ref}'. Pick one:",
1160
+ choices=[
1161
+ questionary.Choice(
1162
+ title=f"{getattr(m, 'name', '—').replace('{', '{{').replace('}', '}}')} — {getattr(m, 'id', '').replace('{', '{{').replace('}', '}}')}",
1163
+ value=i,
1164
+ )
1165
+ for i, m in enumerate(matches)
1166
+ ],
1167
+ use_indicator=True,
1168
+ qmark="🧭",
1169
+ instruction="↑/↓ to select • Enter to confirm",
1170
+ ).ask()
1171
+ if picked_idx is None:
1172
+ raise click.ClickException("Selection cancelled")
1173
+ return matches[picked_idx]
1174
+
1175
+
1176
+ def _handle_fallback_numeric_ambiguity(
1177
+ resource_type: str, ref: str, matches: list[Any]
1178
+ ) -> Any:
1179
+ """Handle ambiguity using numeric prompt fallback."""
1180
+ # Escape special characters for display
1181
+ safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
1182
+ safe_ref = ref.replace("{", "{{").replace("}", "}}")
965
1183
 
966
- if questionary and os.getenv("TERM") and os.isatty(0) and os.isatty(1):
967
- picked_idx = questionary.select(
968
- f"Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s match '{ref.replace('{', '{{').replace('}', '}}')}'. Pick one:",
969
- choices=[
970
- questionary.Choice(
971
- title=f"{getattr(m, 'name', '—').replace('{', '{{').replace('}', '}}')} — {getattr(m, 'id', '').replace('{', '{{').replace('}', '}}')}",
972
- value=i,
973
- )
974
- for i, m in enumerate(matches)
975
- ],
976
- use_indicator=True,
977
- qmark="🧭",
978
- instruction="↑/↓ to select • Enter to confirm",
979
- ).ask()
980
- if picked_idx is None:
981
- raise click.ClickException("Selection cancelled")
982
- return matches[picked_idx]
983
-
984
- # Fallback numeric prompt
985
1184
  console.print(
986
1185
  Text(
987
- f"[yellow]Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s found matching '{ref.replace('{', '{{').replace('}', '}}')}':[/yellow]"
1186
+ f"[yellow]Multiple {safe_resource_type}s found matching '{safe_ref}':[/yellow]"
988
1187
  )
989
1188
  )
990
1189
  table = AIPTable(
991
- title=f"Select {resource_type.replace('{', '{{').replace('}', '}}').title()}",
1190
+ title=f"Select {safe_resource_type.title()}",
992
1191
  )
993
1192
  table.add_column("#", style="dim", width=3)
994
1193
  table.add_column("ID", style="dim", width=36)
@@ -996,10 +1195,60 @@ def handle_ambiguous_resource(
996
1195
  for i, m in enumerate(matches, 1):
997
1196
  table.add_row(str(i), str(getattr(m, "id", "")), str(getattr(m, "name", "")))
998
1197
  console.print(table)
999
- choice = click.prompt(
1000
- f"Select {resource_type.replace('{', '{{').replace('}', '}}')} (1-{len(matches)})",
1001
- type=int,
1198
+ choice_str = click.prompt(
1199
+ f"Select {safe_resource_type} (1-{len(matches)})",
1002
1200
  )
1201
+ try:
1202
+ choice = int(choice_str)
1203
+ except ValueError:
1204
+ raise click.ClickException("Invalid selection")
1003
1205
  if 1 <= choice <= len(matches):
1004
1206
  return matches[choice - 1]
1005
1207
  raise click.ClickException("Invalid selection")
1208
+
1209
+
1210
+ def _should_fallback_to_numeric_prompt(exception: Exception) -> bool:
1211
+ """Determine if we should fallback to numeric prompt for this exception."""
1212
+ # Re-raise cancellation - user explicitly cancelled
1213
+ if "Selection cancelled" in str(exception):
1214
+ return False
1215
+
1216
+ # Fall back to numeric prompt for other exceptions
1217
+ return True
1218
+
1219
+
1220
+ def handle_ambiguous_resource(
1221
+ ctx: Any, resource_type: str, ref: str, matches: list[Any]
1222
+ ) -> Any:
1223
+ """Handle multiple resource matches gracefully."""
1224
+ if _get_view(ctx) == "json":
1225
+ return _handle_json_view_ambiguity(matches)
1226
+
1227
+ try:
1228
+ return _handle_questionary_ambiguity(resource_type, ref, matches)
1229
+ except Exception as e:
1230
+ if _should_fallback_to_numeric_prompt(e):
1231
+ try:
1232
+ return _handle_fallback_numeric_ambiguity(resource_type, ref, matches)
1233
+ except Exception:
1234
+ # If fallback also fails, re-raise the original exception
1235
+ raise e
1236
+ else:
1237
+ # Re-raise cancellation exceptions
1238
+ raise
1239
+
1240
+
1241
+ def detect_export_format(file_path: str | Path) -> str:
1242
+ """Detect export format from file extension.
1243
+
1244
+ Args:
1245
+ file_path: Path to the export file
1246
+
1247
+ Returns:
1248
+ "yaml" if file extension is .yaml or .yml, "json" otherwise
1249
+ """
1250
+ path = Path(file_path)
1251
+ if path.suffix.lower() in [".yaml", ".yml"]:
1252
+ return "yaml"
1253
+ else:
1254
+ return "json"