glaip-sdk 0.0.14__py3-none-any.whl → 0.0.16__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 (44) hide show
  1. glaip_sdk/branding.py +27 -1
  2. glaip_sdk/cli/commands/agents.py +27 -20
  3. glaip_sdk/cli/commands/configure.py +39 -50
  4. glaip_sdk/cli/commands/mcps.py +2 -6
  5. glaip_sdk/cli/commands/models.py +1 -1
  6. glaip_sdk/cli/commands/tools.py +1 -3
  7. glaip_sdk/cli/config.py +42 -0
  8. glaip_sdk/cli/context.py +142 -0
  9. glaip_sdk/cli/display.py +92 -26
  10. glaip_sdk/cli/main.py +141 -124
  11. glaip_sdk/cli/masking.py +148 -0
  12. glaip_sdk/cli/mcp_validators.py +2 -2
  13. glaip_sdk/cli/pager.py +272 -0
  14. glaip_sdk/cli/parsers/json_input.py +2 -2
  15. glaip_sdk/cli/resolution.py +12 -10
  16. glaip_sdk/cli/slash/agent_session.py +7 -0
  17. glaip_sdk/cli/slash/prompt.py +21 -2
  18. glaip_sdk/cli/slash/session.py +15 -21
  19. glaip_sdk/cli/update_notifier.py +8 -2
  20. glaip_sdk/cli/utils.py +99 -369
  21. glaip_sdk/client/_agent_payloads.py +504 -0
  22. glaip_sdk/client/agents.py +194 -551
  23. glaip_sdk/client/base.py +92 -20
  24. glaip_sdk/client/main.py +6 -0
  25. glaip_sdk/client/run_rendering.py +275 -0
  26. glaip_sdk/config/constants.py +3 -0
  27. glaip_sdk/exceptions.py +15 -0
  28. glaip_sdk/models.py +5 -0
  29. glaip_sdk/payload_schemas/__init__.py +19 -0
  30. glaip_sdk/payload_schemas/agent.py +87 -0
  31. glaip_sdk/rich_components.py +12 -0
  32. glaip_sdk/utils/client_utils.py +12 -0
  33. glaip_sdk/utils/import_export.py +2 -2
  34. glaip_sdk/utils/rendering/formatting.py +5 -0
  35. glaip_sdk/utils/rendering/models.py +22 -0
  36. glaip_sdk/utils/rendering/renderer/base.py +9 -1
  37. glaip_sdk/utils/rendering/renderer/panels.py +0 -1
  38. glaip_sdk/utils/rendering/steps.py +59 -0
  39. glaip_sdk/utils/serialization.py +24 -3
  40. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/METADATA +1 -1
  41. glaip_sdk-0.0.16.dist-info/RECORD +72 -0
  42. glaip_sdk-0.0.14.dist-info/RECORD +0 -64
  43. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/WHEEL +0 -0
  44. {glaip_sdk-0.0.14.dist-info → glaip_sdk-0.0.16.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/utils.py CHANGED
@@ -2,23 +2,17 @@
2
2
 
3
3
  Authors:
4
4
  Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
6
  """
6
7
 
7
8
  from __future__ import annotations
8
9
 
9
- import io
10
10
  import json
11
11
  import logging
12
12
  import os
13
- import platform
14
- import shlex
15
- import shutil
16
- import subprocess
17
13
  import sys
18
- import tempfile
19
14
  from collections.abc import Callable
20
15
  from contextlib import AbstractContextManager, nullcontext
21
- from pathlib import Path
22
16
  from typing import TYPE_CHECKING, Any
23
17
 
24
18
  import click
@@ -27,6 +21,8 @@ from rich.markdown import Markdown
27
21
  from rich.pretty import Pretty
28
22
  from rich.text import Text
29
23
 
24
+ from glaip_sdk.rich_components import AIPPanel
25
+
30
26
  # Optional interactive deps (fuzzy palette)
31
27
  try:
32
28
  from prompt_toolkit.completion import Completion
@@ -43,8 +39,16 @@ except Exception: # pragma: no cover - optional dependency
43
39
 
44
40
  if TYPE_CHECKING: # pragma: no cover - import-only during type checking
45
41
  from glaip_sdk import Client
46
- from glaip_sdk.cli.commands.configure import load_config
47
- from glaip_sdk.rich_components import AIPPanel, AIPTable
42
+ from glaip_sdk.cli import masking, pager
43
+ from glaip_sdk.cli.config import load_config
44
+ from glaip_sdk.cli.context import (
45
+ _get_view,
46
+ get_ctx_value,
47
+ )
48
+ from glaip_sdk.cli.context import (
49
+ detect_export_format as _detect_export_format,
50
+ )
51
+ from glaip_sdk.rich_components import AIPTable
48
52
  from glaip_sdk.utils import is_uuid
49
53
  from glaip_sdk.utils.rendering.renderer import (
50
54
  CapturingConsole,
@@ -53,180 +57,61 @@ from glaip_sdk.utils.rendering.renderer import (
53
57
  )
54
58
 
55
59
  console = Console()
60
+ pager.console = console
56
61
  logger = logging.getLogger("glaip_sdk.cli.utils")
57
62
 
58
63
 
59
64
  # ----------------------------- Context helpers ---------------------------- #
60
65
 
61
66
 
62
- def get_ctx_value(ctx: Any, key: str, default: Any = None) -> Any:
63
- """Safely resolve a value from click's context object."""
64
- if ctx is None:
65
- return default
66
-
67
- obj = getattr(ctx, "obj", None)
68
- if obj is None:
69
- return default
70
-
71
- if isinstance(obj, dict):
72
- return obj.get(key, default)
73
-
74
- getter = getattr(obj, "get", None)
75
- if callable(getter):
76
- try:
77
- return getter(key, default)
78
- except TypeError:
79
- return default
80
-
81
- return getattr(obj, key, default) if hasattr(obj, key) else default
82
-
83
-
84
- # ----------------------------- Pager helpers ----------------------------- #
85
-
86
-
87
- def _prepare_pager_env(
88
- clear_on_exit: bool = True,
89
- ) -> None: # pragma: no cover - terminal UI setup
90
- """
91
- Configure LESS flags for a predictable, high-quality UX:
92
- -R : pass ANSI color escapes
93
- -S : chop long lines (horizontal scroll with ←/→)
94
- (No -F, no -X) so we open a full-screen pager and clear on exit.
95
- Toggle wrapping with AIP_PAGER_WRAP=1 to drop -S.
96
- Power users can override via AIP_LESS_FLAGS.
97
- """
98
- os.environ.pop("LESSSECURE", None)
99
- if os.getenv("LESS") is None:
100
- want_wrap = os.getenv("AIP_PAGER_WRAP", "0") == "1"
101
- base = "-R" if want_wrap else "-RS"
102
- default_flags = base if clear_on_exit else (base + "FX")
103
- os.environ["LESS"] = os.getenv("AIP_LESS_FLAGS", default_flags)
104
-
105
-
106
- def _render_ansi(
107
- renderable: Any,
108
- ) -> str:
109
- """Render a Rich renderable to an ANSI string suitable for piping to 'less'."""
110
- buf = io.StringIO()
111
- tmp_console = Console(
112
- file=buf,
113
- force_terminal=True,
114
- color_system=console.color_system or "auto",
115
- width=console.size.width or 100,
116
- legacy_windows=False,
117
- soft_wrap=False,
118
- record=False,
119
- )
120
- tmp_console.print(renderable)
121
- return buf.getvalue()
122
-
123
-
124
- def _pager_header() -> str:
125
- v = (os.getenv("AIP_PAGER_HEADER", "1") or "1").strip().lower()
126
- if v in {"0", "false", "off"}:
127
- return ""
128
- return "\n".join(
129
- [
130
- "TABLE VIEW — ↑/↓ PgUp/PgDn, ←/→ horiz scroll (with -S), /search, n/N next/prev, h help, q quit",
131
- "───────────────────────────────────────────────────────────────────────────────────────────────",
132
- "",
133
- ]
134
- )
135
-
136
-
137
- def _should_use_pager() -> bool:
138
- """Check if we should attempt to use a system pager."""
139
- if not (console.is_terminal and os.isatty(1)):
140
- return False
141
- if (os.getenv("TERM") or "").lower() == "dumb":
142
- return False
143
- return True
144
-
145
-
146
- def _resolve_pager_command() -> tuple[list[str] | None, str | None]:
147
- """Resolve the pager command and path to use."""
148
- pager_cmd = None
149
- pager_env = os.getenv("PAGER")
150
- if pager_env:
151
- parts = shlex.split(pager_env)
152
- if parts and os.path.basename(parts[0]).lower() == "less":
153
- pager_cmd = parts
154
-
155
- less_path = shutil.which("less")
156
- return pager_cmd, less_path
157
-
67
+ def detect_export_format(file_path: str | os.PathLike[str]) -> str:
68
+ """Backward-compatible proxy to `glaip_sdk.cli.context.detect_export_format`."""
69
+ return _detect_export_format(file_path)
158
70
 
159
- def _run_less_pager(
160
- pager_cmd: list[str] | None, less_path: str | None, tmp_path: str
161
- ) -> None:
162
- """Run less pager with appropriate command and flags."""
163
- if pager_cmd:
164
- subprocess.run([*pager_cmd, tmp_path], check=False)
165
- else:
166
- flags = os.getenv("LESS", "-RS").split()
167
- subprocess.run([less_path, *flags, tmp_path], check=False)
168
71
 
169
-
170
- def _run_more_pager(tmp_path: str) -> None:
171
- """Run more pager as fallback."""
172
- more_path = shutil.which("more")
173
- if more_path:
174
- subprocess.run([more_path, tmp_path], check=False)
175
- else:
176
- raise FileNotFoundError("more command not found")
177
-
178
-
179
- def _run_pager_with_temp_file(
180
- pager_runner: Callable[[str], None], ansi_text: str
181
- ) -> bool:
182
- """Run a pager using a temporary file containing the content."""
183
- _prepare_pager_env(clear_on_exit=True)
184
- with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp:
185
- tmp.write(_pager_header())
186
- tmp.write(ansi_text)
187
- tmp_path = tmp.name
188
- try:
189
- pager_runner(tmp_path)
190
- return True
191
- except Exception:
192
- # If pager fails, return False to indicate paging was not successful
193
- return False
194
- finally:
72
+ def in_slash_mode(ctx: click.Context | None = None) -> bool:
73
+ """Return True when running inside the slash command palette."""
74
+ if ctx is None:
195
75
  try:
196
- os.unlink(tmp_path)
197
- except Exception:
198
- pass
76
+ ctx = click.get_current_context(silent=True)
77
+ except RuntimeError:
78
+ ctx = None
199
79
 
200
-
201
- def _page_with_system_pager(
202
- ansi_text: str,
203
- ) -> bool: # pragma: no cover - spawns real pager
204
- """Prefer 'less' with a temp file so stdin remains the TTY."""
205
- if not _should_use_pager():
80
+ if ctx is None:
206
81
  return False
207
82
 
208
- pager_cmd, less_path = _resolve_pager_command()
83
+ obj = getattr(ctx, "obj", None)
84
+ if isinstance(obj, dict):
85
+ return bool(obj.get("_slash_session"))
209
86
 
210
- if pager_cmd or less_path:
211
- return _run_pager_with_temp_file(
212
- lambda tmp_path: _run_less_pager(pager_cmd, less_path, tmp_path), ansi_text
213
- )
87
+ return bool(getattr(obj, "_slash_session", False))
214
88
 
215
- # Windows 'more' is poor with ANSI; let Rich fallback handle it
216
- if platform.system().lower().startswith("win"):
217
- return False
218
89
 
219
- # POSIX 'more' fallback (may or may not honor ANSI)
220
- return _run_pager_with_temp_file(_run_more_pager, ansi_text)
90
+ def command_hint(
91
+ cli_command: str | None,
92
+ slash_command: str | None = None,
93
+ *,
94
+ ctx: click.Context | None = None,
95
+ ) -> str | None:
96
+ """Return the appropriate command string for the current mode.
221
97
 
98
+ Args:
99
+ cli_command: Command string without the ``aip`` prefix (e.g., ``"status"``).
100
+ slash_command: Slash command counterpart (e.g., ``"status"`` or ``"/status"``).
101
+ ctx: Optional Click context override.
222
102
 
223
- def _get_view(ctx: Any) -> str:
224
- view = get_ctx_value(ctx, "view")
225
- if view:
226
- return view
103
+ Returns:
104
+ The formatted command string for the active mode, or ``None`` when no
105
+ equivalent command exists in that mode.
106
+ """
107
+ if in_slash_mode(ctx):
108
+ if not slash_command:
109
+ return None
110
+ return slash_command if slash_command.startswith("/") else f"/{slash_command}"
227
111
 
228
- fallback = get_ctx_value(ctx, "format")
229
- return fallback or "rich"
112
+ if not cli_command:
113
+ return None
114
+ return f"aip {cli_command}"
230
115
 
231
116
 
232
117
  def spinner_context(
@@ -238,7 +123,6 @@ def spinner_context(
238
123
  spinner_style: str = "cyan",
239
124
  ) -> AbstractContextManager[Any]:
240
125
  """Return a context manager that renders a spinner when appropriate."""
241
-
242
126
  active_console = console_override or console
243
127
  if not _can_use_spinner(ctx, active_console):
244
128
  return nullcontext()
@@ -252,7 +136,6 @@ def spinner_context(
252
136
 
253
137
  def _can_use_spinner(ctx: Any | None, active_console: Console) -> bool:
254
138
  """Check if spinner output is allowed in the current environment."""
255
-
256
139
  if ctx is not None:
257
140
  tty_enabled = bool(get_ctx_value(ctx, "tty", True))
258
141
  view = (_get_view(ctx) or "rich").lower()
@@ -267,7 +150,6 @@ def _can_use_spinner(ctx: Any | None, active_console: Console) -> bool:
267
150
 
268
151
  def _stream_supports_tty(stream: Any) -> bool:
269
152
  """Return True if the provided stream can safely render a spinner."""
270
-
271
153
  target = stream if hasattr(stream, "isatty") else sys.stdout
272
154
  try:
273
155
  return bool(target.isatty())
@@ -277,7 +159,6 @@ def _stream_supports_tty(stream: Any) -> bool:
277
159
 
278
160
  def update_spinner(status_indicator: Any | None, message: str) -> None:
279
161
  """Update spinner text when a status indicator is active."""
280
-
281
162
  if status_indicator is None:
282
163
  return
283
164
 
@@ -289,7 +170,6 @@ def update_spinner(status_indicator: Any | None, message: str) -> None:
289
170
 
290
171
  def stop_spinner(status_indicator: Any | None) -> None:
291
172
  """Stop an active spinner safely."""
292
-
293
173
  if status_indicator is None:
294
174
  return
295
175
 
@@ -336,9 +216,12 @@ def get_client(ctx: Any) -> Client: # pragma: no cover
336
216
  }
337
217
 
338
218
  if not config.get("api_url") or not config.get("api_key"):
339
- raise click.ClickException(
340
- "Missing api_url/api_key. Run `aip configure` or set AIP_* env vars."
341
- )
219
+ configure_hint = command_hint("configure", slash_command="login", ctx=ctx)
220
+ actions = []
221
+ if configure_hint:
222
+ actions.append(f"Run `{configure_hint}`")
223
+ actions.append("set AIP_* env vars")
224
+ raise click.ClickException(f"Missing api_url/api_key. {' or '.join(actions)}.")
342
225
 
343
226
  return Client(
344
227
  api_url=config.get("api_url"),
@@ -349,61 +232,6 @@ def get_client(ctx: Any) -> Client: # pragma: no cover
349
232
 
350
233
  # ----------------------------- Secret masking ---------------------------- #
351
234
 
352
- _DEFAULT_MASK_FIELDS = {
353
- "api_key",
354
- "apikey",
355
- "token",
356
- "access_token",
357
- "secret",
358
- "client_secret",
359
- "password",
360
- "private_key",
361
- "bearer",
362
- }
363
-
364
-
365
- def _mask_value(v: Any) -> str:
366
- s = str(v)
367
- if len(s) <= 8:
368
- return "••••"
369
- return f"{s[:4]}••••••••{s[-4:]}"
370
-
371
-
372
- def _mask_any(value: Any, mask_fields: set[str]) -> Any:
373
- """Recursively mask sensitive fields in mappings / lists."""
374
-
375
- if isinstance(value, dict):
376
- masked: dict[Any, Any] = {}
377
- for key, raw in value.items():
378
- if isinstance(key, str) and key.lower() in mask_fields and raw is not None:
379
- masked[key] = _mask_value(raw)
380
- else:
381
- masked[key] = _mask_any(raw, mask_fields)
382
- return masked
383
-
384
- if isinstance(value, list):
385
- return [_mask_any(item, mask_fields) for item in value]
386
-
387
- return value
388
-
389
-
390
- def _maybe_mask_row(row: dict[str, Any], mask_fields: set[str]) -> dict[str, Any]:
391
- """Mask a single row (legacy function, now uses _mask_any)."""
392
- if not mask_fields:
393
- return row
394
- return _mask_any(row, mask_fields)
395
-
396
-
397
- def _resolve_mask_fields() -> set[str]:
398
- if os.getenv("AIP_MASK_OFF", "0") in ("1", "true", "on", "yes"):
399
- return set()
400
- env_fields = (os.getenv("AIP_MASK_FIELDS") or "").strip()
401
- if env_fields:
402
- parts = [p.strip().lower() for p in env_fields.split(",") if p.strip()]
403
- return set(parts)
404
- return set(_DEFAULT_MASK_FIELDS)
405
-
406
-
407
235
  # ----------------------------- Fuzzy palette ----------------------------- #
408
236
 
409
237
 
@@ -470,8 +298,8 @@ def _build_display_parts(
470
298
 
471
299
 
472
300
  def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
473
- """
474
- Build a compact text label for the palette.
301
+ """Build a compact text label for the palette.
302
+
475
303
  Prefers: name • type • framework • [id] (when available)
476
304
  Falls back to first 2 columns + [id].
477
305
  """
@@ -564,8 +392,8 @@ def _perform_fuzzy_search(
564
392
  def _fuzzy_pick(
565
393
  rows: list[dict[str, Any]], columns: list[tuple], title: str
566
394
  ) -> dict[str, Any] | None: # pragma: no cover - requires interactive prompt toolkit
567
- """
568
- Open a minimal fuzzy palette using prompt_toolkit.
395
+ """Open a minimal fuzzy palette using prompt_toolkit.
396
+
569
397
  Returns the selected row (dict) or None if cancelled/missing deps.
570
398
  """
571
399
  if not _check_fuzzy_pick_requirements():
@@ -634,8 +462,8 @@ def _calculate_length_bonus(search: str, target: str) -> int:
634
462
 
635
463
 
636
464
  def _fuzzy_score(search: str, target: str) -> int:
637
- """
638
- Calculate fuzzy match score.
465
+ """Calculate fuzzy match score.
466
+
639
467
  Higher score = better match.
640
468
  Returns -1 if no match possible.
641
469
  """
@@ -667,16 +495,6 @@ def _coerce_result_payload(result: Any) -> Any:
667
495
  return result
668
496
 
669
497
 
670
- def _apply_mask_if_configured(payload: Any) -> Any:
671
- mask_fields = _resolve_mask_fields()
672
- if not mask_fields:
673
- return payload
674
- try:
675
- return _mask_any(payload, mask_fields)
676
- except Exception:
677
- return payload
678
-
679
-
680
498
  def _ensure_displayable(payload: Any) -> Any:
681
499
  if isinstance(payload, dict | list | str | int | float | bool) or payload is None:
682
500
  return payload
@@ -708,12 +526,19 @@ def output_result(
708
526
  result: Any,
709
527
  title: str = "Result",
710
528
  panel_title: str | None = None,
711
- success_message: str | None = None,
712
529
  ) -> None:
530
+ """Output a result to the console with optional title.
531
+
532
+ Args:
533
+ ctx: Click context
534
+ result: Result data to output
535
+ title: Optional title for the output
536
+ panel_title: Optional Rich panel title for structured output
537
+ """
713
538
  fmt = _get_view(ctx)
714
539
 
715
540
  data = _coerce_result_payload(result)
716
- data = _apply_mask_if_configured(data)
541
+ data = masking.mask_payload(data)
717
542
  data = _ensure_displayable(data)
718
543
 
719
544
  if fmt == "json":
@@ -728,20 +553,12 @@ def output_result(
728
553
  _render_markdown_output(data)
729
554
  return
730
555
 
731
- if success_message:
732
- console.print(Text(f"[green]✅ {success_message}[/green]"))
733
-
556
+ renderable = Pretty(data)
734
557
  if panel_title:
735
- console.print(
736
- AIPPanel(
737
- Pretty(data),
738
- title=panel_title,
739
- border_style="blue",
740
- )
741
- )
558
+ console.print(AIPPanel(renderable, title=panel_title))
742
559
  else:
743
560
  console.print(Text(f"[cyan]{title}:[/cyan]"))
744
- console.print(Pretty(data))
561
+ console.print(renderable)
745
562
 
746
563
 
747
564
  # ----------------------------- List rendering ---------------------------- #
@@ -771,16 +588,6 @@ def _normalise_rows(
771
588
  return []
772
589
 
773
590
 
774
- def _mask_rows_if_configured(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
775
- mask_fields = _resolve_mask_fields()
776
- if not mask_fields:
777
- return rows
778
- try:
779
- return [_maybe_mask_row(row, mask_fields) for row in rows]
780
- except Exception:
781
- return rows
782
-
783
-
784
591
  def _render_plain_list(
785
592
  rows: list[dict[str, Any]], title: str, columns: list[tuple]
786
593
  ) -> None:
@@ -832,20 +639,6 @@ def _build_table_group(
832
639
  return Group(table, footer)
833
640
 
834
641
 
835
- def _should_page_output(row_count: int, is_tty: bool) -> bool:
836
- pager_env = (os.getenv("AIP_PAGER", "auto") or "auto").lower()
837
- if pager_env in ("0", "off", "false"):
838
- return False
839
- if pager_env in ("1", "on", "true"):
840
- return is_tty
841
- try:
842
- term_h = console.size.height or 24
843
- approx_lines = 5 + row_count
844
- return is_tty and (approx_lines >= term_h * 0.5)
845
- except Exception:
846
- return is_tty
847
-
848
-
849
642
  def _handle_json_output(items: list[Any], rows: list[dict[str, Any]]) -> None:
850
643
  """Handle JSON output format."""
851
644
  data = (
@@ -877,7 +670,6 @@ def _handle_empty_items(title: str) -> None:
877
670
 
878
671
  def _should_use_fuzzy_picker() -> bool:
879
672
  """Return True when the interactive fuzzy picker can be shown."""
880
-
881
673
  return console.is_terminal and os.isatty(1)
882
674
 
883
675
 
@@ -885,7 +677,6 @@ def _try_fuzzy_pick(
885
677
  rows: list[dict[str, Any]], columns: list[tuple], title: str
886
678
  ) -> dict[str, Any] | None:
887
679
  """Best-effort fuzzy selection; returns None if the picker fails."""
888
-
889
680
  if not _should_use_fuzzy_picker():
890
681
  return None
891
682
 
@@ -896,36 +687,34 @@ def _try_fuzzy_pick(
896
687
  return None
897
688
 
898
689
 
899
- def _resource_tip_command(title: str) -> str:
690
+ def _resource_tip_command(title: str) -> str | None:
900
691
  """Resolve the follow-up command hint for the given table title."""
901
-
902
692
  title_lower = title.lower()
903
693
  mapping = {
904
- "agent": "aip agents get",
905
- "tool": "aip tools get",
906
- "mcp": "aip mcps get",
907
- "model": "aip models list", # models only ship a list command
694
+ "agent": ("agents get", "agents"),
695
+ "tool": ("tools get", None),
696
+ "mcp": ("mcps get", None),
697
+ "model": ("models list", None), # models only ship a list command
908
698
  }
909
- for keyword, command in mapping.items():
699
+ for keyword, (cli_command, slash_command) in mapping.items():
910
700
  if keyword in title_lower:
911
- return command
912
- return "aip agents get"
701
+ return command_hint(cli_command, slash_command=slash_command)
702
+ return command_hint("agents get", slash_command="agents")
913
703
 
914
704
 
915
705
  def _print_selection_tip(title: str) -> None:
916
706
  """Print the contextual follow-up tip after a fuzzy selection."""
917
-
918
707
  tip_cmd = _resource_tip_command(title)
919
- console.print(
920
- Text.from_markup(f"\n[dim]Tip: use `{tip_cmd} <ID>` for details[/dim]")
921
- )
708
+ if tip_cmd:
709
+ console.print(
710
+ Text.from_markup(f"\n[dim]Tip: use `{tip_cmd} <ID>` for details[/dim]")
711
+ )
922
712
 
923
713
 
924
714
  def _handle_fuzzy_pick_selection(
925
715
  rows: list[dict[str, Any]], columns: list[tuple], title: str
926
716
  ) -> bool:
927
717
  """Handle fuzzy picker selection, returns True if selection was made."""
928
-
929
718
  picked = _try_fuzzy_pick(rows, columns, title)
930
719
  if picked is None:
931
720
  return False
@@ -947,14 +736,14 @@ def _handle_table_output(
947
736
  """Handle table output with paging."""
948
737
  content = _build_table_group(rows, columns, title)
949
738
  should_page = (
950
- _should_page_output(len(rows), console.is_terminal and os.isatty(1))
739
+ pager._should_page_output(len(rows), console.is_terminal and os.isatty(1))
951
740
  if use_pager is None
952
741
  else use_pager
953
742
  )
954
743
 
955
744
  if should_page:
956
- ansi = _render_ansi(content)
957
- if not _page_with_system_pager(ansi):
745
+ ansi = pager._render_ansi(content)
746
+ if not pager._page_with_system_pager(ansi):
958
747
  with console.pager(styles=True):
959
748
  console.print(content)
960
749
  else:
@@ -974,7 +763,7 @@ def output_list(
974
763
  """Display a list with optional fuzzy palette for quick selection."""
975
764
  fmt = _get_view(ctx)
976
765
  rows = _normalise_rows(items, transform_func)
977
- rows = _mask_rows_if_configured(rows)
766
+ rows = masking.mask_rows(rows)
978
767
 
979
768
  if fmt == "json":
980
769
  _handle_json_output(items, rows)
@@ -1004,50 +793,6 @@ def output_list(
1004
793
  _handle_table_output(rows, columns, title, use_pager=use_pager)
1005
794
 
1006
795
 
1007
- # ------------------------- Output flags decorator ------------------------ #
1008
-
1009
-
1010
- def _set_view(ctx: Any, _param: Any, value: str) -> None:
1011
- if not value:
1012
- return
1013
- ctx.ensure_object(dict)
1014
- ctx.obj["view"] = value
1015
-
1016
-
1017
- def _set_json(ctx: Any, _param: Any, value: bool) -> None:
1018
- if not value:
1019
- return
1020
- ctx.ensure_object(dict)
1021
- ctx.obj["view"] = "json"
1022
-
1023
-
1024
- def output_flags() -> Callable[[Callable[..., Any]], Callable[..., Any]]:
1025
- """Decorator to allow output format flags on any subcommand."""
1026
-
1027
- def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
1028
- f = click.option(
1029
- "--json",
1030
- "json_mode",
1031
- is_flag=True,
1032
- expose_value=False,
1033
- help="Shortcut for --view json",
1034
- callback=_set_json,
1035
- )(f)
1036
- f = click.option(
1037
- "-o",
1038
- "--output",
1039
- "--view",
1040
- "view_opt",
1041
- type=click.Choice(["rich", "plain", "json", "md"]),
1042
- expose_value=False,
1043
- help="Output format",
1044
- callback=_set_view,
1045
- )(f)
1046
- return f
1047
-
1048
- return decorator
1049
-
1050
-
1051
796
  # ------------------------- Ambiguity handling --------------------------- #
1052
797
 
1053
798
 
@@ -1084,14 +829,16 @@ def build_renderer(
1084
829
  """Build renderer and capturing console for CLI commands.
1085
830
 
1086
831
  Args:
1087
- ctx: Click context
1088
- save_path: Path to save output to (enables capturing)
1089
- theme: Color theme ("dark" or "light")
1090
- verbose: Whether to enable verbose mode
1091
- tty_enabled: Whether TTY is available
832
+ _ctx: Click context object for CLI operations.
833
+ save_path: Path to save output to (enables capturing console).
834
+ theme: Color theme ("dark" or "light").
835
+ verbose: Whether to enable verbose mode.
836
+ _tty_enabled: Whether TTY is available for interactive features.
837
+ live: Whether to enable live rendering mode (overrides verbose default).
838
+ snapshots: Whether to capture and store snapshots.
1092
839
 
1093
840
  Returns:
1094
- Tuple of (renderer, capturing_console)
841
+ Tuple of (renderer, capturing_console) for streaming output.
1095
842
  """
1096
843
  # Use capturing console if saving output
1097
844
  working_console = console
@@ -1170,8 +917,7 @@ def _build_resource_labels(resources: list[Any]) -> tuple[list[str], dict[str, A
1170
917
  def _fuzzy_pick_for_resources(
1171
918
  resources: list[Any], resource_type: str, _search_term: str
1172
919
  ) -> Any | None: # pragma: no cover - interactive selection helper
1173
- """
1174
- Fuzzy picker for resource objects, similar to _fuzzy_pick but without column dependencies.
920
+ """Fuzzy picker for resource objects, similar to _fuzzy_pick but without column dependencies.
1175
921
 
1176
922
  Args:
1177
923
  resources: List of resource objects to choose from
@@ -1400,19 +1146,3 @@ def handle_ambiguous_resource(
1400
1146
  else:
1401
1147
  # Re-raise cancellation exceptions
1402
1148
  raise
1403
-
1404
-
1405
- def detect_export_format(file_path: str | Path) -> str:
1406
- """Detect export format from file extension.
1407
-
1408
- Args:
1409
- file_path: Path to the export file
1410
-
1411
- Returns:
1412
- "yaml" if file extension is .yaml or .yml, "json" otherwise
1413
- """
1414
- path = Path(file_path)
1415
- if path.suffix.lower() in [".yaml", ".yml"]:
1416
- return "yaml"
1417
- else:
1418
- return "json"