glaip-sdk 0.0.4__py3-none-any.whl → 0.0.5b1__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 (48) hide show
  1. glaip_sdk/__init__.py +5 -5
  2. glaip_sdk/branding.py +18 -17
  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 +570 -673
  7. glaip_sdk/cli/commands/configure.py +2 -2
  8. glaip_sdk/cli/commands/mcps.py +148 -143
  9. glaip_sdk/cli/commands/models.py +1 -1
  10. glaip_sdk/cli/commands/tools.py +250 -179
  11. glaip_sdk/cli/display.py +244 -0
  12. glaip_sdk/cli/io.py +106 -0
  13. glaip_sdk/cli/main.py +14 -18
  14. glaip_sdk/cli/resolution.py +59 -0
  15. glaip_sdk/cli/utils.py +305 -264
  16. glaip_sdk/cli/validators.py +235 -0
  17. glaip_sdk/client/__init__.py +3 -224
  18. glaip_sdk/client/agents.py +631 -191
  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 +146 -11
  23. glaip_sdk/config/constants.py +10 -1
  24. glaip_sdk/models.py +42 -2
  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.4.dist-info → glaip_sdk-0.0.5b1.dist-info}/METADATA +22 -21
  43. glaip_sdk-0.0.5b1.dist-info/RECORD +55 -0
  44. {glaip_sdk-0.0.4.dist-info → glaip_sdk-0.0.5b1.dist-info}/WHEEL +1 -1
  45. glaip_sdk-0.0.5b1.dist-info/entry_points.txt +3 -0
  46. glaip_sdk/cli/commands/init.py +0 -93
  47. glaip_sdk-0.0.4.dist-info/RECORD +0 -41
  48. glaip_sdk-0.0.4.dist-info/entry_points.txt +0 -2
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,
@@ -220,28 +231,22 @@ def _mask_value(v: Any) -> str:
220
231
  return f"{s[:4]}••••••••{s[-4:]}"
221
232
 
222
233
 
223
- def _mask_any(x: Any, mask_fields: set[str]) -> Any:
224
- """Recursively mask sensitive fields in any data structure.
225
-
226
- Args:
227
- x: The data to mask (dict, list, or primitive)
228
- 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."""
229
236
 
230
- Returns:
231
- Masked copy of the data with sensitive values replaced
232
- """
233
- if isinstance(x, dict):
234
- out = {}
235
- for k, v in x.items():
236
- if k.lower() in mask_fields and v is not None:
237
- 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)
238
242
  else:
239
- out[k] = _mask_any(v, mask_fields)
240
- return out
241
- elif isinstance(x, list):
242
- return [_mask_any(v, mask_fields) for v in x]
243
- else:
244
- 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
245
250
 
246
251
 
247
252
  def _maybe_mask_row(row: dict[str, Any], mask_fields: set[str]) -> dict[str, Any]:
@@ -297,19 +302,18 @@ def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
297
302
  return " • ".join(parts) if parts else (_id or "(row)")
298
303
 
299
304
 
300
- def _fuzzy_pick(
301
- rows: list[dict[str, Any]], columns: list[tuple], title: str
302
- ) -> dict[str, Any] | None:
303
- """
304
- Open a minimal fuzzy palette using prompt_toolkit.
305
- Returns the selected row (dict) or None if cancelled/missing deps.
306
- """
307
- if not (_HAS_PTK and console.is_terminal and os.isatty(1)):
308
- 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)
309
308
 
310
- # 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."""
311
314
  labels = []
312
315
  by_label: dict[str, dict[str, Any]] = {}
316
+
313
317
  for r in rows:
314
318
  label = _row_display(r, columns)
315
319
  # Ensure uniqueness: if duplicate, suffix with …#n
