glaip-sdk 0.1.3__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.
- glaip_sdk/__init__.py +5 -2
- glaip_sdk/_version.py +9 -0
- glaip_sdk/agents/__init__.py +27 -0
- glaip_sdk/agents/base.py +1191 -0
- glaip_sdk/branding.py +13 -0
- glaip_sdk/cli/account_store.py +540 -0
- glaip_sdk/cli/auth.py +254 -15
- glaip_sdk/cli/commands/__init__.py +2 -2
- glaip_sdk/cli/commands/accounts.py +746 -0
- glaip_sdk/cli/commands/agents.py +213 -73
- glaip_sdk/cli/commands/common_config.py +101 -0
- glaip_sdk/cli/commands/configure.py +729 -113
- glaip_sdk/cli/commands/mcps.py +241 -72
- glaip_sdk/cli/commands/models.py +11 -5
- glaip_sdk/cli/commands/tools.py +49 -57
- glaip_sdk/cli/commands/transcripts.py +755 -0
- glaip_sdk/cli/config.py +48 -4
- glaip_sdk/cli/constants.py +38 -0
- glaip_sdk/cli/context.py +8 -0
- glaip_sdk/cli/core/__init__.py +79 -0
- glaip_sdk/cli/core/context.py +124 -0
- glaip_sdk/cli/core/output.py +846 -0
- glaip_sdk/cli/core/prompting.py +649 -0
- glaip_sdk/cli/core/rendering.py +187 -0
- glaip_sdk/cli/display.py +35 -19
- glaip_sdk/cli/hints.py +57 -0
- glaip_sdk/cli/io.py +6 -3
- glaip_sdk/cli/main.py +228 -119
- glaip_sdk/cli/masking.py +21 -33
- glaip_sdk/cli/pager.py +9 -10
- glaip_sdk/cli/parsers/__init__.py +1 -3
- glaip_sdk/cli/slash/__init__.py +0 -9
- glaip_sdk/cli/slash/accounts_controller.py +578 -0
- glaip_sdk/cli/slash/accounts_shared.py +75 -0
- glaip_sdk/cli/slash/agent_session.py +62 -21
- glaip_sdk/cli/slash/prompt.py +21 -0
- glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
- glaip_sdk/cli/slash/session.py +771 -140
- glaip_sdk/cli/slash/tui/__init__.py +9 -0
- glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
- glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
- glaip_sdk/cli/slash/tui/loading.py +58 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
- glaip_sdk/cli/transcript/__init__.py +12 -52
- glaip_sdk/cli/transcript/cache.py +255 -44
- glaip_sdk/cli/transcript/capture.py +27 -1
- glaip_sdk/cli/transcript/history.py +815 -0
- glaip_sdk/cli/transcript/viewer.py +72 -499
- glaip_sdk/cli/update_notifier.py +14 -5
- glaip_sdk/cli/utils.py +243 -1252
- glaip_sdk/cli/validators.py +5 -6
- glaip_sdk/client/__init__.py +2 -1
- glaip_sdk/client/_agent_payloads.py +45 -9
- glaip_sdk/client/agent_runs.py +147 -0
- glaip_sdk/client/agents.py +287 -29
- glaip_sdk/client/base.py +1 -0
- glaip_sdk/client/main.py +19 -10
- glaip_sdk/client/mcps.py +122 -12
- glaip_sdk/client/run_rendering.py +133 -88
- glaip_sdk/client/shared.py +21 -0
- glaip_sdk/client/tools.py +155 -10
- glaip_sdk/config/constants.py +11 -0
- glaip_sdk/mcps/__init__.py +21 -0
- glaip_sdk/mcps/base.py +345 -0
- glaip_sdk/models/__init__.py +90 -0
- glaip_sdk/models/agent.py +47 -0
- glaip_sdk/models/agent_runs.py +116 -0
- glaip_sdk/models/common.py +42 -0
- glaip_sdk/models/mcp.py +33 -0
- glaip_sdk/models/tool.py +33 -0
- glaip_sdk/payload_schemas/__init__.py +1 -13
- glaip_sdk/registry/__init__.py +55 -0
- glaip_sdk/registry/agent.py +164 -0
- glaip_sdk/registry/base.py +139 -0
- glaip_sdk/registry/mcp.py +253 -0
- glaip_sdk/registry/tool.py +232 -0
- glaip_sdk/rich_components.py +58 -2
- glaip_sdk/runner/__init__.py +59 -0
- glaip_sdk/runner/base.py +84 -0
- glaip_sdk/runner/deps.py +115 -0
- glaip_sdk/runner/langgraph.py +706 -0
- glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
- glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
- glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
- glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
- glaip_sdk/runner/tool_adapter/__init__.py +18 -0
- glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
- glaip_sdk/tools/__init__.py +22 -0
- glaip_sdk/tools/base.py +435 -0
- glaip_sdk/utils/__init__.py +58 -12
- glaip_sdk/utils/a2a/__init__.py +34 -0
- glaip_sdk/utils/a2a/event_processor.py +188 -0
- glaip_sdk/utils/bundler.py +267 -0
- glaip_sdk/utils/client.py +111 -0
- glaip_sdk/utils/client_utils.py +39 -7
- glaip_sdk/utils/datetime_helpers.py +58 -0
- glaip_sdk/utils/discovery.py +78 -0
- glaip_sdk/utils/display.py +23 -15
- glaip_sdk/utils/export.py +143 -0
- glaip_sdk/utils/general.py +0 -33
- glaip_sdk/utils/import_export.py +12 -7
- glaip_sdk/utils/import_resolver.py +492 -0
- glaip_sdk/utils/instructions.py +101 -0
- glaip_sdk/utils/rendering/__init__.py +115 -1
- glaip_sdk/utils/rendering/formatting.py +5 -30
- glaip_sdk/utils/rendering/layout/__init__.py +64 -0
- glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
- glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
- glaip_sdk/utils/rendering/layout/summary.py +74 -0
- glaip_sdk/utils/rendering/layout/transcript.py +606 -0
- glaip_sdk/utils/rendering/models.py +1 -0
- glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
- glaip_sdk/utils/rendering/renderer/base.py +217 -1476
- glaip_sdk/utils/rendering/renderer/debug.py +26 -20
- glaip_sdk/utils/rendering/renderer/factory.py +138 -0
- glaip_sdk/utils/rendering/renderer/stream.py +4 -12
- glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
- glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
- glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
- glaip_sdk/utils/rendering/state.py +204 -0
- glaip_sdk/utils/rendering/steps/__init__.py +34 -0
- glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
- glaip_sdk/utils/rendering/steps/format.py +176 -0
- glaip_sdk/utils/rendering/steps/manager.py +387 -0
- glaip_sdk/utils/rendering/timing.py +36 -0
- glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
- glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
- glaip_sdk/utils/resource_refs.py +25 -13
- glaip_sdk/utils/runtime_config.py +425 -0
- glaip_sdk/utils/serialization.py +18 -0
- glaip_sdk/utils/sync.py +142 -0
- glaip_sdk/utils/tool_detection.py +33 -0
- glaip_sdk/utils/validation.py +16 -24
- {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.10.dist-info}/METADATA +42 -4
- glaip_sdk-0.6.10.dist-info/RECORD +159 -0
- {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.10.dist-info}/WHEEL +1 -1
- glaip_sdk/models.py +0 -240
- glaip_sdk-0.1.3.dist-info/RECORD +0 -83
- {glaip_sdk-0.1.3.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)
|