glaip-sdk 0.0.5b1__py3-none-any.whl → 0.0.6a0__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 (41) hide show
  1. glaip_sdk/__init__.py +1 -1
  2. glaip_sdk/branding.py +3 -2
  3. glaip_sdk/cli/commands/__init__.py +1 -1
  4. glaip_sdk/cli/commands/agents.py +444 -268
  5. glaip_sdk/cli/commands/configure.py +12 -11
  6. glaip_sdk/cli/commands/mcps.py +28 -16
  7. glaip_sdk/cli/commands/models.py +5 -3
  8. glaip_sdk/cli/commands/tools.py +109 -102
  9. glaip_sdk/cli/display.py +38 -16
  10. glaip_sdk/cli/io.py +1 -1
  11. glaip_sdk/cli/main.py +26 -5
  12. glaip_sdk/cli/resolution.py +5 -4
  13. glaip_sdk/cli/utils.py +376 -157
  14. glaip_sdk/cli/validators.py +7 -2
  15. glaip_sdk/client/agents.py +184 -89
  16. glaip_sdk/client/base.py +24 -13
  17. glaip_sdk/client/validators.py +154 -94
  18. glaip_sdk/config/constants.py +0 -2
  19. glaip_sdk/models.py +4 -4
  20. glaip_sdk/utils/__init__.py +7 -7
  21. glaip_sdk/utils/client_utils.py +144 -78
  22. glaip_sdk/utils/display.py +4 -2
  23. glaip_sdk/utils/general.py +8 -6
  24. glaip_sdk/utils/import_export.py +55 -24
  25. glaip_sdk/utils/rendering/formatting.py +12 -6
  26. glaip_sdk/utils/rendering/models.py +1 -1
  27. glaip_sdk/utils/rendering/renderer/base.py +412 -248
  28. glaip_sdk/utils/rendering/renderer/console.py +6 -5
  29. glaip_sdk/utils/rendering/renderer/debug.py +94 -52
  30. glaip_sdk/utils/rendering/renderer/stream.py +93 -48
  31. glaip_sdk/utils/rendering/steps.py +103 -39
  32. glaip_sdk/utils/rich_utils.py +1 -1
  33. glaip_sdk/utils/run_renderer.py +1 -1
  34. glaip_sdk/utils/serialization.py +3 -1
  35. glaip_sdk/utils/validation.py +2 -2
  36. glaip_sdk-0.0.6a0.dist-info/METADATA +183 -0
  37. glaip_sdk-0.0.6a0.dist-info/RECORD +55 -0
  38. glaip_sdk-0.0.5b1.dist-info/METADATA +0 -645
  39. glaip_sdk-0.0.5b1.dist-info/RECORD +0 -55
  40. {glaip_sdk-0.0.5b1.dist-info → glaip_sdk-0.0.6a0.dist-info}/WHEEL +0 -0
  41. {glaip_sdk-0.0.5b1.dist-info → glaip_sdk-0.0.6a0.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/utils.py CHANGED
@@ -14,6 +14,7 @@ import shlex
14
14
  import shutil
15
15
  import subprocess
16
16
  import tempfile
17
+ from collections.abc import Callable
17
18
  from typing import TYPE_CHECKING, Any
18
19
 
19
20
  import click
@@ -38,8 +39,6 @@ except Exception:
38
39
 
39
40
  if TYPE_CHECKING:
40
41
  from glaip_sdk import Client
41
-
42
- from glaip_sdk import Client
43
42
  from glaip_sdk.cli.commands.configure import load_config
44
43
  from glaip_sdk.rich_components import AIPPanel, AIPTable
45
44
  from glaip_sdk.utils import is_uuid
@@ -52,6 +51,31 @@ from glaip_sdk.utils.rendering.renderer import (
52
51
  console = Console()
53
52
 
54
53
 
54
+ # ----------------------------- Context helpers ---------------------------- #
55
+
56
+
57
+ def get_ctx_value(ctx: Any, key: str, default: Any = None) -> Any:
58
+ """Safely resolve a value from click's context object."""
59
+ if ctx is None:
60
+ return default
61
+
62
+ obj = getattr(ctx, "obj", None)
63
+ if obj is None:
64
+ return default
65
+
66
+ if isinstance(obj, dict):
67
+ return obj.get(key, default)
68
+
69
+ getter = getattr(obj, "get", None)
70
+ if callable(getter):
71
+ try:
72
+ return getter(key, default)
73
+ except TypeError:
74
+ return default
75
+
76
+ return getattr(obj, key, default) if hasattr(obj, key) else default
77
+
78
+
55
79
  # ----------------------------- Pager helpers ----------------------------- #
56
80
 
57
81
 
@@ -75,7 +99,7 @@ def _prepare_pager_env(
75
99
 
76
100
 
77
101
  def _render_ansi(
78
- renderable,
102
+ renderable: Any,
79
103
  ) -> str: # pragma: no cover - rendering requires real terminal
80
104
  """Render a Rich renderable to an ANSI string suitable for piping to 'less'."""
81
105
  buf = io.StringIO()
@@ -105,15 +129,17 @@ def _pager_header() -> str: # pragma: no cover - terminal UI helper
105
129
  )
106
130
 
107
131
 
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."""
132
+ def _should_use_pager() -> bool:
133
+ """Check if we should attempt to use a system pager."""
112
134
  if not (console.is_terminal and os.isatty(1)):
113
135
  return False
114
136
  if (os.getenv("TERM") or "").lower() == "dumb":
115
137
  return False
138
+ return True
139
+
116
140
 
141
+ def _resolve_pager_command() -> tuple[list[str] | None, str | None]:
142
+ """Resolve the pager command and path to use."""
117
143
  pager_cmd = None
118
144
  pager_env = os.getenv("PAGER")
119
145
  if pager_env:
@@ -122,60 +148,92 @@ def _page_with_system_pager(
122
148
  pager_cmd = parts
123
149
 
124
150
  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
151
+ return pager_cmd, less_path
152
+
153
+
154
+ def _run_less_pager(
155
+ pager_cmd: list[str] | None, less_path: str | None, tmp_path: str
156
+ ) -> None:
157
+ """Run less pager with appropriate command and flags."""
158
+ if pager_cmd:
159
+ subprocess.run([*pager_cmd, tmp_path], check=False)
160
+ else:
161
+ flags = os.getenv("LESS", "-RS").split()
162
+ subprocess.run([less_path, *flags, tmp_path], check=False)
163
+
164
+
165
+ def _run_more_pager(tmp_path: str) -> None:
166
+ """Run more pager as fallback."""
167
+ more_path = shutil.which("more")
168
+ if more_path:
169
+ subprocess.run([more_path, tmp_path], check=False)
170
+ else:
171
+ raise FileNotFoundError("more command not found")
172
+
173
+
174
+ def _run_pager_with_temp_file(
175
+ pager_runner: Callable[[str], None], ansi_text: str
176
+ ) -> bool:
177
+ """Run a pager using a temporary file containing the content."""
178
+ _prepare_pager_env(clear_on_exit=True)
179
+ with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
180
+ tmp.write(_pager_header())
181
+ tmp.write(ansi_text)
182
+ tmp_path = tmp.name
183
+ try:
184
+ pager_runner(tmp_path)
142
185
  return True
186
+ except Exception:
187
+ # If pager fails, return False to indicate paging was not successful
188
+ return False
189
+ finally:
190
+ try:
191
+ os.unlink(tmp_path)
192
+ except Exception:
193
+ pass
194
+
195
+
196
+ def _page_with_system_pager(
197
+ ansi_text: str,
198
+ ) -> bool: # pragma: no cover - spawns real pager
199
+ """Prefer 'less' with a temp file so stdin remains the TTY."""
200
+ if not _should_use_pager():
201
+ return False
202
+
203
+ pager_cmd, less_path = _resolve_pager_command()
204
+
205
+ if pager_cmd or less_path:
206
+ return _run_pager_with_temp_file(
207
+ lambda tmp_path: _run_less_pager(pager_cmd, less_path, tmp_path), ansi_text
208
+ )
143
209
 
144
210
  # Windows 'more' is poor with ANSI; let Rich fallback handle it
145
211
  if platform.system().lower().startswith("win"):
146
212
  return False
147
213
 
148
214
  # 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
215
+ return _run_pager_with_temp_file(_run_more_pager, ansi_text)
163
216
 
164
- return False
165
217
 
218
+ def _get_view(ctx: Any) -> str:
219
+ view = get_ctx_value(ctx, "view")
220
+ if view:
221
+ return view
166
222
 
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"
223
+ fallback = get_ctx_value(ctx, "format")
224
+ return fallback or "rich"
170
225
 
171
226
 
172
227
  # ----------------------------- Client config ----------------------------- #
173
228
 
174
229
 
175
- def get_client(ctx) -> Client:
230
+ def get_client(ctx: Any) -> Client:
176
231
  """Get configured client from context, env, and config file (ctx > env > file)."""
232
+ from glaip_sdk import Client
233
+
177
234
  file_config = load_config() or {}
178
- context_config = (ctx.obj or {}) if ctx else {}
235
+ context_config_obj = getattr(ctx, "obj", None)
236
+ context_config = context_config_obj or {}
179
237
 
180
238
  raw_timeout = os.getenv("AIP_TIMEOUT", "0") or "0"
181
239
  try:
@@ -269,17 +327,17 @@ def _resolve_mask_fields() -> set[str]:
269
327
  # ----------------------------- Fuzzy palette ----------------------------- #
270
328
 
271
329
 
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
- """
330
+ def _extract_display_fields(row: dict[str, Any]) -> tuple[str, str, str, str]:
331
+ """Extract display fields from row data."""
278
332
  name = str(row.get("name", "")).strip()
279
333
  _id = str(row.get("id", "")).strip()
280
334
  type_ = str(row.get("type", "")).strip()
281
335
  fw = str(row.get("framework", "")).strip()
336
+ return name, _id, type_, fw
337
+
282
338
 
339
+ def _build_primary_parts(name: str, type_: str, fw: str) -> list[str]:
340
+ """Build primary display parts from name, type, and framework."""
283
341
  parts = []
284
342
  if name:
285
343
  parts.append(name)
@@ -287,18 +345,58 @@ def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
287
345
  parts.append(type_)
288
346
  if fw:
289
347
  parts.append(fw)
348
+ return parts
349
+
350
+
351
+ def _get_fallback_columns(columns: list[tuple]) -> list[tuple]:
352
+ """Get first two visible columns for fallback display."""
353
+ return columns[:2]
354
+
355
+
356
+ def _is_standard_field(k: str) -> bool:
357
+ """Check if field is a standard field to skip."""
358
+ return k in ("id", "name", "type", "framework")
359
+
360
+
361
+ def _extract_fallback_values(row: dict[str, Any], columns: list[tuple]) -> list[str]:
362
+ """Extract fallback values from columns."""
363
+ fallback_parts = []
364
+ for k, _hdr, _style, _w in columns:
365
+ if _is_standard_field(k):
366
+ continue
367
+ val = str(row.get(k, "")).strip()
368
+ if val:
369
+ fallback_parts.append(val)
370
+ if len(fallback_parts) >= 2:
371
+ break
372
+ return fallback_parts
373
+
374
+
375
+ def _build_display_parts(
376
+ name: str, _id: str, type_: str, fw: str, row: dict[str, Any], columns: list[tuple]
377
+ ) -> list[str]:
378
+ """Build complete display parts list."""
379
+ parts = _build_primary_parts(name, type_, fw)
380
+
290
381
  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
382
+ # Use fallback columns
383
+ fallback_columns = _get_fallback_columns(columns)
384
+ parts.extend(_extract_fallback_values(row, fallback_columns))
385
+
300
386
  if _id:
301
387
  parts.append(f"[{_id}]")
388
+
389
+ return parts
390
+
391
+
392
+ def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
393
+ """
394
+ Build a compact text label for the palette.
395
+ Prefers: name • type • framework • [id] (when available)
396
+ Falls back to first 2 columns + [id].
397
+ """
398
+ name, _id, type_, fw = _extract_display_fields(row)
399
+ parts = _build_display_parts(name, _id, type_, fw, row, columns)
302
400
  return " • ".join(parts) if parts else (_id or "(row)")
303
401
 
304
402
 
@@ -332,10 +430,10 @@ def _build_unique_labels(
332
430
  class _FuzzyCompleter:
333
431
  """Fuzzy completer for prompt_toolkit."""
334
432
 
335
- def __init__(self, words: list[str]):
433
+ def __init__(self, words: list[str]) -> None:
336
434
  self.words = words
337
435
 
338
- def get_completions(self, document, _complete_event):
436
+ def get_completions(self, document: Any, _complete_event: Any) -> Any:
339
437
  word = document.get_word_before_cursor()
340
438
  if not word:
341
439
  return
@@ -511,12 +609,12 @@ def _render_markdown_output(data: Any) -> None:
511
609
 
512
610
 
513
611
  def output_result(
514
- ctx,
612
+ ctx: Any,
515
613
  result: Any,
516
614
  title: str = "Result",
517
615
  panel_title: str | None = None,
518
616
  success_message: str | None = None,
519
- ):
617
+ ) -> None:
520
618
  fmt = _get_view(ctx)
521
619
 
522
620
  data = _coerce_result_payload(result)
@@ -557,7 +655,9 @@ def output_result(
557
655
  # _PICK_THRESHOLD = int(os.getenv("AIP_PICK_THRESHOLD", "5") or "5")
558
656
 
559
657
 
560
- def _normalise_rows(items: list[Any], transform_func) -> list[dict[str, Any]]:
658
+ def _normalise_rows(
659
+ items: list[Any], transform_func: Callable[[Any], dict[str, Any]] | None
660
+ ) -> list[dict[str, Any]]:
561
661
  try:
562
662
  rows: list[dict[str, Any]] = []
563
663
  for item in items:
@@ -620,7 +720,7 @@ def _should_sort_rows(rows: list[dict[str, Any]]) -> bool:
620
720
  )
621
721
 
622
722
 
623
- def _create_table(columns: list[tuple], title: str):
723
+ def _create_table(columns: list[tuple[str, str, str, int | None]], title: str) -> Any:
624
724
  table = AIPTable(title=title, expand=True)
625
725
  for _key, header, style, width in columns:
626
726
  table.add_column(header, style=style, width=width)
@@ -651,37 +751,93 @@ def _should_page_output(row_count: int, is_tty: bool) -> bool:
651
751
  return is_tty
652
752
 
653
753
 
754
+ def _handle_json_output(items: list[Any], rows: list[dict[str, Any]]) -> None:
755
+ """Handle JSON output format."""
756
+ data = (
757
+ rows
758
+ if rows
759
+ else [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
760
+ )
761
+ click.echo(json.dumps(data, indent=2, default=str))
762
+
763
+
764
+ def _handle_plain_output(
765
+ rows: list[dict[str, Any]], title: str, columns: list[tuple]
766
+ ) -> None:
767
+ """Handle plain text output format."""
768
+ _render_plain_list(rows, title, columns)
769
+
770
+
771
+ def _handle_markdown_output(
772
+ rows: list[dict[str, Any]], title: str, columns: list[tuple]
773
+ ) -> None:
774
+ """Handle markdown output format."""
775
+ _render_markdown_list(rows, title, columns)
776
+
777
+
778
+ def _handle_empty_items(title: str) -> None:
779
+ """Handle case when no items are found."""
780
+ console.print(Text(f"[yellow]No {title.lower()} found.[/yellow]"))
781
+
782
+
783
+ def _handle_fuzzy_pick_selection(
784
+ rows: list[dict[str, Any]], columns: list[tuple], title: str
785
+ ) -> bool:
786
+ """Handle fuzzy picker selection, returns True if selection was made."""
787
+ picked = (
788
+ _fuzzy_pick(rows, columns, title)
789
+ if console.is_terminal and os.isatty(1)
790
+ else None
791
+ )
792
+ if picked:
793
+ table = _create_table(columns, title)
794
+ table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
795
+ console.print(table)
796
+ console.print(Text("\n[dim]Tip: use `aip agents get <ID>` for details[/dim]"))
797
+ return True
798
+ return False
799
+
800
+
801
+ def _handle_table_output(
802
+ rows: list[dict[str, Any]], columns: list[tuple], title: str
803
+ ) -> None:
804
+ """Handle table output with paging."""
805
+ content = _build_table_group(rows, columns, title)
806
+ if _should_page_output(len(rows), console.is_terminal and os.isatty(1)):
807
+ ansi = _render_ansi(content)
808
+ if not _page_with_system_pager(ansi):
809
+ with console.pager(styles=True):
810
+ console.print(content)
811
+ else:
812
+ console.print(content)
813
+
814
+
654
815
  def output_list(
655
- ctx,
816
+ ctx: Any,
656
817
  items: list[Any],
657
818
  title: str,
658
- columns: list[tuple],
659
- transform_func=None,
660
- ):
819
+ columns: list[tuple[str, str, str, int | None]],
820
+ transform_func: Callable | None = None,
821
+ ) -> None:
661
822
  """Display a list with fuzzy palette by default on TTY, Rich table as fallback."""
662
823
  fmt = _get_view(ctx)
663
824
  rows = _normalise_rows(items, transform_func)
664
825
  rows = _mask_rows_if_configured(rows)
665
826
 
666
827
  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))