@@ -322,64 +326,49 @@ def _fuzzy_pick(
322
326
  labels.append(label)
323
327
  by_label[label] = r
324
328
 
325
- # Create a fuzzy completer that searches anywhere in the string
326
- class FuzzyCompleter:
327
- def __init__(self, words: list[str]):
328
- self.words = words
329
-
330
- def get_completions(self, document, complete_event):
331
- word = document.get_word_before_cursor()
332
- if not word:
333
- return
334
-
335
- word_lower = word.lower()
336
- for label in self.words:
337
- label_lower = label.lower()
338
- # Check if all characters in the search word appear in order in the label
339
- if self._fuzzy_match(word_lower, label_lower):
340
- yield Completion(label, start_position=-len(word))
341
-
342
- def _fuzzy_match(self, search: str, target: str) -> bool:
343
- """
344
- True fuzzy matching: checks if all characters in search appear in order in target.
345
- Examples:
346
- - "aws" matches "aws_calculator_agent" ✓
347
- - "calc" matches "aws_calculator_agent" ✓
348
- - "gent" matches "aws_calculator_agent" ✓
349
- - "agent" matches "aws_calculator_agent" ✓
350
- - "aws_calc" matches "aws_calculator_agent" ✓
351
- """
352
- if not search:
353
- return True
354
-
355
- search_idx = 0
356
- for char in target:
357
- if search_idx < len(search) and search[search_idx] == char:
358
- search_idx += 1
359
- if search_idx == len(search):
360
- return True
361
- return False
362
-
363
- completer = FuzzyCompleter(labels)
329
+ return labels, by_label
364
330
 
365
- try:
366
- answer = prompt(
367
- message=f"Find {title.rstrip('s')}: ",
368
- completer=completer,
369
- complete_in_thread=True,
370
- complete_while_typing=True,
371
- )
372
- except (KeyboardInterrupt, EOFError):
373
- return None
374
331
 
375
- if not answer:
376
- return None
332
+ class _FuzzyCompleter:
333
+ """Fuzzy completer for prompt_toolkit."""
377
334
 
378
- # 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
379
368
  if answer in by_label:
380
369
  return by_label[answer]
381
370
 
382
- # Fuzzy search fallback: find best fuzzy match
371
+ # Fuzzy search fallback
383
372
  best_match = None
384
373
  best_score = -1
385
374
 
@@ -389,11 +378,36 @@ def _fuzzy_pick(
389
378
  best_score = score
390
379
  best_match = label
391
380
 
392
- if best_match and best_score > 0:
393
- return by_label[best_match]
381
+ return by_label[best_match] if best_match and best_score > 0 else None
382
+
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
394
409
 
395
- # No match
396
- return None
410
+ return _perform_fuzzy_search(answer, labels, by_label) if answer else None
397
411
 
398
412
 
399
413
  def _fuzzy_score(search: str, target: str) -> int:
@@ -450,6 +464,52 @@ def _fuzzy_score(search: str, target: str) -> int:
450
464
  # ----------------------------- Pretty outputs ---------------------------- #
451
465
 
452
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
+
453
513
  def output_result(
454
514
  ctx,
455
515
  result: Any,
@@ -458,15 +518,10 @@ def output_result(
458
518
  success_message: str | None = None,
459
519
  ):
460
520
  fmt = _get_view(ctx)
461
- data = result.to_dict() if hasattr(result, "to_dict") else result
462
521
 
463
- # Apply recursive secret masking before any rendering
464
- mask_fields = _resolve_mask_fields()
465
- if mask_fields:
466
- try:
467
- data = _mask_any(data, mask_fields)
468
- except Exception:
469
- 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)
470
525
 
471
526
  if fmt == "json":
472
527
  click.echo(json.dumps(data, indent=2, default=str))
@@ -477,18 +532,20 @@ def output_result(
477
532
  return
478
533
 
479
534
  if fmt == "md":
480
- try:
481
- console.print(Markdown(str(data)))
482
- except ImportError:
483
- # Fallback to plain if markdown not available
484
- click.echo(str(data))
535
+ _render_markdown_output(data)
485
536
  return
486
537
 
487
538
  if success_message:
488
539
  console.print(Text(f"[green]✅ {success_message}[/green]"))
489
540
 
490
541
  if panel_title:
491
- 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
+ )
492
549
  else:
493
550
  console.print(Text(f"[cyan]{title}:[/cyan]"))
494
551
  console.print(Pretty(data))
@@ -500,17 +557,7 @@ def output_result(
500
557
  # _PICK_THRESHOLD = int(os.getenv("AIP_PICK_THRESHOLD", "5") or "5")
501
558
 
502
559
 
503
- def output_list(
504
- ctx,
505
- items: list[Any],
506
- title: str,
507
- columns: list[tuple],
508
- transform_func=None,
509
- ):
510
- """Display a list with fuzzy palette by default on TTY, Rich table as fallback."""
511
- fmt = _get_view(ctx)
512
-
513
- # Normalize rows
560
+ def _normalise_rows(items: list[Any], transform_func) -> list[dict[str, Any]]:
514
561
  try:
515
562
  rows: list[dict[str, Any]] = []
516
563
  for item in items:
@@ -524,106 +571,139 @@ def output_list(
524
571
  rows.append(item)
525
572
  else:
526
573
  rows.append({"value": item})
574
+ return rows
527
575
  except Exception:
528
- rows = []
576
+ return []
577
+
529
578
 
530
- # Mask secrets (apply before any view)
579
+ def _mask_rows_if_configured(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
531
580
  mask_fields = _resolve_mask_fields()
532
- if mask_fields:
533
- try:
534
- rows = [_maybe_mask_row(r, mask_fields) for r in rows]
535
- except Exception:
536
- 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)
537
665
 
538
- # JSON view bypasses any UI
539
666
  if fmt == "json":
540
- 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
+ )
541
672
  click.echo(json.dumps(data, indent=2, default=str))
542
673
  return
543
674
 
544
- # Plain view - simple text output
545
675
  if fmt == "plain":
546
- if not rows:
547
- click.echo(f"No {title.lower()} found.")
548
- return
549
- for row in rows:
550
- row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
551
- click.echo(row_str)
676
+ _render_plain_list(rows, title, columns)
552
677
  return
553
678
 
554
- # Markdown view - table format
555
679
  if fmt == "md":
556
- if not rows:
557
- click.echo(f"No {title.lower()} found.")
558
- return
559
- # Create markdown table
560
- headers = [header for _, header, _, _ in columns]
561
- click.echo(f"| {' | '.join(headers)} |")
562
- click.echo(f"| {' | '.join('---' for _ in headers)} |")
563
- for row in rows:
564
- row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
565
- click.echo(f"| {row_str} |")
680
+ _render_markdown_list(rows, title, columns)
566
681
  return
567
682
 
568
683
  if not items:
569
684
  console.print(Text(f"[yellow]No {title.lower()} found.[/yellow]"))
570
685
  return
571
686
 
572
- # Sort by name by default (unless disabled)
573
- if (
574
- os.getenv("AIP_TABLE_NO_SORT", "0") not in ("1", "true", "on")
575
- and rows
576
- and isinstance(rows[0], dict)
577
- and "name" in rows[0]
578
- ):
687
+ if _should_sort_rows(rows):
579
688
  try:
580
689
  rows = sorted(rows, key=lambda r: str(r.get("name", "")).lower())
581
690
  except Exception:
582
691
  pass
583
692
 
584
- # === Fuzzy palette is the default for TTY lists ===
585
- picked: dict[str, Any] | None = None
586
- if console.is_terminal and os.isatty(1):
587
- picked = _fuzzy_pick(rows, columns, title)
588
-
693
+ picked = (
694
+ _fuzzy_pick(rows, columns, title)
695
+ if console.is_terminal and os.isatty(1)
696
+ else None
697
+ )
589
698
  if picked:
590
- # Show a focused, single-row table (easy to copy ID/name)
591
- table = Table(title=title, box=box.ROUNDED, expand=True)
592
- for _key, header, style, width in columns:
593
- table.add_column(header, style=style, width=width)
699
+ table = _create_table(columns, title)
594
700
  table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
595
-
596
701
  console.print(table)
597
702
  console.print(Text("\n[dim]Tip: use `aip agents get <ID>` for details[/dim]"))
598
703
  return
599
704
 
600
- # Build full table
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)
604
- for row in rows:
605
- table.add_row(*[str(row.get(key, "N/A")) for key, _, _, _ in columns])
606
-
607
- footer = Text(f"\n[dim]Total {len(rows)} items[/dim]")
608
- content = Group(table, footer)
609
-
610
- # Auto paging when long
611
- is_tty = console.is_terminal and os.isatty(1)
612
- pager_env = (os.getenv("AIP_PAGER", "auto") or "auto").lower()
613
-
614
- if pager_env in ("0", "off", "false"):
615
- should_page = False
616
- elif pager_env in ("1", "on", "true"):
617
- should_page = is_tty
618
- else:
619
- try:
620
- term_h = console.size.height or 24
621
- approx_lines = 5 + len(rows)
622
- should_page = is_tty and (approx_lines >= term_h * 0.5)
623
- except Exception:
624
- should_page = is_tty
625
-
626
- 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)):
627
707
  ansi = _render_ansi(content)
