glaip-sdk 0.6.15b2__py3-none-any.whl → 0.6.15b3__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 (154) hide show
  1. glaip_sdk/agents/__init__.py +27 -0
  2. glaip_sdk/agents/base.py +1196 -0
  3. glaip_sdk/cli/__init__.py +9 -0
  4. glaip_sdk/cli/account_store.py +540 -0
  5. glaip_sdk/cli/agent_config.py +78 -0
  6. glaip_sdk/cli/auth.py +699 -0
  7. glaip_sdk/cli/commands/__init__.py +5 -0
  8. glaip_sdk/cli/commands/accounts.py +746 -0
  9. glaip_sdk/cli/commands/agents.py +1509 -0
  10. glaip_sdk/cli/commands/common_config.py +104 -0
  11. glaip_sdk/cli/commands/configure.py +896 -0
  12. glaip_sdk/cli/commands/mcps.py +1356 -0
  13. glaip_sdk/cli/commands/models.py +69 -0
  14. glaip_sdk/cli/commands/tools.py +576 -0
  15. glaip_sdk/cli/commands/transcripts.py +755 -0
  16. glaip_sdk/cli/commands/update.py +61 -0
  17. glaip_sdk/cli/config.py +95 -0
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +150 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +355 -0
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +112 -0
  28. glaip_sdk/cli/main.py +615 -0
  29. glaip_sdk/cli/masking.py +136 -0
  30. glaip_sdk/cli/mcp_validators.py +287 -0
  31. glaip_sdk/cli/pager.py +266 -0
  32. glaip_sdk/cli/parsers/__init__.py +7 -0
  33. glaip_sdk/cli/parsers/json_input.py +177 -0
  34. glaip_sdk/cli/resolution.py +67 -0
  35. glaip_sdk/cli/rich_helpers.py +27 -0
  36. glaip_sdk/cli/slash/__init__.py +15 -0
  37. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  38. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  39. glaip_sdk/cli/slash/agent_session.py +285 -0
  40. glaip_sdk/cli/slash/prompt.py +256 -0
  41. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  42. glaip_sdk/cli/slash/session.py +1708 -0
  43. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  44. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  45. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  46. glaip_sdk/cli/slash/tui/loading.py +58 -0
  47. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  48. glaip_sdk/cli/transcript/__init__.py +31 -0
  49. glaip_sdk/cli/transcript/cache.py +536 -0
  50. glaip_sdk/cli/transcript/capture.py +329 -0
  51. glaip_sdk/cli/transcript/export.py +38 -0
  52. glaip_sdk/cli/transcript/history.py +815 -0
  53. glaip_sdk/cli/transcript/launcher.py +77 -0
  54. glaip_sdk/cli/transcript/viewer.py +374 -0
  55. glaip_sdk/cli/update_notifier.py +290 -0
  56. glaip_sdk/cli/utils.py +263 -0
  57. glaip_sdk/cli/validators.py +238 -0
  58. glaip_sdk/client/__init__.py +11 -0
  59. glaip_sdk/client/_agent_payloads.py +520 -0
  60. glaip_sdk/client/agent_runs.py +147 -0
  61. glaip_sdk/client/agents.py +1335 -0
  62. glaip_sdk/client/base.py +502 -0
  63. glaip_sdk/client/main.py +249 -0
  64. glaip_sdk/client/mcps.py +370 -0
  65. glaip_sdk/client/run_rendering.py +700 -0
  66. glaip_sdk/client/shared.py +21 -0
  67. glaip_sdk/client/tools.py +661 -0
  68. glaip_sdk/client/validators.py +198 -0
  69. glaip_sdk/config/constants.py +52 -0
  70. glaip_sdk/mcps/__init__.py +21 -0
  71. glaip_sdk/mcps/base.py +345 -0
  72. glaip_sdk/models/__init__.py +90 -0
  73. glaip_sdk/models/agent.py +47 -0
  74. glaip_sdk/models/agent_runs.py +116 -0
  75. glaip_sdk/models/common.py +42 -0
  76. glaip_sdk/models/mcp.py +33 -0
  77. glaip_sdk/models/tool.py +33 -0
  78. glaip_sdk/payload_schemas/__init__.py +7 -0
  79. glaip_sdk/payload_schemas/agent.py +85 -0
  80. glaip_sdk/registry/__init__.py +55 -0
  81. glaip_sdk/registry/agent.py +164 -0
  82. glaip_sdk/registry/base.py +139 -0
  83. glaip_sdk/registry/mcp.py +253 -0
  84. glaip_sdk/registry/tool.py +232 -0
  85. glaip_sdk/runner/__init__.py +59 -0
  86. glaip_sdk/runner/base.py +84 -0
  87. glaip_sdk/runner/deps.py +112 -0
  88. glaip_sdk/runner/langgraph.py +782 -0
  89. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  90. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  91. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  92. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  93. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  94. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  95. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  96. glaip_sdk/tools/__init__.py +22 -0
  97. glaip_sdk/tools/base.py +435 -0
  98. glaip_sdk/utils/__init__.py +86 -0
  99. glaip_sdk/utils/a2a/__init__.py +34 -0
  100. glaip_sdk/utils/a2a/event_processor.py +188 -0
  101. glaip_sdk/utils/agent_config.py +194 -0
  102. glaip_sdk/utils/bundler.py +267 -0
  103. glaip_sdk/utils/client.py +111 -0
  104. glaip_sdk/utils/client_utils.py +486 -0
  105. glaip_sdk/utils/datetime_helpers.py +58 -0
  106. glaip_sdk/utils/discovery.py +78 -0
  107. glaip_sdk/utils/display.py +135 -0
  108. glaip_sdk/utils/export.py +143 -0
  109. glaip_sdk/utils/general.py +61 -0
  110. glaip_sdk/utils/import_export.py +168 -0
  111. glaip_sdk/utils/import_resolver.py +492 -0
  112. glaip_sdk/utils/instructions.py +101 -0
  113. glaip_sdk/utils/rendering/__init__.py +115 -0
  114. glaip_sdk/utils/rendering/formatting.py +264 -0
  115. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  116. glaip_sdk/utils/rendering/layout/panels.py +156 -0
  117. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  118. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  119. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  120. glaip_sdk/utils/rendering/models.py +85 -0
  121. glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
  122. glaip_sdk/utils/rendering/renderer/base.py +1024 -0
  123. glaip_sdk/utils/rendering/renderer/config.py +27 -0
  124. glaip_sdk/utils/rendering/renderer/console.py +55 -0
  125. glaip_sdk/utils/rendering/renderer/debug.py +178 -0
  126. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  127. glaip_sdk/utils/rendering/renderer/stream.py +202 -0
  128. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  129. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  130. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  131. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  132. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  133. glaip_sdk/utils/rendering/state.py +204 -0
  134. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  135. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  136. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  137. glaip_sdk/utils/rendering/steps/format.py +176 -0
  138. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  139. glaip_sdk/utils/rendering/timing.py +36 -0
  140. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  141. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  142. glaip_sdk/utils/resource_refs.py +195 -0
  143. glaip_sdk/utils/run_renderer.py +41 -0
  144. glaip_sdk/utils/runtime_config.py +425 -0
  145. glaip_sdk/utils/serialization.py +424 -0
  146. glaip_sdk/utils/sync.py +142 -0
  147. glaip_sdk/utils/tool_detection.py +33 -0
  148. glaip_sdk/utils/validation.py +264 -0
  149. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/METADATA +1 -1
  150. glaip_sdk-0.6.15b3.dist-info/RECORD +160 -0
  151. glaip_sdk-0.6.15b2.dist-info/RECORD +0 -12
  152. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/WHEEL +0 -0
  153. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/entry_points.txt +0 -0
  154. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.15b3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,851 @@
