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