628
708
  if not _page_with_system_pager(ansi):
629
709
  with console.pager(styles=True):
@@ -701,12 +781,12 @@ def coerce_to_row(item, keys: list[str]) -> dict[str, Any]:
701
781
 
702
782
 
703
783
  def build_renderer(
704
- ctx,
784
+ _ctx,
705
785
  *,
706
786
  save_path,
707
787
  theme="dark",
708
788
  verbose=False,
709
- tty_enabled=True,
789
+ _tty_enabled=True,
710
790
  live=None,
711
791
  snapshots=None,
712
792
  ):
@@ -755,30 +835,16 @@ def build_renderer(
755
835
  return renderer, working_console
756
836
 
757
837
 
758
- def _fuzzy_pick_for_resources(
759
- resources: list[Any], resource_type: str, search_term: str
760
- ) -> Any | None:
761
- """
762
- Fuzzy picker for resource objects, similar to _fuzzy_pick but without column dependencies.
763
-
764
- Args:
765
- resources: List of resource objects to choose from
766
- resource_type: Type of resource (e.g., "agent", "tool")
767
- search_term: The search term that led to multiple matches
768
-
769
- Returns:
770
- Selected resource object or None if cancelled/no selection
771
- """
772
- if not (_HAS_PTK and console.is_terminal and os.isatty(1)):
773
- return None
774
-
775
- # Build display corpus and a reverse map
838
+ def _build_resource_labels(resources: list[Any]) -> tuple[list[str], dict[str, Any]]:
839
+ """Build unique display labels for resources."""
776
840
  labels = []
777
841
  by_label: dict[str, Any] = {}
842
+
778
843
  for resource in resources:
779
844
  name = getattr(resource, "name", "Unknown")
780
845
  _id = getattr(resource, "id", "Unknown")
781
- # Create a display label similar to _row_display
846
+
847
+ # Create display label
782
848
  label_parts = []
783
849
  if name and name != "Unknown":
784
850
  label_parts.append(name)
@@ -792,39 +858,35 @@ def _fuzzy_pick_for_resources(
792
858
  while f"{base} #{i}" in by_label:
793
859
  i += 1
794
860
  label = f"{base} #{i}"
861
+
795
862
  labels.append(label)
796
863
  by_label[label] = resource
797
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
+
798
888
  # Create fuzzy completer
799
- class FuzzyCompleter:
800
- def __init__(self, words: list[str]):
801
- self.words = words
802
-
803
- def get_completions(self, document, complete_event):
804
- word = document.get_word_before_cursor()
805
- if not word:
806
- return
807
-
808
- word_lower = word.lower()
809
- for label in self.words:
810
- label_lower = label.lower()
811
- # Fuzzy match logic
812
- if self._fuzzy_match(word_lower, label_lower):
813
- yield Completion(label, start_position=-len(word))
814
-
815
- def _fuzzy_match(self, search: str, target: str) -> bool:
816
- if not search:
817
- return True
818
-
819
- search_idx = 0
820
- for char in target:
821
- if search_idx < len(search) and search[search_idx] == char:
822
- search_idx += 1
823
- if search_idx == len(search):
824
- return True
825
- return False
826
-
827
- completer = FuzzyCompleter(labels)
889
+ completer = _FuzzyCompleter(labels)
828
890
 
829
891
  try:
830
892
  answer = prompt(
@@ -836,27 +898,7 @@ def _fuzzy_pick_for_resources(
836
898
  except (KeyboardInterrupt, EOFError):
837
899
  return None
838
900
 
839
- if not answer:
840
- return None
841
-
842
- # Exact label match
843
- if answer in by_label:
844
- return by_label[answer]
845
-
846
- # Fuzzy search fallback
847
- best_match = None
848
- best_score = -1
849
-
850
- for label in labels:
851
- score = _fuzzy_score(answer.lower(), label.lower())
852
- if score > best_score:
853
- best_score = score
854
- best_match = label
855
-
856
- if best_match and best_score > 0:
857
- return by_label[best_match]
858
-
859
- return None
901
+ return _perform_fuzzy_search(answer, labels, by_label) if answer else None
860
902
 
861
903
 
862
904
  def resolve_resource(
@@ -945,9 +987,8 @@ def handle_ambiguous_resource(
945
987
  f"[yellow]Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s found matching '{ref.replace('{', '{{').replace('}', '}}')}':[/yellow]"
946
988
  )
947
989
  )
948
- table = Table(
990
+ table = AIPTable(
949
991
  title=f"Select {resource_type.replace('{', '{{').replace('}', '}}').title()}",
950
- box=box.ROUNDED,
951
992
  )
952
993
  table.add_column("#", style="dim", width=3)
953
994
  table.add_column("ID", style="dim", width=36)