828
+ _handle_json_output(items, rows)
673
829
  return
674
830
 
675
831
  if fmt == "plain":
676
- _render_plain_list(rows, title, columns)
832
+ _handle_plain_output(rows, title, columns)
677
833
  return
678
834
 
679
835
  if fmt == "md":
680
- _render_markdown_list(rows, title, columns)
836
+ _handle_markdown_output(rows, title, columns)
681
837
  return
682
838
 
683
839
  if not items:
684
- console.print(Text(f"[yellow]No {title.lower()} found.[/yellow]"))
840
+ _handle_empty_items(title)
685
841
  return
686
842
 
687
843
  if _should_sort_rows(rows):
@@ -690,50 +846,33 @@ def output_list(
690
846
  except Exception:
691
847
  pass
692
848
 
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]"))
849
+ if _handle_fuzzy_pick_selection(rows, columns, title):
703
850
  return
704
851
 
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)
852
+ _handle_table_output(rows, columns, title)
714
853
 
715
854
 
716
855
  # ------------------------- Output flags decorator ------------------------ #
717
856
 
718
857
 
719
- def _set_view(ctx, _param, value):
858
+ def _set_view(ctx: Any, _param: Any, value: str) -> None:
720
859
  if not value:
721
860
  return
722
861
  ctx.ensure_object(dict)
