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.
- glaip_sdk/__init__.py +5 -5
- glaip_sdk/branding.py +146 -0
- glaip_sdk/cli/__init__.py +1 -1
- glaip_sdk/cli/agent_config.py +82 -0
- glaip_sdk/cli/commands/__init__.py +3 -3
- glaip_sdk/cli/commands/agents.py +786 -271
- glaip_sdk/cli/commands/configure.py +19 -19
- glaip_sdk/cli/commands/mcps.py +151 -141
- glaip_sdk/cli/commands/models.py +1 -1
- glaip_sdk/cli/commands/tools.py +252 -178
- glaip_sdk/cli/display.py +244 -0
- glaip_sdk/cli/io.py +106 -0
- glaip_sdk/cli/main.py +27 -20
- glaip_sdk/cli/resolution.py +59 -0
- glaip_sdk/cli/utils.py +372 -213
- glaip_sdk/cli/validators.py +235 -0
- glaip_sdk/client/__init__.py +3 -224
- glaip_sdk/client/agents.py +632 -171
- glaip_sdk/client/base.py +66 -4
- glaip_sdk/client/main.py +226 -0
- glaip_sdk/client/mcps.py +143 -18
- glaip_sdk/client/tools.py +327 -104
- glaip_sdk/config/constants.py +10 -1
- glaip_sdk/models.py +43 -3
- glaip_sdk/rich_components.py +29 -0
- glaip_sdk/utils/__init__.py +18 -171
- glaip_sdk/utils/agent_config.py +181 -0
- glaip_sdk/utils/client_utils.py +159 -79
- glaip_sdk/utils/display.py +100 -0
- glaip_sdk/utils/general.py +94 -0
- glaip_sdk/utils/import_export.py +140 -0
- glaip_sdk/utils/rendering/formatting.py +6 -1
- glaip_sdk/utils/rendering/renderer/__init__.py +67 -8
- glaip_sdk/utils/rendering/renderer/base.py +340 -247
- glaip_sdk/utils/rendering/renderer/debug.py +3 -2
- glaip_sdk/utils/rendering/renderer/panels.py +11 -10
- glaip_sdk/utils/rendering/steps.py +1 -1
- glaip_sdk/utils/resource_refs.py +192 -0
- glaip_sdk/utils/rich_utils.py +29 -0
- glaip_sdk/utils/serialization.py +285 -0
- glaip_sdk/utils/validation.py +273 -0
- {glaip_sdk-0.0.3.dist-info → glaip_sdk-0.0.5.dist-info}/METADATA +6 -5
- glaip_sdk-0.0.5.dist-info/RECORD +55 -0
- glaip_sdk/cli/commands/init.py +0 -177
- glaip_sdk-0.0.3.dist-info/RECORD +0 -40
- {glaip_sdk-0.0.3.dist-info → glaip_sdk-0.0.5.dist-info}/WHEEL +0 -0
- {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(
|
|
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(
|
|
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(
|
|
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":
|
|
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(
|
|
234
|
-
"""Recursively mask sensitive fields in
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
250
|
-
return
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
332
|
+
class _FuzzyCompleter:
|
|
333
|
+
"""Fuzzy completer for prompt_toolkit."""
|
|
387
334
|
|
|
388
|
-
|
|
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
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
576
|
+
return []
|
|
577
|
+
|
|
539
578
|
|
|
540
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
611
|
-
|
|
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
|
-
|
|
784
|
+
_ctx,
|
|
715
785
|
*,
|
|
716
786
|
save_path,
|
|
717
787
|
theme="dark",
|
|
718
788
|
verbose=False,
|
|
719
|
-
|
|
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
|
-
#
|
|
741
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
986
|
+
Text(
|
|
987
|
+
f"[yellow]Multiple {resource_type.replace('{', '{{').replace('}', '}}')}s found matching '{ref.replace('{', '{{').replace('}', '}}')}':[/yellow]"
|
|
988
|
+
)
|
|
829
989
|
)
|
|
830
|
-
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)
|