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