1
+ """CLI output utilities: Table/console output utilities, list rendering.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ from collections.abc import Callable
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import click
18
+ import yaml
19
+ from rich.console import Console, Group
20
+ from rich.markdown import Markdown
21
+ from rich.syntax import Syntax
22
+
23
+ from glaip_sdk.branding import ACCENT_STYLE, SUCCESS_STYLE, WARNING_STYLE
24
+ from glaip_sdk.cli import display as cli_display, masking, pager
25
+ from glaip_sdk.cli.constants import LITERAL_STRING_THRESHOLD, TABLE_SORT_ENABLED
26
+ from glaip_sdk.cli.context import _get_view, detect_export_format as _detect_export_format
27
+ from glaip_sdk.cli.hints import command_hint
28
+ from glaip_sdk.cli.io import export_resource_to_file_with_validation
29
+ from glaip_sdk.cli.rich_helpers import markup_text, print_markup
30
+ from glaip_sdk.rich_components import AIPPanel, AIPTable
31
+ from glaip_sdk.utils import format_datetime, is_uuid
32
+ from .prompting import (
33
+ _fuzzy_pick,
34
+ _fuzzy_pick_for_resources,
35
+ _load_questionary_module,
36
+ _make_questionary_choice,
37
+ )
38
+ from .rendering import _spinner_stop, _spinner_update, spinner_context
39
+
40
+ _VERSION_MODULE_MISSING = object()
41
+ _version_module: Any | None = _VERSION_MODULE_MISSING
42
+
43
+ console = Console()
44
+ pager.console = console
45
+ logger = logging.getLogger("glaip_sdk.cli.core.output")
46
+ _version_logger = logging.getLogger("glaip_sdk.cli.version")
47
+ _WARNED_SDK_VERSION_FALLBACK = False
48
+
49
+
50
+ def _is_tty(fd: int) -> bool:
51
+ """Return True if the file descriptor is a valid TTY."""
52
+ try:
53
+ return os.isatty(fd)
54
+ except OSError:
55
+ return False
56
+
57
+
58
+ class _LiteralYamlDumper(yaml.SafeDumper):
59
+ """YAML dumper that emits literal scalars for multiline strings."""
60
+
61
+
62
+ def _literal_str_representer(dumper: yaml.Dumper, data: str) -> yaml.nodes.ScalarNode:
63
+ """Represent strings in YAML, using literal blocks for verbose values."""
64
+ needs_literal = "\n" in data or "\r" in data
65
+ if not needs_literal and LITERAL_STRING_THRESHOLD and len(data) >= LITERAL_STRING_THRESHOLD: # pragma: no cover
66
+ needs_literal = True
67
+
68
+ style = "|" if needs_literal else None
69
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style)
70
+
71
+
72
+ _LiteralYamlDumper.add_representer(str, _literal_str_representer)
73
+
74
+
75
+ def detect_export_format(file_path: str | os.PathLike[str]) -> str:
76
+ """Backward-compatible proxy to `glaip_sdk.cli.context.detect_export_format`."""
77
+ return _detect_export_format(file_path)
78
+
79
+
80
+ def format_size(num: int | None) -> str:
81
+ """Format byte counts using short human-friendly units.
82
+
83
+ Args:
84
+ num: Number of bytes to format (can be None or 0)
85
+
86
+ Returns:
87
+ Human-readable size string (e.g., "1.5KB", "2MB")
88
+ """
89
+ if not num or num <= 0:
90
+ return "0B"
91
+
92
+ units = ["B", "KB", "MB", "GB", "TB"]
93
+ value = float(num)
94
+ for unit in units:
95
+ if value < 1024 or unit == units[-1]:
96
+ if unit == "B" or value >= 100:
97
+ return f"{value:.0f}{unit}"
98
+ if value >= 10:
99
+ return f"{value:.1f}{unit}"
100
+ return f"{value:.2f}{unit}"
101
+ value /= 1024
102
+ return f"{value:.1f}TB" # pragma: no cover - defensive fallback
103
+
104
+
105
+ def parse_json_line(line: str) -> dict[str, Any] | None:
106
+ """Parse a JSON line into a dictionary payload.
107
+
108
+ Args:
109
+ line: JSON line string to parse
110
+
111
+ Returns:
112
+ Parsed dictionary or None if parsing fails or result is not a dict
113
+ """
114
+ line = line.strip()
115
+ if not line:
116
+ return None
117
+ try:
118
+ payload = json.loads(line)
119
+ except json.JSONDecodeError:
120
+ return None
121
+ return payload if isinstance(payload, dict) else None
122
+
123
+
124
+ def format_datetime_fields(
125
+ data: dict[str, Any], fields: tuple[str, ...] = ("created_at", "updated_at")
126
+ ) -> dict[str, Any]:
127
+ """Format datetime fields in a data dictionary for display.
128
+
129
+ Args:
130
+ data: Dictionary containing the data to format
131
+ fields: Tuple of field names to format (default: created_at, updated_at)
132
+
133
+ Returns:
134
+ New dictionary with formatted datetime fields
135
+ """
136
+ formatted = data.copy()
137
+ for field in fields:
138
+ if field in formatted:
139
+ formatted[field] = format_datetime(formatted[field])
140
+ return formatted
141
+
142
+
143
+ def fetch_resource_for_export(
144
+ ctx: Any,
145
+ resource: Any,
146
+ resource_type: str,
147
+ get_by_id_func: Callable[[str], Any],
148
+ console_override: Console | None = None,
149
+ ) -> Any:
150
+ """Fetch full resource details for export, handling errors gracefully.
151
+
152
+ Args:
153
+ ctx: Click context for spinner management
154
+ resource: Resource object to fetch details for
155
+ resource_type: Type of resource (e.g., "MCP", "Agent", "Tool")
156
+ get_by_id_func: Function to fetch resource by ID
157
+ console_override: Optional console override
158
+
159
+ Returns:
160
+ Resource object with full details, or original resource if fetch fails
161
+ """
162
+ active_console = console_override or console
163
+ resource_id = str(getattr(resource, "id", "")).strip()
164
+
165
+ if not resource_id:
166
+ return resource
167
+
168
+ try:
169
+ with spinner_context(
170
+ ctx,
171
+ f"[bold blue]Fetching {resource_type} details…[/bold blue]",
172
+ console_override=active_console,
173
+ ):
174
+ return get_by_id_func(resource_id)
175
+ except Exception:
176
+ # Return original resource if fetch fails
177
+ return resource
178
+
179
+
180
+ def handle_resource_export(
181
+ ctx: Any,
182
+ resource: Any,
183
+ export_path: Path,
184
+ resource_type: str,
185
+ get_by_id_func: Callable[[str], Any],
186
+ console_override: Console | None = None,
187
+ ) -> None:
188
+ """Handle resource export to file with format detection and error handling.
189
+
190
+ Args:
191
+ ctx: Click context for spinner management
192
+ resource: Resource object to export
193
+ export_path: Target file path (format detected from extension)
194
+ resource_type: Type of resource (e.g., "agent", "tool")
195
+ get_by_id_func: Function to fetch resource by ID
196
+ console_override: Optional console override
197
+ """
198
+ active_console = console_override or console
199
+
200
+ # Auto-detect format from file extension
201
+ detected_format = detect_export_format(export_path)
202
+
203
+ # Try to fetch full details for export
204
+ full_resource = fetch_resource_for_export(
205
+ ctx,
206
+ resource,
207
+ resource_type.capitalize(),
208
+ get_by_id_func,
209
+ console_override=active_console,
210
+ )
211
+
212
+ # Export the resource
213
+ try:
214
+ with spinner_context(
215
+ ctx,
216
+ f"[bold blue]Exporting {resource_type}…[/bold blue]",
217
+ console_override=active_console,
218
+ ):
219
+ export_resource_to_file_with_validation(full_resource, export_path, detected_format)
220
+ except Exception:
221
+ cli_display.handle_rich_output(
222
+ ctx,
223
+ markup_text(f"[{WARNING_STYLE}]⚠️ Failed to fetch full details, using available data[/]"),
224
+ )
225
+ # Fallback: export with available data
226
+ export_resource_to_file_with_validation(resource, export_path, detected_format)
227
+
228
+ print_markup(
229
+ f"[{SUCCESS_STYLE}]✅ {resource_type.capitalize()} exported to: {export_path} (format: {detected_format})[/]",
230
+ console=active_console,
231
+ )
232
+
233
+
234
+ def sdk_version() -> str:
235
+ """Return the current SDK version, warning if metadata is unavailable."""
236
+ global _WARNED_SDK_VERSION_FALLBACK, _version_module
237
+
238
+ if _version_module is _VERSION_MODULE_MISSING:
239
+ try:
240
+ from importlib import import_module # noqa: PLC0415
241
+
242
+ _version_module = import_module("glaip_sdk._version")
243
+ except Exception: # pragma: no cover - defensive fallback
244
+ _version_module = None
245
+
246
+ if _version_module is None:
247
+ if not _WARNED_SDK_VERSION_FALLBACK:
248
+ _version_logger.warning("Unable to resolve glaip-sdk version metadata; using fallback '0.0.0'.")
249
+ _WARNED_SDK_VERSION_FALLBACK = True
250
+ return "0.0.0"
251
+
252
+ version = getattr(_version_module, "__version__", None)
253
+ if isinstance(version, str) and version:
254
+ return version
255
+
256
+ # Use module-level flag to avoid repeated warnings
257
+ if not _WARNED_SDK_VERSION_FALLBACK:
258
+ _version_logger.warning("Unable to resolve glaip-sdk version metadata; using fallback '0.0.0'.")
259
+ _WARNED_SDK_VERSION_FALLBACK = True
260
+
261
+ return "0.0.0"
262
+
263
+
264
+ def _coerce_result_payload(result: Any) -> Any:
265
+ """Convert renderer outputs into plain dict/list structures when possible."""
266
+ try:
267
+ to_dict = getattr(result, "to_dict", None)
268
+ if callable(to_dict):
269
+ return to_dict()
270
+ except Exception:
271
+ return result
272
+ return result
273
+
274
+
275
+ def _ensure_displayable(payload: Any) -> Any:
276
+ """Best-effort coercion into JSON/str-safe payloads for console rendering."""
277
+ if isinstance(payload, (dict, list, str, int, float, bool)) or payload is None:
278
+ return payload
279
+
280
+ if hasattr(payload, "__dict__"):
281
+ try:
282
+ return dict(payload)
283
+ except Exception:
284
+ try:
285
+ return dict(payload.__dict__)
286
+ except Exception:
287
+ pass
288
+
289
+ try:
290
+ return str(payload)
291
+ except Exception:
292
+ return repr(payload)
293
+
294
+
295
+ def _render_markdown_output(data: Any) -> None:
296
+ """Render markdown output using Rich when available."""
297
+ try:
298
+ console.print(Markdown(str(data)))
299
+ except ImportError:
300
+ click.echo(str(data))
301
+
302
+
303
+ def _format_yaml_text(data: Any) -> str:
304
+ """Convert structured payloads to YAML for readability."""
305
+ try:
306
+ yaml_text = yaml.dump(
307
+ data,
308
+ sort_keys=False,
309
+ default_flow_style=False,
310
+ allow_unicode=True,
311
+ Dumper=_LiteralYamlDumper,
312
+ )
313
+ except Exception: # pragma: no cover - defensive YAML fallback
314
+ try:
315
+ return str(data)
316
+ except Exception: # pragma: no cover - defensive str fallback
317
+ return repr(data)
318
+
319
+ yaml_text = yaml_text.rstrip()
320
+ if yaml_text.endswith("..."): # pragma: no cover - defensive YAML cleanup
321
+ yaml_text = yaml_text[:-3].rstrip()
322
+ return yaml_text
323
+
324
+
325
+ def _build_yaml_renderable(data: Any) -> Any:
326
+ """Return a syntax-highlighted YAML renderable when possible."""
327
+ yaml_text = _format_yaml_text(data) or "# No data"
328
+ try:
329
+ return Syntax(yaml_text, "yaml", word_wrap=False)
330
+ except Exception: # pragma: no cover - defensive syntax highlighting fallback
331
+ return yaml_text
332
+
333
+
334
+ def output_result(
335
+ ctx: Any,
336
+ result: Any,
337
+ title: str = "Result",
338
+ panel_title: str | None = None,
339
+ ) -> None:
340
+ """Output a result to the console with optional title.
341
+
342
+ Args:
343
+ ctx: Click context
344
+ result: Result data to output
345
+ title: Optional title for the output
346
+ panel_title: Optional Rich panel title for structured output
347
+ """
348
+ fmt = _get_view(ctx)
349
+
350
+ data = _coerce_result_payload(result)
351
+ data = masking.mask_payload(data)
352
+ data = _ensure_displayable(data)
353
+
354
+ if fmt == "json":
355
+ click.echo(json.dumps(data, indent=2, default=str))
356
+ return
357
+
358
+ if fmt == "plain":
359
+ click.echo(str(data))
360
+ return
361
+
362
+ if fmt == "md":
363
+ _render_markdown_output(data)
364
+ return
365
+
366
+ renderable = _build_yaml_renderable(data)
367
+ if panel_title:
368
+ console.print(AIPPanel(renderable, title=panel_title))
369
+ else:
370
+ console.print(markup_text(f"[{ACCENT_STYLE}]{title}:[/]"))
371
+ console.print(renderable)
372
+
373
+
374
+ def _normalise_rows(items: list[Any], transform_func: Callable[[Any], dict[str, Any]] | None) -> list[dict[str, Any]]:
375
+ """Convert heterogeneous item lists into table rows."""
376
+ try:
377
+ rows: list[dict[str, Any]] = []
378
+ for item in items:
379
+ if transform_func:
380
+ rows.append(transform_func(item))
381
+ elif hasattr(item, "to_dict"):
382
+ rows.append(item.to_dict())
383
+ elif hasattr(item, "__dict__"):
384
+ rows.append(vars(item))
385
+ elif isinstance(item, dict):
386
+ rows.append(item)
387
+ else:
388
+ rows.append({"value": item})
389
+ return rows
390
+ except Exception:
391
+ return []
392
+
393
+
394
+ def _render_plain_list(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
395
+ """Display tabular data as a simple pipe-delimited list."""
396
+ if not rows:
397
+ click.echo(f"No {title.lower()} found.")
398
+ return
399
+ for row in rows:
400
+ row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
401
+ click.echo(row_str)
402
+
403
+
404
+ def _render_markdown_list(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
405
+ """Display tabular data using markdown table syntax."""
406
+ if not rows:
407
+ click.echo(f"No {title.lower()} found.")
408
+ return
409
+ headers = [header for _, header, _, _ in columns]
410
+ click.echo(f"| {' | '.join(headers)} |")
411
+ click.echo(f"| {' | '.join('---' for _ in headers)} |")
412
+ for row in rows:
413
+ row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
414
+ click.echo(f"| {row_str} |")
415
+
416
+
417
+ def _should_sort_rows(rows: list[dict[str, Any]]) -> bool:
418
+ """Return True when rows should be name-sorted prior to rendering."""
419
+ return TABLE_SORT_ENABLED and rows and isinstance(rows[0], dict) and "name" in rows[0]
420
+
421
+
422
+ def _create_table(columns: list[tuple[str, str, str, int | None]], title: str) -> Any:
423
+ """Build a configured Rich table for the provided columns."""
424
+ table = AIPTable(title=title, expand=True)
425
+ for _key, header, style, width in columns:
426
+ table.add_column(header, style=style, width=width)
427
+ return table
428
+
429
+
430
+ def _build_table_group(rows: list[dict[str, Any]], columns: list[tuple], title: str) -> Group:
431
+ """Return a Rich group containing the table and a small footer summary."""
432
+ table = _create_table(columns, title)
433
+ for row in rows:
434
+ table.add_row(*[str(row.get(key, "N/A")) for key, _, _, _ in columns])
435
+ footer = markup_text(f"\n[dim]Total {len(rows)} items[/dim]")
436
+ return Group(table, footer)
437
+
438
+
439
+ def _handle_json_output(items: list[Any], rows: list[dict[str, Any]]) -> None:
440
+ """Handle JSON output format."""
441
+ data = rows if rows else [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
442
+ click.echo(json.dumps(data, indent=2, default=str))
443
+
444
+
445
+ def _handle_plain_output(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
446
+ """Handle plain text output format."""
447
+ _render_plain_list(rows, title, columns)
448
+
449
+
450
+ def _handle_markdown_output(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
451
+ """Handle markdown output format."""
452
+ _render_markdown_list(rows, title, columns)
453
+
454
+
455
+ def _handle_empty_items(title: str) -> None:
456
+ """Handle case when no items are found."""
457
+ console.print(markup_text(f"[{WARNING_STYLE}]No {title.lower()} found.[/]"))
458
+
459
+
460
+ def _should_use_fuzzy_picker() -> bool:
461
+ """Return True when the interactive fuzzy picker can be shown."""
462
+ return console.is_terminal and _is_tty(1)
463
+
464
+
465
+ def _try_fuzzy_pick(rows: list[dict[str, Any]], columns: list[tuple], title: str) -> dict[str, Any] | None:
466
+ """Best-effort fuzzy selection; returns None if the picker fails."""
467
+ if not _should_use_fuzzy_picker():
468
+ return None
469
+
470
+ try:
471
+ return _fuzzy_pick(rows, columns, title)
472
+ except Exception:
473
+ logger.debug("Fuzzy picker failed; falling back to table output", exc_info=True)
474
+ return None
475
+
476
+
477
+ def _resource_tip_command(title: str) -> str | None:
478
+ """Resolve the follow-up command hint for the given table title."""
479
+ title_lower = title.lower()
480
+ mapping = {
481
+ "agent": ("agents get", "agents"),
482
+ "tool": ("tools get", None),
483
+ "mcp": ("mcps get", None),
484
+ "model": ("models list", None), # models only ship a list command
485
+ }
486
+ for keyword, (cli_command, slash_command) in mapping.items():
487
+ if keyword in title_lower:
488
+ return command_hint(cli_command, slash_command=slash_command)
489
+ return command_hint("agents get", slash_command="agents")
490
+
491
+
492
+ def _print_selection_tip(title: str) -> None:
493
+ """Print the contextual follow-up tip after a fuzzy selection."""
494
+ tip_cmd = _resource_tip_command(title)
495
+ if tip_cmd:
496
+ console.print(markup_text(f"\n[dim]Tip: use `{tip_cmd} <ID>` for details[/dim]"))
497
+
498
+
499
+ def _handle_fuzzy_pick_selection(rows: list[dict[str, Any]], columns: list[tuple], title: str) -> bool:
500
+ """Handle fuzzy picker selection.
501
+
502
+ Returns:
503
+ True if a resource was selected and displayed,
504
+ False if cancelled/no selection.
505
+ """
506
+ picked = _try_fuzzy_pick(rows, columns, title)
507
+ if picked is None:
508
+ return False
509
+
510
+ table = _create_table(columns, title)
511
+ table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
512
+ console.print(table)
513
+ _print_selection_tip(title)
514
+ return True
515
+
516
+
517
+ def _handle_table_output(
518
+ rows: list[dict[str, Any]],
519
+ columns: list[tuple],
520
+ title: str,
521
+ *,
522
+ use_pager: bool | None = None,
523
+ ) -> None:
524
+ """Handle table output with paging."""
525
+ content = _build_table_group(rows, columns, title)
526
+ should_page = (
527
+ pager._should_page_output(len(rows), console.is_terminal and _is_tty(1)) if use_pager is None else use_pager
528
+ )
529
+
530
+ if should_page:
531
+ ansi = pager._render_ansi(content)
532
+ if not pager._page_with_system_pager(ansi):
533
+ with console.pager(styles=True):
534
+ console.print(content)
535
+ else:
536
+ console.print(content)
537
+
538
+
539
+ def output_list(
540
+ ctx: Any,
541
+ items: list[Any],
542
+ title: str,
543
+ columns: list[tuple[str, str, str, int | None]],
544
+ transform_func: Callable | None = None,
545
+ *,
546
+ skip_picker: bool = False,
547
+ use_pager: bool | None = None,
548
+ ) -> None:
549
+ """Display a list with optional fuzzy palette for quick selection."""
550
+ fmt = _get_view(ctx)
551
+ rows = _normalise_rows(items, transform_func)
552
+ rows = masking.mask_rows(rows)
553
+
554
+ if fmt == "json":
555
+ _handle_json_output(items, rows)
556
+ return
557
+
558
+ if fmt == "plain":
559
+ _handle_plain_output(rows, title, columns)
560
+ return
561
+
562
+ if fmt == "md":
563
+ _handle_markdown_output(rows, title, columns)
564
+ return
565
+
566
+ if not items:
567
+ _handle_empty_items(title)
568
+ return
569
+
570
+ if _should_sort_rows(rows):
571
+ try:
572
+ rows = sorted(rows, key=lambda r: str(r.get("name", "")).lower())
573
+ except Exception:
574
+ pass
575
+
576
+ if not skip_picker and _handle_fuzzy_pick_selection(rows, columns, title):
577
+ return
578
+
579
+ _handle_table_output(rows, columns, title, use_pager=use_pager)
580
+
581
+
582
+ def coerce_to_row(item: Any, keys: list[str]) -> dict[str, Any]:
583
+ """Coerce an item (dict or object) to a row dict with specified keys.
584
+
585
+ Args:
586
+ item: The item to coerce (dict or object with attributes)
587
+ keys: List of keys/attribute names to extract
588
+
589
+ Returns:
590
+ Dict with the extracted values, "N/A" for missing values
591
+ """
592
+ result = {}
593
+ for key in keys:
594
+ if isinstance(item, dict):
595
+ value = item.get(key, "N/A")
596
+ else:
597
+ value = getattr(item, key, "N/A")
598
+ result[key] = str(value) if value is not None else "N/A"
599
+ return result
600
+
601
+
602
+ def _resolve_by_id(ref: str, get_by_id: Callable) -> Any | None:
603
+ """Resolve resource by UUID if ref is a valid UUID."""
604
+ if is_uuid(ref):
605
+ return get_by_id(ref)
606
+ return None
607
+
608
+
609
+ def _resolve_by_name_multiple_with_select(matches: list[Any], select: int) -> Any:
610
+ """Resolve multiple matches using select parameter."""
611
+ idx = int(select) - 1
612
+ if not (0 <= idx < len(matches)):
613
+ raise click.ClickException(f"--select must be 1..{len(matches)}")
614
+ return matches[idx]
615
+
616
+
617
+ def _resolve_by_name_multiple_fuzzy(ctx: Any, ref: str, matches: list[Any], label: str) -> Any:
618
+ """Resolve multiple matches preferring the fuzzy picker interface."""
619
+ return handle_ambiguous_resource(ctx, label.lower(), ref, matches, interface_preference="fuzzy")
620
+
621
+
622
+ def _resolve_by_name_multiple_questionary(ctx: Any, ref: str, matches: list[Any], label: str) -> Any:
623
+ """Resolve multiple matches preferring the questionary interface."""
624
+ return handle_ambiguous_resource(ctx, label.lower(), ref, matches, interface_preference="questionary")
625
+
626
+
627
+ def resolve_resource(
628
+ ctx: Any,
629
+ ref: str,
630
+ *,
631
+ get_by_id: Callable,
632
+ find_by_name: Callable,
633
+ label: str,
634
+ select: int | None = None,
635
+ interface_preference: str = "fuzzy",
636
+ status_indicator: Any | None = None,
637
+ ) -> Any | None:
638
+ """Resolve resource reference (ID or name) with ambiguity handling.
639
+
640
+ Args:
641
+ ctx: Click context
642
+ ref: Resource reference (ID or name)
643
+ get_by_id: Function to get resource by ID
644
+ find_by_name: Function to find resources by name
645
+ label: Resource type label for error messages
646
+ select: Optional selection index for ambiguity resolution
647
+ interface_preference: "fuzzy" for fuzzy picker, "questionary" for up/down list
648
+ status_indicator: Optional Rich status indicator for wait animations
649
+
650
+ Returns:
651
+ Resolved resource object
652
+ """
653
+ spinner = status_indicator
654
+ _spinner_update(spinner, f"[bold blue]Resolving {label}…[/bold blue]")
655
+
656
+ # Try to resolve by ID first
657
+ _spinner_update(spinner, f"[bold blue]Fetching {label} by ID…[/bold blue]")
658
+ result = _resolve_by_id(ref, get_by_id)
659
+ if result is not None:
660
+ _spinner_update(spinner, f"[{SUCCESS_STYLE}]{label} found[/]")
661
+ return result
662
+
663
+ # If get_by_id returned None, the resource doesn't exist
664
+ if is_uuid(ref):
665
+ _spinner_stop(spinner)
666
+ raise click.ClickException(f"{label} '{ref}' not found")
667
+
668
+ # Find resources by name
669
+ _spinner_update(spinner, f"[bold blue]Searching {label}s matching '{ref}'…[/bold blue]")
670
+ matches = find_by_name(name=ref)
671
+ if not matches:
672
+ _spinner_stop(spinner)
673
+ raise click.ClickException(f"{label} '{ref}' not found")
674
+
675
+ if len(matches) == 1:
676
+ _spinner_update(spinner, f"[{SUCCESS_STYLE}]{label} found[/]")
677
+ return matches[0]
678
+
679
+ # Multiple matches found, handle ambiguity
680
+ if select:
681
+ _spinner_stop(spinner)
682
+ return _resolve_by_name_multiple_with_select(matches, select)
683
+
684
+ # Choose interface based on preference
685
+ _spinner_stop(spinner)
686
+ preference = (interface_preference or "fuzzy").lower()
687
+ if preference not in {"fuzzy", "questionary"}:
688
+ preference = "fuzzy"
689
+ if preference == "fuzzy":
690
+ return _resolve_by_name_multiple_fuzzy(ctx, ref, matches, label)
691
+ else:
692
+ return _resolve_by_name_multiple_questionary(ctx, ref, matches, label)
693
+
694
+
695
+ def _handle_json_view_ambiguity(matches: list[Any]) -> Any:
696
+ """Handle ambiguity in JSON view by returning first match."""
697
+ return matches[0]
698
+
699
+
700
+ def _handle_questionary_ambiguity(resource_type: str, ref: str, matches: list[Any]) -> Any:
701
+ """Handle ambiguity using questionary interactive interface."""
702
+ questionary_module, choice_cls = _load_questionary_module()
703
+ if not (questionary_module and os.getenv("TERM") and _is_tty(0) and _is_tty(1)):
704
+ raise click.ClickException("Interactive selection not available")
705
+
706
+ # Escape special characters for questionary
707
+ safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
708
+ safe_ref = ref.replace("{", "{{").replace("}", "}}")
709
+
710
+ picked_idx = questionary_module.select(
711
+ f"Multiple {safe_resource_type}s match '{safe_ref}'. Pick one:",
712
+ choices=[
713
+ _make_questionary_choice(
714
+ choice_cls,
715
+ title=(
716
+ f"{getattr(m, 'name', '—').replace('{', '{{').replace('}', '}}')} — "
717
+ f"{getattr(m, 'id', '').replace('{', '{{').replace('}', '}}')}"
718
+ ),
719
+ value=i,
720
+ )
721
+ for i, m in enumerate(matches)
722
+ ],
723
+ use_indicator=True,
724
+ qmark="🧭",
725
+ instruction="↑/↓ to select • Enter to confirm",
726
+ ).ask()
727
+ if picked_idx is None:
728
+ raise click.ClickException("Selection cancelled")
729
+ return matches[picked_idx]
730
+
731
+
732
+ def _handle_fallback_numeric_ambiguity(resource_type: str, ref: str, matches: list[Any]) -> Any:
733
+ """Handle ambiguity using numeric prompt fallback."""
734
+ # Escape special characters for display
735
+ safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
736
+ safe_ref = ref.replace("{", "{{").replace("}", "}}")
737
+
738
+ console.print(markup_text(f"[{WARNING_STYLE}]Multiple {safe_resource_type}s found matching '{safe_ref}':[/]"))
739
+ table = AIPTable(
740
+ title=f"Select {safe_resource_type.title()}",
741
+ )
742
+ table.add_column("#", style="dim", width=3)
743
+ table.add_column("ID", style="dim", width=36)
744
+ table.add_column("Name", style=ACCENT_STYLE)
745
+ for i, m in enumerate(matches, 1):
746
+ table.add_row(str(i), str(getattr(m, "id", "")), str(getattr(m, "name", "")))
747
+ console.print(table)
748
+ choice_str = click.prompt(
749
+ f"Select {safe_resource_type} (1-{len(matches)})",
750
+ )
751
+ try:
752
+ choice = int(choice_str)
753
+ except ValueError as err:
754
+ raise click.ClickException("Invalid selection") from err
755
+ if 1 <= choice <= len(matches):
756
+ return matches[choice - 1]
757
+ raise click.ClickException("Invalid selection")
758
+
759
+
760
+ def _should_fallback_to_numeric_prompt(exception: Exception) -> bool:
761
+ """Determine if we should fallback to numeric prompt for this exception."""
762
+ # Re-raise cancellation - user explicitly cancelled
763
+ if "Selection cancelled" in str(exception):
764
+ return False
765
+
766
+ # Fall back to numeric prompt for other exceptions
767
+ return True
768
+
769
+
770
+ def _normalize_interface_preference(preference: str) -> str:
771
+ """Normalize and validate interface preference."""
772
+ normalized = (preference or "questionary").lower()
773
+ return normalized if normalized in {"fuzzy", "questionary"} else "questionary"
774
+
775
+
776
+ def _get_interface_order(preference: str) -> tuple[str, str]:
777
+ """Get the ordered interface preferences."""
778
+ interface_orders = {
779
+ "fuzzy": ("fuzzy", "questionary"),
780
+ "questionary": ("questionary", "fuzzy"),
781
+ }
782
+ return interface_orders.get(preference, ("questionary", "fuzzy"))
783
+
784
+
785
+ def _try_fuzzy_selection(
786
+ resource_type: str,
787
+ ref: str,
788
+ matches: list[Any],
789
+ ) -> Any | None:
790
+ """Try fuzzy interface selection."""
791
+ picked = _fuzzy_pick_for_resources(matches, resource_type, ref)
792
+ return picked if picked else None
793
+
794
+
795
+ def _try_questionary_selection(
796
+ resource_type: str,
797
+ ref: str,
798
+ matches: list[Any],
799
+ ) -> Any | None:
800
+ """Try questionary interface selection."""
801
+ try:
802
+ return _handle_questionary_ambiguity(resource_type, ref, matches)
803
+ except Exception as exc:
804
+ if not _should_fallback_to_numeric_prompt(exc):
805
+ raise
806
+ return None
807
+
808
+
809
+ def _try_interface_selection(
810
+ interface_order: tuple[str, str],
811
+ resource_type: str,
812
+ ref: str,
813
+ matches: list[Any],
814
+ ) -> Any | None:
815
+ """Try interface selection in order, return result or None if all failed."""
816
+ interface_handlers = {
817
+ "fuzzy": _try_fuzzy_selection,
818
+ "questionary": _try_questionary_selection,
819
+ }
820
+
821
+ for interface in interface_order:
822
+ handler = interface_handlers.get(interface)
823
+ if handler:
824
+ result = handler(resource_type, ref, matches)
825
+ if result:
826
+ return result
827
+
828
+ return None
829
+
830
+
831
+ def handle_ambiguous_resource(
832
+ ctx: Any,
833
+ resource_type: str,
834
+ ref: str,
835
+ matches: list[Any],
836
+ *,
837
+ interface_preference: str = "questionary",
838
+ ) -> Any:
839
+ """Handle multiple resource matches gracefully."""
840
+ if _get_view(ctx) == "json":
841
+ return _handle_json_view_ambiguity(matches)
842
+
843
+ preference = _normalize_interface_preference(interface_preference)
844
+ interface_order = _get_interface_order(preference)
845
+
846
+ result = _try_interface_selection(interface_order, resource_type, ref, matches)
847
+
848
+ if result is not None:
849
+ return result
850
+
851
+ return _handle_fallback_numeric_ambiguity(resource_type, ref, matches)