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