723
862
  ctx.obj["view"] = value
724
863
 
725
864
 
726
- def _set_json(ctx, _param, value):
865
+ def _set_json(ctx: Any, _param: Any, value: bool) -> None:
727
866
  if not value:
728
867
  return
729
868
  ctx.ensure_object(dict)
730
869
  ctx.obj["view"] = "json"
731
870
 
732
871
 
733
- def output_flags():
872
+ def output_flags() -> Callable[[Callable[..., Any]], Callable[..., Any]]:
734
873
  """Decorator to allow output format flags on any subcommand."""
735
874
 
736
- def decorator(f):
875
+ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
737
876
  f = click.option(
738
877
  "--json",
739
878
  "json_mode",
@@ -760,7 +899,7 @@ def output_flags():
760
899
  # ------------------------- Ambiguity handling --------------------------- #
761
900
 
762
901
 
763
- def coerce_to_row(item, keys: list[str]) -> dict[str, Any]:
902
+ def coerce_to_row(item: Any, keys: list[str]) -> dict[str, Any]:
764
903
  """Coerce an item (dict or object) to a row dict with specified keys.
765
904
 
766
905
  Args:
@@ -781,15 +920,15 @@ def coerce_to_row(item, keys: list[str]) -> dict[str, Any]:
781
920
 
782
921
 
783
922
  def build_renderer(
784
- _ctx,
923
+ _ctx: Any,
785
924
  *,
786
- save_path,
787
- theme="dark",
788
- verbose=False,
789
- _tty_enabled=True,
790
- live=None,
791
- snapshots=None,
792
- ):
925
+ save_path: str | os.PathLike[str] | None,
926
+ theme: str = "dark",
927
+ verbose: bool = False,
928
+ _tty_enabled: bool = True,
929
+ live: bool | None = None,
930
+ snapshots: bool | None = None,
931
+ ) -> tuple[RichStreamRenderer, Console | CapturingConsole]:
793
932
  """Build renderer and capturing console for CLI commands.
794
933
 
795
934
  Args:
@@ -901,16 +1040,49 @@ def _fuzzy_pick_for_resources(
901
1040
  return _perform_fuzzy_search(answer, labels, by_label) if answer else None
902
1041
 
903
1042
 
1043
+ def _resolve_by_id(ref: str, get_by_id: Callable) -> Any | None:
1044
+ """Resolve resource by UUID if ref is a valid UUID."""
1045
+ if is_uuid(ref):
1046
+ return get_by_id(ref)
1047
+ return None
1048
+
1049
+
1050
+ def _resolve_by_name_multiple_with_select(matches: list[Any], select: int) -> Any:
1051
+ """Resolve multiple matches using select parameter."""
1052
+ idx = int(select) - 1
1053
+ if not (0 <= idx < len(matches)):
1054
+ raise click.ClickException(f"--select must be 1..{len(matches)}")
1055
+ return matches[idx]
1056
+
1057
+
1058
+ def _resolve_by_name_multiple_fuzzy(
1059
+ ctx: Any, ref: str, matches: list[Any], label: str
1060
+ ) -> Any:
1061
+ """Resolve multiple matches using fuzzy picker interface."""
1062
+ picked = _fuzzy_pick_for_resources(matches, label.lower(), ref)
1063
+ if picked:
1064
+ return picked
1065
+ # Fallback to original ambiguity handler if fuzzy picker fails
1066
+ return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
1067
+
1068
+
1069
+ def _resolve_by_name_multiple_questionary(
1070
+ ctx: Any, ref: str, matches: list[Any], label: str
1071
+ ) -> Any:
1072
+ """Resolve multiple matches using questionary interface."""
1073
+ return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
1074
+
1075
+
904
1076
  def resolve_resource(
905
- ctx,
1077
+ ctx: Any,
906
1078
  ref: str,
907
1079
  *,
908
- get_by_id,
909
- find_by_name,
1080
+ get_by_id: Callable,
1081
+ find_by_name: Callable,
910
1082
  label: str,
911
1083
  select: int | None = None,
912
1084
  interface_preference: str = "fuzzy",
913
- ):
1085
+ ) -> Any | None:
914
1086
  """Resolve resource reference (ID or name) with ambiguity handling.
915
1087
 
916
1088
  Args:
@@ -925,8 +1097,14 @@ def resolve_resource(
925
1097
  Returns:
926
1098
  Resolved resource object
927
1099
  """
1100
+ # Try to resolve by ID first
1101
+ result = _resolve_by_id(ref, get_by_id)
1102
+ if result is not None:
1103
+ return result
1104
+
1105
+ # If get_by_id returned None, the resource doesn't exist
928
1106
  if is_uuid(ref):
929
- return get_by_id(ref)
1107
+ raise click.ClickException(f"{label} '{ref}' not found")
930
1108
 
931
1109
  # Find resources by name
932
1110
  matches = find_by_name(name=ref)
@@ -936,59 +1114,66 @@ def resolve_resource(
936
1114
  if len(matches) == 1:
937
1115
  return matches[0]
938
1116
 
939
- # Multiple matches - handle ambiguity
1117
+ # Multiple matches found, handle ambiguity
940
1118
  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]
1119
+ return _resolve_by_name_multiple_with_select(matches, select)
945
1120
 
946
1121
  # Choose interface based on preference
947
1122
  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)
