glaip-sdk 0.0.3__py3-none-any.whl → 0.0.5__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 (47) hide show
  1. glaip_sdk/__init__.py +5 -5
  2. glaip_sdk/branding.py +146 -0
  3. glaip_sdk/cli/__init__.py +1 -1
  4. glaip_sdk/cli/agent_config.py +82 -0
  5. glaip_sdk/cli/commands/__init__.py +3 -3
  6. glaip_sdk/cli/commands/agents.py +786 -271
  7. glaip_sdk/cli/commands/configure.py +19 -19
  8. glaip_sdk/cli/commands/mcps.py +151 -141
  9. glaip_sdk/cli/commands/models.py +1 -1
  10. glaip_sdk/cli/commands/tools.py +252 -178
  11. glaip_sdk/cli/display.py +244 -0
  12. glaip_sdk/cli/io.py +106 -0
  13. glaip_sdk/cli/main.py +27 -20
  14. glaip_sdk/cli/resolution.py +59 -0
  15. glaip_sdk/cli/utils.py +372 -213
  16. glaip_sdk/cli/validators.py +235 -0
  17. glaip_sdk/client/__init__.py +3 -224
  18. glaip_sdk/client/agents.py +632 -171
  19. glaip_sdk/client/base.py +66 -4
  20. glaip_sdk/client/main.py +226 -0
  21. glaip_sdk/client/mcps.py +143 -18
  22. glaip_sdk/client/tools.py +327 -104
  23. glaip_sdk/config/constants.py +10 -1
  24. glaip_sdk/models.py +43 -3
  25. glaip_sdk/rich_components.py +29 -0
  26. glaip_sdk/utils/__init__.py +18 -171
  27. glaip_sdk/utils/agent_config.py +181 -0
  28. glaip_sdk/utils/client_utils.py +159 -79
  29. glaip_sdk/utils/display.py +100 -0
  30. glaip_sdk/utils/general.py +94 -0
  31. glaip_sdk/utils/import_export.py +140 -0
  32. glaip_sdk/utils/rendering/formatting.py +6 -1
  33. glaip_sdk/utils/rendering/renderer/__init__.py +67 -8
  34. glaip_sdk/utils/rendering/renderer/base.py +340 -247
  35. glaip_sdk/utils/rendering/renderer/debug.py +3 -2
  36. glaip_sdk/utils/rendering/renderer/panels.py +11 -10
  37. glaip_sdk/utils/rendering/steps.py +1 -1
  38. glaip_sdk/utils/resource_refs.py +192 -0
  39. glaip_sdk/utils/rich_utils.py +29 -0
  40. glaip_sdk/utils/serialization.py +285 -0
  41. glaip_sdk/utils/validation.py +273 -0
  42. {glaip_sdk-0.0.3.dist-info → glaip_sdk-0.0.5.dist-info}/METADATA +6 -5
  43. glaip_sdk-0.0.5.dist-info/RECORD +55 -0
  44. glaip_sdk/cli/commands/init.py +0 -177
  45. glaip_sdk-0.0.3.dist-info/RECORD +0 -40
  46. {glaip_sdk-0.0.3.dist-info → glaip_sdk-0.0.5.dist-info}/WHEEL +0 -0
  47. {glaip_sdk-0.0.3.dist-info → glaip_sdk-0.0.5.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/utils.py CHANGED
@@ -17,12 +17,9 @@ import tempfile
17
17
  from typing import TYPE_CHECKING, Any
18
18
 
19
19
  import click
20
- from rich import box
21
20
  from rich.console import Console, Group
22
21
  from rich.markdown import Markdown
23
- from rich.panel import Panel
24
22
  from rich.pretty import Pretty
25
- from rich.table import Table
26
23
  from rich.text import Text
27
24
 
28
25
  # Optional interactive deps (fuzzy palette)
@@ -44,6 +41,7 @@ if TYPE_CHECKING:
44
41
 
45
42
  from glaip_sdk import Client
46
43
  from glaip_sdk.cli.commands.configure import load_config
44
+ from glaip_sdk.rich_components import AIPPanel, AIPTable
47
45
  from glaip_sdk.utils import is_uuid
48
46
  from glaip_sdk.utils.rendering.renderer import (
49
47
  CapturingConsole,
@@ -57,7 +55,9 @@ console = Console()
57
55
  # ----------------------------- Pager helpers ----------------------------- #
58
56
 
59
57
 
60
- def _prepare_pager_env(clear_on_exit: bool = True) -> None:
58
+ def _prepare_pager_env(
59
+ clear_on_exit: bool = True,
60
+ ) -> None: # pragma: no cover - terminal UI setup
61
61
  """
62
62
  Configure LESS flags for a predictable, high-quality UX:
63
63
  -R : pass ANSI color escapes
@@ -74,7 +74,9 @@ def _prepare_pager_env(clear_on_exit: bool = True) -> None:
74
74
  os.environ["LESS"] = os.getenv("AIP_LESS_FLAGS", default_flags)
75
75
 
76
76
 
77
- def _render_ansi(renderable) -> str:
77
+ def _render_ansi(
78
+ renderable,
79
+ ) -> str: # pragma: no cover - rendering requires real terminal
78
80
  """Render a Rich renderable to an ANSI string suitable for piping to 'less'."""
79
81
  buf = io.StringIO()
80
82
  tmp_console = Console(
@@ -90,7 +92,7 @@ def _render_ansi(renderable) -> str:
90
92
  return buf.getvalue()
91
93
 
92
94
 
93
- def _pager_header() -> str:
95
+ def _pager_header() -> str: # pragma: no cover - terminal UI helper
94
96
  v = (os.getenv("AIP_PAGER_HEADER", "1") or "1").strip().lower()
95
97
  if v in {"0", "false", "off"}:
96
98
  return ""
@@ -103,7 +105,9 @@ def _pager_header() -> str:
103
105
  )
104
106
 
105
107
 
106
- def _page_with_system_pager(ansi_text: str) -> bool:
108
+ def _page_with_system_pager(
109
+ ansi_text: str,
110
+ ) -> bool: # pragma: no cover - spawns real pager
107
111
  """Prefer 'less' with a temp file so stdin remains the TTY."""
108
112
  if not (console.is_terminal and os.isatty(1)):
109
113
  return False
@@ -161,7 +165,7 @@ def _page_with_system_pager(ansi_text: str) -> bool:
161
165
 
162
166
 
163
167
  def _get_view(ctx) -> str:
164
- obj = ctx.obj or {}
168
+ obj = (ctx.obj or {}) if ctx is not None else {}
165
169
  return obj.get("view") or obj.get("format") or "rich"
166
170
 
167
171
 
@@ -173,13 +177,20 @@ def get_client(ctx) -> Client:
173
177
  file_config = load_config() or {}
174
178
  context_config = (ctx.obj or {}) if ctx else {}
175
179
 
180
+ raw_timeout = os.getenv("AIP_TIMEOUT", "0") or "0"
181
+ try:
182
+ timeout_value = float(raw_timeout)
183
+ except ValueError:
184
+ timeout_value = None
185
+
176
186
  env_config = {
177
187
  "api_url": os.getenv("AIP_API_URL"),
178
188
  "api_key": os.getenv("AIP_API_KEY"),
179
- "timeout": float(os.getenv("AIP_TIMEOUT", "0") or 0) or None,
189
+ "timeout": timeout_value if timeout_value else None,
180
190
  }
181
191
  env_config = {k: v for k, v in env_config.items() if v not in (None, "", 0)}
182
192
 
193
+ # Merge config sources: context > env > file
183
194
  config = {
184
195
  **file_config,
185
196
  **env_config,
@@ -198,16 +209,6 @@ def get_client(ctx) -> Client:
198
209
  )
199
210
 
200
211
 
201
- # ----------------------------- Small helpers ----------------------------- #
202
-
203
-
204
- def safe_getattr(obj: Any, attr: str, default: Any = None) -> Any:
205
- try:
206
- return getattr(obj, attr)
207
- except Exception:
208
- return default
209
-
210
-
211
212
  # ----------------------------- Secret masking ---------------------------- #
212
213
 
213
214
  _DEFAULT_MASK_FIELDS = {
@@ -230,28 +231,22 @@ def _mask_value(v: Any) -> str:
230
231
  return f"{s[:4]}••••••••{s[-4:]}"
231
232
 
232
233
 
233
- def _mask_any(x: Any, mask_fields: set[str]) -> Any:
234
- """Recursively mask sensitive fields in any data structure.
235
-
236
- Args:
237
- x: The data to mask (dict, list, or primitive)
238
- mask_fields: Set of field names to mask (case-insensitive)
234
+ def _mask_any(value: Any, mask_fields: set[str]) -> Any:
235
+ """Recursively mask sensitive fields in mappings / lists."""
239
236
 
240
- Returns:
241
- Masked copy of the data with sensitive values replaced
242
- """
243
- if isinstance(x, dict):
244
- out = {}
245
- for k, v in x.items():
246
- if k.lower() in mask_fields and v is not None:
247
- out[k] = _mask_value(v)
237
+ if isinstance(value, dict):
238
+ masked: dict[Any, Any] = {}
239
+ for key, raw in value.items():
240
+ if isinstance(key, str) and key.lower() in mask_fields and raw is not None:
241
+ masked[key] = _mask_value(raw)
248
242
  else:
249
- out[k] = _mask_any(v, mask_fields)
250
- return out
251
- elif isinstance(x, list):
252
- return [_mask_any(v, mask_fields) for v in x]
253
- else:
254
- return x
243
+ masked[key] = _mask_any(raw, mask_fields)
244
+ return masked
245
+
246
+ if isinstance(value, list):
247
+ return [_mask_any(item, mask_fields) for item in value]
248
+
249
+ return value
255
250
 
256
251
 
257
252
  def _maybe_mask_row(row: dict[str, Any], mask_fields: set[str]) -> dict[str, Any]:
@@ -307,19 +302,18 @@ def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
307
302
  return " • ".join(parts) if parts else (_id or "(row)")
308
303
 
309
304
 
310
- def _fuzzy_pick(
311
- rows: list[dict[str, Any]], columns: list[tuple], title: str
312
- ) -> dict[str, Any] | None:
313
- """
314
- Open a minimal fuzzy palette using prompt_toolkit.
315
- Returns the selected row (dict) or None if cancelled/missing deps.
316
- """
317
- if not (_HAS_PTK and console.is_terminal and os.isatty(1)):
318
- return None
305
+ def _check_fuzzy_pick_requirements() -> bool:
306
+ """Check if fuzzy picking requirements are met."""
307
+ return _HAS_PTK and console.is_terminal and os.isatty(1)
319
308
 
320
- # Build display corpus and a reverse map
309
+
310
+ def _build_unique_labels(
311
+ rows: list[dict[str, Any]], columns: list[tuple]
312
+ ) -> tuple[list[str], dict[str, dict[str, Any]]]:
313
+ """Build unique display labels and reverse mapping."""
321
314
  labels = []
322
315
  by_label: dict[str, dict[str, Any]] = {}
316
+
323
317
  for r in rows:
324
318
  label = _row_display(r, columns)
325
319
  # Ensure uniqueness: if duplicate, suffix with …#n
@@ -332,64 +326,49 @@ def _fuzzy_pick(
332
326
  labels.append(label)
333
327
  by_label[label] = r
334
328
 
335
- # Create a fuzzy completer that searches anywhere in the string
336
- class FuzzyCompleter:
337
- def __init__(self, words: list[str]):
338
- self.words = words
339
-
340
- def get_completions(self, document, complete_event):
341
- word = document.get_word_before_cursor()
342
- if not word:
343
- return
344
-
345
- word_lower = word.lower()
346
- for label in self.words:
347
- label_lower = label.lower()
348
- # Check if all characters in the search word appear in order in the label
349
- if self._fuzzy_match(word_lower, label_lower):
350
- yield Completion(label, start_position=-len(word))
351
-
352
- def _fuzzy_match(self, search: str, target: str) -> bool:
353
- """
354
- True fuzzy matching: checks if all characters in search appear in order in target.
355
- Examples:
356
- - "aws" matches "aws_calculator_agent" ✓
357
- - "calc" matches "aws_calculator_agent" ✓
358
- - "gent" matches "aws_calculator_agent" ✓
359
- - "agent" matches "aws_calculator_agent" ✓
360
- - "aws_calc" matches "aws_calculator_agent" ✓
361
- """
362
- if not search:
363
- return True
364
-
365
- search_idx = 0
366
- for char in target:
367
- if search_idx < len(search) and search[search_idx] == char:
368
- search_idx += 1
369
- if search_idx == len(search):
370
- return True
371
- return False
372
-
373
- completer = FuzzyCompleter(labels)
329
+ return labels, by_label
374
330
 
375
- try:
376
- answer = prompt(
377
- message=f"Find {title.rstrip('s')}: ",
378
- completer=completer,
379
- complete_in_thread=True,
380
- complete_while_typing=True,
381
- )
382
- except (KeyboardInterrupt, EOFError):
383
- return None
384
331
 
385
- if not answer:
386
- return None
332
+ class _FuzzyCompleter:
333
+ """Fuzzy completer for prompt_toolkit."""
387
334
 
388
- # Exact label chosen from menu → direct hit
335
+ def __init__(self, words: list[str]):
336
+ self.words = words
337
+
338
+ def get_completions(self, document, _complete_event):
339
+ word = document.get_word_before_cursor()
340
+ if not word:
341
+ return
342
+
343
+ word_lower = word.lower()
344
+ for label in self.words:
345
+ label_lower = label.lower()
346
+ if self._fuzzy_match(word_lower, label_lower):
347
+ yield Completion(label, start_position=-len(word))
348
+
349
+ def _fuzzy_match(self, search: str, target: str) -> bool:
350
+ """True fuzzy matching: checks if all characters in search appear in order in target."""
351
+ if not search:
352
+ return True
353
+
354
+ search_idx = 0
355
+ for char in target:
356
+ if search_idx < len(search) and search[search_idx] == char:
357
+ search_idx += 1
358
+ if search_idx == len(search):
359
+ return True
360
+ return False
361
+
362
+
363
+ def _perform_fuzzy_search(
364
+ answer: str, labels: list[str], by_label: dict[str, dict[str, Any]]
365
+ ) -> dict[str, Any] | None:
366
+ """Perform fuzzy search fallback and return best match."""
367
+ # Exact label match
389
368
  if answer in by_label:
390
369
  return by_label[answer]
391
370
 
392
- # Fuzzy search fallback: find best fuzzy match
371
+ # Fuzzy search fallback
393
372
  best_match = None
394
373
  best_score = -1
395
374
 
@@ -399,11 +378,36 @@ def _fuzzy_pick(
399
378
  best_score = score
400
379
  best_match = label
401
380
 
402
- if best_match and best_score > 0:
403
- return by_label[best_match]
381
+ return by_label[best_match] if best_match and best_score > 0 else None
404
382
 
405
- # No match
406
- return None
383
+
384
+ def _fuzzy_pick(
385
+ rows: list[dict[str, Any]], columns: list[tuple], title: str
386
+ ) -> dict[str, Any] | None: # pragma: no cover - requires interactive prompt toolkit
387
+ """
388
+ Open a minimal fuzzy palette using prompt_toolkit.
389
+ Returns the selected row (dict) or None if cancelled/missing deps.
390
+ """
391
+ if not _check_fuzzy_pick_requirements():
392
+ return None
393
+
394
+ # Build display labels and mapping
395
+ labels, by_label = _build_unique_labels(rows, columns)
396
+
397
+ # Create fuzzy completer
398
+ completer = _FuzzyCompleter(labels)
399
+
400
+ try:
401
+ answer = prompt(
402
+ message=f"Find {title.rstrip('s')}: ",
403
+ completer=completer,
404
+ complete_in_thread=True,
405
+ complete_while_typing=True,
406
+ )
407
+ except (KeyboardInterrupt, EOFError):
408
+ return None
409
+
410
+ return _perform_fuzzy_search(answer, labels, by_label) if answer else None
407
411
 
408
412
 
409
413
  def _fuzzy_score(search: str, target: str) -> int:
@@ -460,6 +464,52 @@ def _fuzzy_score(search: str, target: str) -> int:
460
464
  # ----------------------------- Pretty outputs ---------------------------- #
461
465
 
462
466
 
467
+ def _coerce_result_payload(result: Any) -> Any:
468
+ try:
469
+ to_dict = getattr(result, "to_dict", None)
470
+ if callable(to_dict):
471
+ return to_dict()
472
+ except Exception:
473
+ return result
474
+ return result
475
+
476
+
477
+ def _apply_mask_if_configured(payload: Any) -> Any:
478
+ mask_fields = _resolve_mask_fields()
479
+ if not mask_fields:
480
+ return payload
481
+ try:
482
+ return _mask_any(payload, mask_fields)
483
+ except Exception:
484
+ return payload
485
+
486
+
487
+ def _ensure_displayable(payload: Any) -> Any:
488
+ if isinstance(payload, dict | list | str | int | float | bool) or payload is None:
489
+ return payload
490
+
491
+ if hasattr(payload, "__dict__"):
492
+ try:
493
+ return dict(payload)
494
+ except Exception:
495
+ try:
496
+ return dict(payload.__dict__)
497
+ except Exception:
498
+ pass
499
+
500
+ try:
501
+ return str(payload)
502
+ except Exception:
503
+ return repr(payload)
504
+
505
+
506
+ def _render_markdown_output(data: Any) -> None:
507
+ try:
508
+ console.print(Markdown(str(data)))
509
+ except ImportError:
510
+ click.echo(str(data))
511
+
512
+
463
513
  def output_result(
464
514
  ctx,
465
515
  result: Any,
@@ -468,15 +518,10 @@ def output_result(
468
518
  success_message: str | None = None,
469
519
  ):
470
520
  fmt = _get_view(ctx)
471
- data = result.to_dict() if hasattr(result, "to_dict") else result
472
521
 
473
- # Apply recursive secret masking before any rendering
474
- mask_fields = _resolve_mask_fields()
475
- if mask_fields:
476
- try:
477
- data = _mask_any(data, mask_fields)
478
- except Exception:
479
- pass # Continue with unmasked data if masking fails
522
+ data = _coerce_result_payload(result)
523
+ data = _apply_mask_if_configured(data)
524
+ data = _ensure_displayable(data)
480
525
 
481
526
  if fmt == "json":
482
527
  click.echo(json.dumps(data, indent=2, default=str))
@@ -487,20 +532,22 @@ def output_result(
487
532
  return
488
533
 
489
534
  if fmt == "md":
490
- try:
491
- console.print(Markdown(str(data)))
492
- except ImportError:
493
- # Fallback to plain if markdown not available
494
- click.echo(str(data))
535
+ _render_markdown_output(data)
495
536
  return
496
537
 
497
538
  if success_message:
498
- console.print(f"[green]✅ {success_message}[/green]")
539
+ console.print(Text(f"[green]✅ {success_message}[/green]"))
499
540
 
500
541
  if panel_title:
501
- console.print(Panel(Pretty(data), title=panel_title, border_style="blue"))
542
+ console.print(
543
+ AIPPanel(
544
+ Pretty(data),
545
+ title=panel_title,
546
+ border_style="blue",
547
+ )
548
+ )
502
549
  else:
503
- console.print(f"[cyan]{title}:[/cyan]")
550
+ console.print(Text(f"[cyan]{title}:[/cyan]"))
504
551
  console.print(Pretty(data))
505
552
 
506
553
 
@@ -510,17 +557,7 @@ def output_result(
510
557
  # _PICK_THRESHOLD = int(os.getenv("AIP_PICK_THRESHOLD", "5") or "5")
511
558
 
512
559
 
513
- def output_list(
514
- ctx,
515
- items: list[Any],
516
- title: str,
517
- columns: list[tuple],
518
- transform_func=None,
519
- ):
520
- """Display a list with fuzzy palette by default on TTY, Rich table as fallback."""
521
- fmt = _get_view(ctx)
522
-
523
- # Normalize rows
560
+ def _normalise_rows(items: list[Any], transform_func) -> list[dict[str, Any]]:
524
561
  try:
525
562
  rows: list[dict[str, Any]] = []
526
563
  for item in items:
@@ -534,106 +571,139 @@ def output_list(
534
571
  rows.append(item)
535
572
  else:
536
573
  rows.append({"value": item})
574
+ return rows
537
575
  except Exception:
538
- rows = []
576
+ return []
577
+
539
578
 
540
- # Mask secrets (apply before any view)
579
+ def _mask_rows_if_configured(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
541
580
  mask_fields = _resolve_mask_fields()
542
- if mask_fields:
543
- try:
544
- rows = [_maybe_mask_row(r, mask_fields) for r in rows]
545
- except Exception:
546
- pass
581
+ if not mask_fields:
582
+ return rows
583
+ try:
584
+ return [_maybe_mask_row(row, mask_fields) for row in rows]
585
+ except Exception:
586
+ return rows
587
+
588
+
589
+ def _render_plain_list(
590
+ rows: list[dict[str, Any]], title: str, columns: list[tuple]
591
+ ) -> None:
592
+ if not rows:
593
+ click.echo(f"No {title.lower()} found.")
594
+ return
595
+ for row in rows:
596
+ row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
597
+ click.echo(row_str)
598
+
599
+
600
+ def _render_markdown_list(
601
+ rows: list[dict[str, Any]], title: str, columns: list[tuple]
602
+ ) -> None:
603
+ if not rows:
604
+ click.echo(f"No {title.lower()} found.")
605
+ return
606
+ headers = [header for _, header, _, _ in columns]
607
+ click.echo(f"| {' | '.join(headers)} |")
608
+ click.echo(f"| {' | '.join('---' for _ in headers)} |")
609
+ for row in rows:
610
+ row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
611
+ click.echo(f"| {row_str} |")
612
+
613
+
614
+ def _should_sort_rows(rows: list[dict[str, Any]]) -> bool:
615
+ return (
616
+ os.getenv("AIP_TABLE_NO_SORT", "0") not in ("1", "true", "on")
617
+ and rows
618
+ and isinstance(rows[0], dict)
619
+ and "name" in rows[0]
620
+ )
621
+
622
+
623
+ def _create_table(columns: list[tuple], title: str):
624
+ table = AIPTable(title=title, expand=True)
625
+ for _key, header, style, width in columns:
626
+ table.add_column(header, style=style, width=width)
627
+ return table
628
+
629
+
630
+ def _build_table_group(
631
+ rows: list[dict[str, Any]], columns: list[tuple], title: str
632
+ ) -> Group:
633
+ table = _create_table(columns, title)
634
+ for row in rows:
635
+ table.add_row(*[str(row.get(key, "N/A")) for key, _, _, _ in columns])
636
+ footer = Text(f"\n[dim]Total {len(rows)} items[/dim]")
637
+ return Group(table, footer)
638
+
639
+
640
+ def _should_page_output(row_count: int, is_tty: bool) -> bool:
641
+ pager_env = (os.getenv("AIP_PAGER", "auto") or "auto").lower()
642
+ if pager_env in ("0", "off", "false"):
643
+ return False
644
+ if pager_env in ("1", "on", "true"):
645
+ return is_tty
646
+ try:
647
+ term_h = console.size.height or 24
648
+ approx_lines = 5 + row_count
649
+ return is_tty and (approx_lines >= term_h * 0.5)
650
+ except Exception:
651
+ return is_tty
652
+
653
+
654
+ def output_list(
655
+ ctx,
656
+ items: list[Any],
657
+ title: str,
658
+ columns: list[tuple],
659
+ transform_func=None,
660
+ ):
661
+ """Display a list with fuzzy palette by default on TTY, Rich table as fallback."""
662
+ fmt = _get_view(ctx)
663
+ rows = _normalise_rows(items, transform_func)
664
+ rows = _mask_rows_if_configured(rows)
547
665
 
548
- # JSON view bypasses any UI
549
666
  if fmt == "json":
550
- data = rows or [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
667
+ data = (
668
+ rows
669
+ if rows
670
+ else [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
671
+ )
551
672
  click.echo(json.dumps(data, indent=2, default=str))
552
673
  return
553
674
 
554
- # Plain view - simple text output
555
675
  if fmt == "plain":
556
- if not rows:
557
- click.echo(f"No {title.lower()} found.")
558
- return
559
- for row in rows:
560
- row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
561
- click.echo(row_str)
676
+ _render_plain_list(rows, title, columns)
562
677
  return
563
678
 
564
- # Markdown view - table format
565
679
  if fmt == "md":
566
- if not rows:
567
- click.echo(f"No {title.lower()} found.")
568
- return
569
- # Create markdown table
570
- headers = [header for _, header, _, _ in columns]
571
- click.echo(f"| {' | '.join(headers)} |")
572
- click.echo(f"| {' | '.join('---' for _ in headers)} |")
573
- for row in rows:
574
- row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
575
- click.echo(f"| {row_str} |")
680
+ _render_markdown_list(rows, title, columns)
576
681
  return
577
682
 
578
683
  if not items:
579
- console.print(f"[yellow]No {title.lower()} found.[/yellow]")
684
+ console.print(Text(f"[yellow]No {title.lower()} found.[/yellow]"))
580
685
  return
581
686
 
582
- # Sort by name by default (unless disabled)
583
- if (
584
- os.getenv("AIP_TABLE_NO_SORT", "0") not in ("1", "true", "on")
585
- and rows
586
- and isinstance(rows[0], dict)
587
- and "name" in rows[0]
588
- ):
687
+ if _should_sort_rows(rows):
589
688
  try:
590
689
  rows = sorted(rows, key=lambda r: str(r.get("name", "")).lower())
591
690
  except Exception:
592
691
  pass
593
692
 
594
- # === Fuzzy palette is the default for TTY lists ===
595
- picked: dict[str, Any] | None = None
596
- if console.is_terminal and os.isatty(1):
597
- picked = _fuzzy_pick(rows, columns, title)
598
-
693
+ picked = (
694
+ _fuzzy_pick(rows, columns, title)
695
+ if console.is_terminal and os.isatty(1)
696
+ else None
697
+ )
599
698
  if picked:
600
- # Show a focused, single-row table (easy to copy ID/name)
601
- table = Table(title=title, box=box.ROUNDED, expand=True)
602
- for _key, header, style, width in columns:
603
- table.add_column(header, style=style, width=width)
699
+ table = _create_table(columns, title)
604
700
  table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
605
-
606
701
  console.print(table)
607
702
  console.print(Text("\n[dim]Tip: use `aip agents get <ID>` for details[/dim]"))
608
703
  return
609
704
 
610
- # Build full table
611
- table = Table(title=title, box=box.ROUNDED, expand=True)
612
- for _key, header, style, width in columns:
613
- table.add_column(header, style=style, width=width)
614
- for row in rows:
615
- table.add_row(*[str(row.get(key, "N/A")) for key, _, _, _ in columns])
616
-
617
- footer = Text(f"\n[dim]Total {len(rows)} items[/dim]")
618
- content = Group(table, footer)
619
-
620
- # Auto paging when long
621
- is_tty = console.is_terminal and os.isatty(1)
622
- pager_env = (os.getenv("AIP_PAGER", "auto") or "auto").lower()
623
-
624
- if pager_env in ("0", "off", "false"):
625
- should_page = False
626
- elif pager_env in ("1", "on", "true"):
627
- should_page = is_tty
628
- else:
629
- try:
630
- term_h = console.size.height or 24
631
- approx_lines = 5 + len(rows)
632
- should_page = is_tty and (approx_lines >= term_h * 0.5)
633
- except Exception:
634
- should_page = is_tty
635
-
636
- if should_page:
705
+ content = _build_table_group(rows, columns, title)
706
+ if _should_page_output(len(rows), console.is_terminal and os.isatty(1)):
637
707
  ansi = _render_ansi(content)
638
708
  if not _page_with_system_pager(ansi):
639
709
  with console.pager(styles=True):
@@ -711,12 +781,12 @@ def coerce_to_row(item, keys: list[str]) -> dict[str, Any]:
711
781
 
712
782
 
713
783
  def build_renderer(
714
- ctx,
784
+ _ctx,
715
785
  *,
716
786
  save_path,
717
787
  theme="dark",
718
788
  verbose=False,
719
- tty_enabled=True,
789
+ _tty_enabled=True,
720
790
  live=None,
721
791
  snapshots=None,
722
792
  ):
@@ -737,8 +807,12 @@ def build_renderer(
737
807
  if save_path:
738
808
  working_console = CapturingConsole(console, capture=True)
739
809
 
740
- # Decide live behavior: default is live unless verbose; allow explicit override
741
- live_enabled = (not verbose) if live is None else bool(live)
810
+ # Configure renderer based on verbose mode and explicit overrides
811
+ if live is None:
812
+ live_enabled = not verbose # Disable live mode in verbose (unless overridden)
813
+ else:
814
+ live_enabled = bool(live)
815
+
742
816
  renderer_cfg = RendererConfig(
743
817
  theme=theme,
744
818
  style="debug" if verbose else "pretty",
@@ -761,8 +835,81 @@ def build_renderer(
761
835
  return renderer, working_console
762
836
 
763
837
 
838
+ def _build_resource_labels(resources: list[Any]) -> tuple[list[str], dict[str, Any]]:
839
+ """Build unique display labels for resources."""
840
+ labels = []
841
+ by_label: dict[str, Any] = {}
842
+
843
+ for resource in resources:
844
+ name = getattr(resource, "name", "Unknown")
845
+ _id = getattr(resource, "id", "Unknown")
846
+
847
+ # Create display label
848
+ label_parts = []
849
+ if name and name != "Unknown":
850
+ label_parts.append(name)
851
+ label_parts.append(f"[{_id[:8]}...]") # Show first 8 chars of ID
852
+ label = " • ".join(label_parts)
853
+
854
+ # Ensure uniqueness
855
+ if label in by_label:
856
+ i = 2
857
+ base = label
858
+ while f"{base} #{i}" in by_label:
859
+ i += 1
860
+ label = f"{base} #{i}"
861
+
862
+ labels.append(label)
863
+ by_label[label] = resource
864
+
865
+ return labels, by_label
866
+
867
+
868
+ def _fuzzy_pick_for_resources(
869
+ resources: list[Any], resource_type: str, _search_term: str
870
+ ) -> Any | None: # pragma: no cover - interactive selection helper
871
+ """
872
+ Fuzzy picker for resource objects, similar to _fuzzy_pick but without column dependencies.
873
+
874
+ Args:
875
+ resources: List of resource objects to choose from
876
+ resource_type: Type of resource (e.g., "agent", "tool")
877
+ search_term: The search term that led to multiple matches
878
+
879
+ Returns:
880
+ Selected resource object or None if cancelled/no selection
881
+ """
882
+ if not _check_fuzzy_pick_requirements():
883
+ return None
884
+
885
+ # Build labels and mapping
886
+ labels, by_label = _build_resource_labels(resources)
887
+
888
+ # Create fuzzy completer
889
+ completer = _FuzzyCompleter(labels)
890
+
891
+ try:
892
+ answer = prompt(
893
+ message=f"Find 🤖 {resource_type.title()}: ",
894
+ completer=completer,
895
+ complete_in_thread=True,
896
+ complete_while_typing=True,
897
+ )
898
+ except (KeyboardInterrupt, EOFError):
899
+ return None
900
+
901
+ return _perform_fuzzy_search(answer, labels, by_label) if answer else None
902
+
903
+
764
904
  def resolve_resource(
765
- ctx, ref: str, *, get_by_id, find_by_name, label: str, select: int | None = None
905
+ ctx,
906
+ ref: str,
907
+ *,
908
+ get_by_id,
909
+ find_by_name,
910
+ label: str,
911
+ select: int | None = None,
912
+ interface_preference: str = "fuzzy",
766
913
  ):
767
914
  """Resolve resource reference (ID or name) with ambiguity handling.
768
915
 
@@ -773,6 +920,7 @@ def resolve_resource(
773
920
  find_by_name: Function to find resources by name
774
921
  label: Resource type label for error messages
775
922
  select: Optional selection index for ambiguity resolution
923
+ interface_preference: "fuzzy" for fuzzy picker, "questionary" for up/down list
776
924
 
777
925
  Returns:
778
926
  Resolved resource object
@@ -795,7 +943,17 @@ def resolve_resource(
795
943
  raise click.ClickException(f"--select must be 1..{len(matches)}")
796
944
  return matches[idx]
797
945
 
798
- return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
946
+ # Choose interface based on preference
947
+ 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)
954
+ else:
955
+ # Use questionary interface for traditional up/down selection
956
+ return handle_ambiguous_resource(ctx, label.lower(), ref, matches)
799
957
 
800
958
 
801
959
  def handle_ambiguous_resource(
@@ -825,11 +983,12 @@ def handle_ambiguous_resource(
825
983
 
826
984
  # Fallback numeric prompt
827
985
  console.print(
828
- f"[yellow]Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s found matching '{ref.replace('{', '{{').replace('}', '}}')}':[/yellow]"
986
+ Text(
987
+ f"[yellow]Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s found matching '{ref.replace('{', '{{').replace('}', '}}')}':[/yellow]"
988
+ )
829
989
  )
830
- table = Table(
990
+ table = AIPTable(
831
991
  title=f"Select {resource_type.replace('{', '{{').replace('}', '}}').title()}",
832
- box=box.ROUNDED,
833
992
  )
834
993
  table.add_column("#", style="dim", width=3)
835
994
  table.add_column("ID", style="dim", width=36)