glaip-sdk 0.0.1b5__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.
glaip_sdk/cli/utils.py ADDED
@@ -0,0 +1,733 @@
1
+ """CLI utilities for glaip-sdk.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ import os
12
+ import platform
13
+ import shlex
14
+ import shutil
15
+ import subprocess
16
+ import tempfile
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ import click
20
+ from rich import box
21
+ from rich.console import Console, Group
22
+ from rich.panel import Panel
23
+ from rich.pretty import Pretty
24
+ from rich.table import Table
25
+ from rich.text import Text
26
+
27
+ # Optional interactive deps (fuzzy palette)
28
+ try:
29
+ from prompt_toolkit.completion import Completion
30
+ from prompt_toolkit.shortcuts import prompt
31
+
32
+ _HAS_PTK = True
33
+ except Exception:
34
+ _HAS_PTK = False
35
+
36
+ try:
37
+ import questionary
38
+ except Exception:
39
+ questionary = None
40
+
41
+ if TYPE_CHECKING:
42
+ from glaip_sdk import Client
43
+
44
+ console = Console()
45
+
46
+
47
+ # ----------------------------- Pager helpers ----------------------------- #
48
+
49
+
50
+ def _prepare_pager_env(clear_on_exit: bool = True) -> None:
51
+ """
52
+ Configure LESS flags for a predictable, high-quality UX:
53
+ -R : pass ANSI color escapes
54
+ -S : chop long lines (horizontal scroll with ←/→)
55
+ (No -F, no -X) so we open a full-screen pager and clear on exit.
56
+ Toggle wrapping with AIP_PAGER_WRAP=1 to drop -S.
57
+ Power users can override via AIP_LESS_FLAGS.
58
+ """
59
+ os.environ.pop("LESSSECURE", None)
60
+ if os.getenv("LESS") is None:
61
+ want_wrap = os.getenv("AIP_PAGER_WRAP", "0") == "1"
62
+ base = "-R" if want_wrap else "-RS"
63
+ default_flags = base if clear_on_exit else (base + "FX")
64
+ os.environ["LESS"] = os.getenv("AIP_LESS_FLAGS", default_flags)
65
+
66
+
67
+ def _render_ansi(renderable) -> str:
68
+ """Render a Rich renderable to an ANSI string suitable for piping to 'less'."""
69
+ buf = io.StringIO()
70
+ tmp_console = Console(
71
+ file=buf,
72
+ force_terminal=True,
73
+ color_system=console.color_system or "auto",
74
+ width=console.size.width or 100,
75
+ legacy_windows=False,
76
+ soft_wrap=False,
77
+ record=False,
78
+ )
79
+ tmp_console.print(renderable)
80
+ return buf.getvalue()
81
+
82
+
83
+ def _pager_header() -> str:
84
+ v = (os.getenv("AIP_PAGER_HEADER", "1") or "1").strip().lower()
85
+ if v in {"0", "false", "off"}:
86
+ return ""
87
+ return "\n".join(
88
+ [
89
+ "TABLE VIEW — ↑/↓ PgUp/PgDn, ←/→ horiz scroll (with -S), /search, n/N next/prev, h help, q quit",
90
+ "───────────────────────────────────────────────────────────────────────────────────────────────",
91
+ "",
92
+ ]
93
+ )
94
+
95
+
96
+ def _page_with_system_pager(ansi_text: str) -> bool:
97
+ """Prefer 'less' with a temp file so stdin remains the TTY."""
98
+ if not (console.is_terminal and os.isatty(1)):
99
+ return False
100
+ if (os.getenv("TERM") or "").lower() == "dumb":
101
+ return False
102
+
103
+ pager_cmd = None
104
+ pager_env = os.getenv("PAGER")
105
+ if pager_env:
106
+ parts = shlex.split(pager_env)
107
+ if parts and os.path.basename(parts[0]).lower() == "less":
108
+ pager_cmd = parts
109
+
110
+ less_path = shutil.which("less")
111
+ if pager_cmd or less_path:
112
+ _prepare_pager_env(clear_on_exit=True)
113
+ with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
114
+ tmp.write(_pager_header())
115
+ tmp.write(ansi_text)
116
+ tmp_path = tmp.name
117
+ try:
118
+ if pager_cmd:
119
+ subprocess.run([*pager_cmd, tmp_path], check=False)
120
+ else:
121
+ flags = os.getenv("LESS", "-RS").split()
122
+ subprocess.run([less_path, *flags, tmp_path], check=False)
123
+ finally:
124
+ try:
125
+ os.unlink(tmp_path)
126
+ except Exception:
127
+ pass
128
+ return True
129
+
130
+ # Windows 'more' is poor with ANSI; let Rich fallback handle it
131
+ if platform.system().lower().startswith("win"):
132
+ return False
133
+
134
+ # POSIX 'more' fallback (may or may not honor ANSI)
135
+ more_path = shutil.which("more")
136
+ if more_path:
137
+ with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
138
+ tmp.write(_pager_header())
139
+ tmp.write(ansi_text)
140
+ tmp_path = tmp.name
141
+ try:
142
+ subprocess.run([more_path, tmp_path], check=False)
143
+ finally:
144
+ try:
145
+ os.unlink(tmp_path)
146
+ except Exception:
147
+ pass
148
+ return True
149
+
150
+ return False
151
+
152
+
153
+ def _get_view(ctx) -> str:
154
+ obj = ctx.obj or {}
155
+ return obj.get("view") or obj.get("format") or "rich"
156
+
157
+
158
+ # ----------------------------- Client config ----------------------------- #
159
+
160
+
161
+ def get_client(ctx) -> Client:
162
+ """Get configured client from context, env, and config file (ctx > env > file)."""
163
+ from glaip_sdk import Client
164
+ from glaip_sdk.cli.commands.configure import load_config
165
+
166
+ file_config = load_config() or {}
167
+ context_config = (ctx.obj or {}) if ctx else {}
168
+
169
+ env_config = {
170
+ "api_url": os.getenv("AIP_API_URL"),
171
+ "api_key": os.getenv("AIP_API_KEY"),
172
+ "timeout": float(os.getenv("AIP_TIMEOUT", "0") or 0) or None,
173
+ }
174
+ env_config = {k: v for k, v in env_config.items() if v not in (None, "", 0)}
175
+
176
+ config = {
177
+ **file_config,
178
+ **env_config,
179
+ **{k: v for k, v in context_config.items() if v is not None},
180
+ }
181
+
182
+ if not config.get("api_url") or not config.get("api_key"):
183
+ raise click.ClickException(
184
+ "Missing api_url/api_key. Run `aip configure` or set AIP_* env vars."
185
+ )
186
+
187
+ return Client(
188
+ api_url=config.get("api_url"),
189
+ api_key=config.get("api_key"),
190
+ timeout=float(config.get("timeout") or 30.0),
191
+ )
192
+
193
+
194
+ # ----------------------------- Small helpers ----------------------------- #
195
+
196
+
197
+ def safe_getattr(obj: Any, attr: str, default: Any = None) -> Any:
198
+ try:
199
+ return getattr(obj, attr)
200
+ except Exception:
201
+ return default
202
+
203
+
204
+ # ----------------------------- Secret masking ---------------------------- #
205
+
206
+ _DEFAULT_MASK_FIELDS = {
207
+ "api_key",
208
+ "apikey",
209
+ "token",
210
+ "access_token",
211
+ "secret",
212
+ "client_secret",
213
+ "password",
214
+ "private_key",
215
+ "bearer",
216
+ }
217
+
218
+
219
+ def _mask_value(v: Any) -> str:
220
+ s = str(v)
221
+ if len(s) <= 8:
222
+ return "••••"
223
+ return f"{s[:4]}••••••••{s[-4:]}"
224
+
225
+
226
+ def _mask_any(x: Any, mask_fields: set[str]) -> Any:
227
+ """Recursively mask sensitive fields in any data structure.
228
+
229
+ Args:
230
+ x: The data to mask (dict, list, or primitive)
231
+ mask_fields: Set of field names to mask (case-insensitive)
232
+
233
+ Returns:
234
+ Masked copy of the data with sensitive values replaced
235
+ """
236
+ if isinstance(x, dict):
237
+ out = {}
238
+ for k, v in x.items():
239
+ if k.lower() in mask_fields and v is not None:
240
+ out[k] = _mask_value(v)
241
+ else:
242
+ out[k] = _mask_any(v, mask_fields)
243
+ return out
244
+ elif isinstance(x, list):
245
+ return [_mask_any(v, mask_fields) for v in x]
246
+ else:
247
+ return x
248
+
249
+
250
+ def _maybe_mask_row(row: dict[str, Any], mask_fields: set[str]) -> dict[str, Any]:
251
+ """Mask a single row (legacy function, now uses _mask_any)."""
252
+ if not mask_fields:
253
+ return row
254
+ return _mask_any(row, mask_fields)
255
+
256
+
257
+ def _resolve_mask_fields() -> set[str]:
258
+ if os.getenv("AIP_MASK_OFF", "0") in ("1", "true", "on", "yes"):
259
+ return set()
260
+ env_fields = (os.getenv("AIP_MASK_FIELDS") or "").strip()
261
+ if env_fields:
262
+ parts = [p.strip().lower() for p in env_fields.split(",") if p.strip()]
263
+ return set(parts)
264
+ return set(_DEFAULT_MASK_FIELDS)
265
+
266
+
267
+ # ----------------------------- Fuzzy palette ----------------------------- #
268
+
269
+
270
+ def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
271
+ """
272
+ Build a compact text label for the palette.
273
+ Prefers: name • type • framework • [id] (when available)
274
+ Falls back to first 2 columns + [id].
275
+ """
276
+ name = str(row.get("name", "")).strip()
277
+ _id = str(row.get("id", "")).strip()
278
+ type_ = str(row.get("type", "")).strip()
279
+ fw = str(row.get("framework", "")).strip()
280
+
281
+ parts = []
282
+ if name:
283
+ parts.append(name)
284
+ if type_:
285
+ parts.append(type_)
286
+ if fw:
287
+ parts.append(fw)
288
+ if not parts:
289
+ # use first two visible columns
290
+ for k, _hdr, _style, _w in columns[:2]:
291
+ if k in ("id", "name", "type", "framework"):
292
+ continue
293
+ val = str(row.get(k, "")).strip()
294
+ if val:
295
+ parts.append(val)
296
+ if len(parts) >= 2:
297
+ break
298
+ if _id:
299
+ parts.append(f"[{_id}]")
300
+ return " • ".join(parts) if parts else (_id or "(row)")
301
+
302
+
303
+ def _fuzzy_pick(
304
+ rows: list[dict[str, Any]], columns: list[tuple], title: str
305
+ ) -> dict[str, Any] | None:
306
+ """
307
+ Open a minimal fuzzy palette using prompt_toolkit.
308
+ Returns the selected row (dict) or None if cancelled/missing deps.
309
+ """
310
+ if not (_HAS_PTK and console.is_terminal and os.isatty(1)):
311
+ return None
312
+
313
+ # Build display corpus and a reverse map
314
+ labels = []
315
+ by_label: dict[str, dict[str, Any]] = {}
316
+ for r in rows:
317
+ label = _row_display(r, columns)
318
+ # Ensure uniqueness: if duplicate, suffix with …#n
319
+ if label in by_label:
320
+ i = 2
321
+ base = label
322
+ while f"{base} #{i}" in by_label:
323
+ i += 1
324
+ label = f"{base} #{i}"
325
+ labels.append(label)
326
+ by_label[label] = r
327
+
328
+ # Create a fuzzy completer that searches anywhere in the string
329
+ class FuzzyCompleter:
330
+ def __init__(self, words: list[str]):
331
+ self.words = words
332
+
333
+ def get_completions(self, document, complete_event):
334
+ word = document.get_word_before_cursor()
335
+ if not word:
336
+ return
337
+
338
+ word_lower = word.lower()
339
+ for label in self.words:
340
+ label_lower = label.lower()
341
+ # Check if all characters in the search word appear in order in the label
342
+ if self._fuzzy_match(word_lower, label_lower):
343
+ yield Completion(label, start_position=-len(word))
344
+
345
+ def _fuzzy_match(self, search: str, target: str) -> bool:
346
+ """
347
+ True fuzzy matching: checks if all characters in search appear in order in target.
348
+ Examples:
349
+ - "aws" matches "aws_calculator_agent" ✓
350
+ - "calc" matches "aws_calculator_agent" ✓
351
+ - "gent" matches "aws_calculator_agent" ✓
352
+ - "agent" matches "aws_calculator_agent" ✓
353
+ - "aws_calc" matches "aws_calculator_agent" ✓
354
+ """
355
+ if not search:
356
+ return True
357
+
358
+ search_idx = 0
359
+ for char in target:
360
+ if search_idx < len(search) and search[search_idx] == char:
361
+ search_idx += 1
362
+ if search_idx == len(search):
363
+ return True
364
+ return False
365
+
366
+ completer = FuzzyCompleter(labels)
367
+
368
+ try:
369
+ answer = prompt(
370
+ message=f"Find {title.rstrip('s')}: ",
371
+ completer=completer,
372
+ complete_in_thread=True,
373
+ complete_while_typing=True,
374
+ )
375
+ except (KeyboardInterrupt, EOFError):
376
+ return None
377
+
378
+ if not answer:
379
+ return None
380
+
381
+ # Exact label chosen from menu → direct hit
382
+ if answer in by_label:
383
+ return by_label[answer]
384
+
385
+ # Fuzzy search fallback: find best fuzzy match
386
+ best_match = None
387
+ best_score = -1
388
+
389
+ for label in labels:
390
+ score = _fuzzy_score(answer.lower(), label.lower())
391
+ if score > best_score:
392
+ best_score = score
393
+ best_match = label
394
+
395
+ if best_match and best_score > 0:
396
+ return by_label[best_match]
397
+
398
+ # No match
399
+ return None
400
+
401
+
402
+ def _fuzzy_score(search: str, target: str) -> int:
403
+ """
404
+ Calculate fuzzy match score.
405
+ Higher score = better match.
406
+ Returns -1 if no match possible.
407
+ """
408
+ if not search:
409
+ return 0
410
+
411
+ # Check if it's a fuzzy match first
412
+ search_idx = 0
413
+ for char in target:
414
+ if search_idx < len(search) and search[search_idx] == char:
415
+ search_idx += 1
416
+ if search_idx == len(search):
417
+ break
418
+
419
+ if search_idx < len(search):
420
+ return -1 # Not a fuzzy match
421
+
422
+ # Calculate score based on:
423
+ # 1. Exact substring match gets bonus points
424
+ # 2. Consecutive character matches get bonus points
425
+ # 3. Shorter search terms get bonus points
426
+
427
+ score = 0
428
+
429
+ # Exact substring bonus
430
+ if search.lower() in target.lower():
431
+ score += 100
432
+
433
+ # Consecutive character bonus
434
+ consecutive = 0
435
+ max_consecutive = 0
436
+ search_idx = 0
437
+ for char in target:
438
+ if search_idx < len(search) and search[search_idx] == char:
439
+ consecutive += 1
440
+ max_consecutive = max(max_consecutive, consecutive)
441
+ search_idx += 1
442
+ else:
443
+ consecutive = 0
444
+
445
+ score += max_consecutive * 10
446
+
447
+ # Length bonus (shorter searches get higher scores)
448
+ score += (len(target) - len(search)) * 2
449
+
450
+ return score
451
+
452
+
453
+ # ----------------------------- Pretty outputs ---------------------------- #
454
+
455
+
456
+ def output_result(
457
+ ctx,
458
+ result: Any,
459
+ title: str = "Result",
460
+ panel_title: str | None = None,
461
+ success_message: str | None = None,
462
+ ):
463
+ fmt = _get_view(ctx)
464
+ data = result.to_dict() if hasattr(result, "to_dict") else result
465
+
466
+ # Apply recursive secret masking before any rendering
467
+ mask_fields = _resolve_mask_fields()
468
+ if mask_fields:
469
+ try:
470
+ data = _mask_any(data, mask_fields)
471
+ except Exception:
472
+ pass # Continue with unmasked data if masking fails
473
+
474
+ if fmt == "json":
475
+ click.echo(json.dumps(data, indent=2, default=str))
476
+ return
477
+
478
+ if fmt == "plain":
479
+ click.echo(str(data))
480
+ return
481
+
482
+ if fmt == "md":
483
+ try:
484
+ from rich.markdown import Markdown
485
+
486
+ console.print(Markdown(str(data)))
487
+ except ImportError:
488
+ # Fallback to plain if markdown not available
489
+ click.echo(str(data))
490
+ return
491
+
492
+ if success_message:
493
+ console.print(f"[green]✅ {success_message}[/green]")
494
+
495
+ if panel_title:
496
+ console.print(Panel(Pretty(data), title=panel_title, border_style="blue"))
497
+ else:
498
+ console.print(f"[cyan]{title}:[/cyan]")
499
+ console.print(Pretty(data))
500
+
501
+
502
+ # ----------------------------- List rendering ---------------------------- #
503
+
504
+ # Threshold no longer used - fuzzy palette is always default for TTY
505
+ # _PICK_THRESHOLD = int(os.getenv("AIP_PICK_THRESHOLD", "5") or "5")
506
+
507
+
508
+ def output_list(
509
+ ctx,
510
+ items: list[Any],
511
+ title: str,
512
+ columns: list[tuple],
513
+ transform_func=None,
514
+ ):
515
+ """Display a list with fuzzy palette by default on TTY, Rich table as fallback."""
516
+ fmt = _get_view(ctx)
517
+
518
+ # Normalize rows
519
+ try:
520
+ rows: list[dict[str, Any]] = []
521
+ for item in items:
522
+ if transform_func:
523
+ rows.append(transform_func(item))
524
+ elif hasattr(item, "to_dict"):
525
+ rows.append(item.to_dict())
526
+ elif hasattr(item, "__dict__"):
527
+ rows.append(vars(item))
528
+ elif isinstance(item, dict):
529
+ rows.append(item)
530
+ else:
531
+ rows.append({"value": item})
532
+ except Exception:
533
+ rows = []
534
+
535
+ # JSON view bypasses any UI
536
+ if fmt == "json":
537
+ data = rows or [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
538
+ click.echo(json.dumps(data, indent=2, default=str))
539
+ return
540
+
541
+ # Plain view - simple text output
542
+ if fmt == "plain":
543
+ if not rows:
544
+ click.echo(f"No {title.lower()} found.")
545
+ return
546
+ for row in rows:
547
+ row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
548
+ click.echo(row_str)
549
+ return
550
+
551
+ # Markdown view - table format
552
+ if fmt == "md":
553
+ if not rows:
554
+ click.echo(f"No {title.lower()} found.")
555
+ return
556
+ # Create markdown table
557
+ headers = [header for _, header, _, _ in columns]
558
+ click.echo(f"| {' | '.join(headers)} |")
559
+ click.echo(f"| {' | '.join('---' for _ in headers)} |")
560
+ for row in rows:
561
+ row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
562
+ click.echo(f"| {row_str} |")
563
+ return
564
+
565
+ if not items:
566
+ console.print(f"[yellow]No {title.lower()} found.[/yellow]")
567
+ return
568
+
569
+ # Sort by name by default (unless disabled)
570
+ if (
571
+ os.getenv("AIP_TABLE_NO_SORT", "0") not in ("1", "true", "on")
572
+ and rows
573
+ and isinstance(rows[0], dict)
574
+ and "name" in rows[0]
575
+ ):
576
+ try:
577
+ rows = sorted(rows, key=lambda r: str(r.get("name", "")).lower())
578
+ except Exception:
579
+ pass
580
+
581
+ # Mask secrets
582
+ mask_fields = _resolve_mask_fields()
583
+ if mask_fields:
584
+ try:
585
+ rows = [_maybe_mask_row(r, mask_fields) for r in rows]
586
+ except Exception:
587
+ pass
588
+
589
+ # === Fuzzy palette is the default for TTY lists ===
590
+ picked: dict[str, Any] | None = None
591
+ if console.is_terminal and os.isatty(1):
592
+ picked = _fuzzy_pick(rows, columns, title)
593
+
594
+ if picked:
595
+ # Show a focused, single-row table (easy to copy ID/name)
596
+ table = Table(title=title, box=box.ROUNDED, expand=True)
597
+ for _key, header, style, width in columns:
598
+ table.add_column(header, style=style, width=width)
599
+ table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
600
+
601
+ console.print(table)
602
+ console.print(Text("\n[dim]Tip: use `aip agents get <ID>` for details[/dim]"))
603
+ return
604
+
605
+ # Build full table
606
+ table = Table(title=title, box=box.ROUNDED, expand=True)
607
+ for _key, header, style, width in columns:
608
+ table.add_column(header, style=style, width=width)
609
+ for row in rows:
610
+ table.add_row(*[str(row.get(key, "N/A")) for key, _, _, _ in columns])
611
+
612
+ footer = Text(f"\n[dim]Total {len(rows)} items[/dim]")
613
+ content = Group(table, footer)
614
+
615
+ # Auto paging when long
616
+ is_tty = console.is_terminal and os.isatty(1)
617
+ pager_env = (os.getenv("AIP_PAGER", "auto") or "auto").lower()
618
+
619
+ if pager_env in ("0", "off", "false"):
620
+ should_page = False
621
+ elif pager_env in ("1", "on", "true"):
622
+ should_page = is_tty
623
+ else:
624
+ try:
625
+ term_h = console.size.height or 24
626
+ approx_lines = 5 + len(rows)
627
+ should_page = is_tty and (approx_lines >= term_h * 0.5)
628
+ except Exception:
629
+ should_page = is_tty
630
+
631
+ if should_page:
632
+ ansi = _render_ansi(content)
633
+ if not _page_with_system_pager(ansi):
634
+ with console.pager(styles=True):
635
+ console.print(content)
636
+ return
637
+
638
+ console.print(content)
639
+
640
+
641
+ # ------------------------- Output flags decorator ------------------------ #
642
+
643
+
644
+ def _set_view(ctx, _param, value):
645
+ if not value:
646
+ return
647
+ ctx.ensure_object(dict)
648
+ ctx.obj["view"] = value
649
+
650
+
651
+ def _set_json(ctx, _param, value):
652
+ if not value:
653
+ return
654
+ ctx.ensure_object(dict)
655
+ ctx.obj["view"] = "json"
656
+
657
+
658
+ def output_flags():
659
+ """Decorator to allow output format flags on any subcommand."""
660
+
661
+ def decorator(f):
662
+ f = click.option(
663
+ "--json",
664
+ "json_mode",
665
+ is_flag=True,
666
+ expose_value=False,
667
+ help="Shortcut for --view json",
668
+ callback=_set_json,
669
+ )(f)
670
+ f = click.option(
671
+ "-o",
672
+ "--output",
673
+ "--view",
674
+ "view_opt",
675
+ type=click.Choice(["rich", "plain", "json", "md"]),
676
+ expose_value=False,
677
+ help="Output format",
678
+ callback=_set_view,
679
+ )(f)
680
+ return f
681
+
682
+ return decorator
683
+
684
+
685
+ # ------------------------- Ambiguity handling --------------------------- #
686
+
687
+
688
+ def handle_ambiguous_resource(
689
+ ctx, resource_type: str, ref: str, matches: list[Any]
690
+ ) -> Any:
691
+ """Handle multiple resource matches gracefully."""
692
+ if _get_view(ctx) == "json":
693
+ return matches[0]
694
+
695
+ if questionary and os.getenv("TERM") and os.isatty(0) and os.isatty(1):
696
+ picked_idx = questionary.select(
697
+ f"Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s match '{ref.replace('{', '{{').replace('}', '}}')}'. Pick one:",
698
+ choices=[
699
+ questionary.Choice(
700
+ title=f"{getattr(m, 'name', '—').replace('{', '{{').replace('}', '}}')} — {getattr(m, 'id', '').replace('{', '{{').replace('}', '}}')}",
701
+ value=i,
702
+ )
703
+ for i, m in enumerate(matches)
704
+ ],
705
+ use_indicator=True,
706
+ qmark="🧭",
707
+ instruction="↑/↓ to select • Enter to confirm",
708
+ ).ask()
709
+ if picked_idx is None:
710
+ raise click.ClickException("Selection cancelled")
711
+ return matches[picked_idx]
712
+
713
+ # Fallback numeric prompt
714
+ console.print(
715
+ f"[yellow]Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s found matching '{ref.replace('{', '{{').replace('}', '}}')}':[/yellow]"
716
+ )
717
+ table = Table(
718
+ title=f"Select {resource_type.replace('{', '{{').replace('}', '}}').title()}",
719
+ box=box.ROUNDED,
720
+ )
721
+ table.add_column("#", style="dim", width=3)
722
+ table.add_column("ID", style="dim", width=36)
723
+ table.add_column("Name", style="cyan")
724
+ for i, m in enumerate(matches, 1):
725
+ table.add_row(str(i), str(getattr(m, "id", "")), str(getattr(m, "name", "")))
726
+ console.print(table)
727
+ choice = click.prompt(
728
+ f"Select {resource_type.replace('{', '{{').replace('}', '}}')} (1-{len(matches)})",
729
+ type=int,
730
+ )
731
+ if 1 <= choice <= len(matches):
732
+ return matches[choice - 1]
733
+ raise click.ClickException("Invalid selection")