1123
+ return _resolve_by_name_multiple_fuzzy(ctx, ref, matches, label)
954
1124
  else:
955
- # Use questionary interface for traditional up/down selection
956
- return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
1125
+ return _resolve_by_name_multiple_questionary(ctx, ref, matches, label)
957
1126
 
958
1127
 
959
- def handle_ambiguous_resource(
960
- ctx, resource_type: str, ref: str, matches: list[Any]
1128
+ def _handle_json_view_ambiguity(matches: list[Any]) -> Any:
1129
+ """Handle ambiguity in JSON view by returning first match."""
1130
+ return matches[0]
1131
+
1132
+
1133
+ def _handle_questionary_ambiguity(
1134
+ resource_type: str, ref: str, matches: list[Any]
961
1135
  ) -> Any:
962
- """Handle multiple resource matches gracefully."""
963
- if _get_view(ctx) == "json":
964
- return matches[0]
1136
+ """Handle ambiguity using questionary interactive interface."""
1137
+ if not (questionary and os.getenv("TERM") and os.isatty(0) and os.isatty(1)):
1138
+ raise click.ClickException("Interactive selection not available")
1139
+
1140
+ # Escape special characters for questionary
1141
+ safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
1142
+ safe_ref = ref.replace("{", "{{").replace("}", "}}")
1143
+
1144
+ picked_idx = questionary.select(
1145
+ f"Multiple {safe_resource_type}s match '{safe_ref}'. Pick one:",
1146
+ choices=[
1147
+ questionary.Choice(
1148
+ title=f"{getattr(m, 'name', '—').replace('{', '{{').replace('}', '}}')} — {getattr(m, 'id', '').replace('{', '{{').replace('}', '}}')}",
1149
+ value=i,
1150
+ )
1151
+ for i, m in enumerate(matches)
1152
+ ],
1153
+ use_indicator=True,
1154
+ qmark="🧭",
1155
+ instruction="↑/↓ to select • Enter to confirm",
1156
+ ).ask()
1157
+ if picked_idx is None:
1158
+ raise click.ClickException("Selection cancelled")
1159
+ return matches[picked_idx]
1160
+
1161
+
1162
+ def _handle_fallback_numeric_ambiguity(
1163
+ resource_type: str, ref: str, matches: list[Any]
1164
+ ) -> Any:
1165
+ """Handle ambiguity using numeric prompt fallback."""
1166
+ # Escape special characters for display
1167
+ safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
1168
+ safe_ref = ref.replace("{", "{{").replace("}", "}}")
965
1169
 
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
1170
  console.print(
986
1171
  Text(
987
- f"[yellow]Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s found matching '{ref.replace('{', '{{').replace('}', '}}')}':[/yellow]"
1172
+ f"[yellow]Multiple {safe_resource_type}s found matching '{safe_ref}':[/yellow]"
988
1173
  )
989
1174
  )
990
1175
  table = AIPTable(
991
- title=f"Select {resource_type.replace('{', '{{').replace('}', '}}').title()}",
1176
+ title=f"Select {safe_resource_type.title()}",
992
1177
  )
993
1178
  table.add_column("#", style="dim", width=3)
994
1179
  table.add_column("ID", style="dim", width=36)
@@ -996,10 +1181,44 @@ def handle_ambiguous_resource(
996
1181
  for i, m in enumerate(matches, 1):
997
1182
  table.add_row(str(i), str(getattr(m, "id", "")), str(getattr(m, "name", "")))
998
1183
  console.print(table)
999
- choice = click.prompt(
1000
- f"Select {resource_type.replace('{', '{{').replace('}', '}}')} (1-{len(matches)})",
1001
- type=int,
1184
+ choice_str = click.prompt(
1185
+ f"Select {safe_resource_type} (1-{len(matches)})",
1002
1186
  )
1187
+ try:
1188
+ choice = int(choice_str)
1189
+ except ValueError:
1190
+ raise click.ClickException("Invalid selection")
1003
1191
  if 1 <= choice <= len(matches):
1004
1192
  return matches[choice - 1]
1005
1193
  raise click.ClickException("Invalid selection")
1194
+
1195
+
1196
+ def _should_fallback_to_numeric_prompt(exception: Exception) -> bool:
1197
+ """Determine if we should fallback to numeric prompt for this exception."""
1198
+ # Re-raise cancellation - user explicitly cancelled
1199
+ if "Selection cancelled" in str(exception):
1200
+ return False
1201
+
1202
+ # Fall back to numeric prompt for other exceptions
1203
+ return True
1204
+
1205
+
1206
+ def handle_ambiguous_resource(
1207
+ ctx: Any, resource_type: str, ref: str, matches: list[Any]
1208
+ ) -> Any:
1209
+ """Handle multiple resource matches gracefully."""
1210
+ if _get_view(ctx) == "json":
1211
+ return _handle_json_view_ambiguity(matches)
1212
+
1213
+ try:
1214
+ return _handle_questionary_ambiguity(resource_type, ref, matches)
1215
+ except Exception as e:
1216
+ if _should_fallback_to_numeric_prompt(e):
1217
+ try:
1218
+ return _handle_fallback_numeric_ambiguity(resource_type, ref, matches)
1219
+ except Exception:
1220
+ # If fallback also fails, re-raise the original exception
1221
+ raise e
1222
+ else:
1223
+ # Re-raise cancellation exceptions
1224
+ raise