glaip-sdk 0.6.3__py3-none-any.whl → 0.6.5__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/cli/utils.py CHANGED
@@ -1,1754 +1,263 @@
1
- """CLI utilities for glaip-sdk.
1
+ """CLI utilities for glaip-sdk (facade for backward compatibility).
2
+
3
+ This module is a backward-compatible facade that re-exports functions from
4
+ glaip_sdk.cli.core.* modules. New code should import directly from the core modules.
5
+ The facade is deprecated and will be removed after consumers migrate to core modules;
6
+ see docs/specs/refactor/cli-core-modularization.md for the migration plan.
2
7
 
3
8
  Authors:
4
9
  Raymond Christopher (raymond.christopher@gdplabs.id)
5
10
  Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
6
- """
11
+ """ # pylint: disable=duplicate-code
7
12
 
8
13
  from __future__ import annotations
9
14
 
10
- import asyncio
11
- import importlib
12
- import json
13
- import logging
14
- import os
15
- import re
16
- import sys
17
- from collections.abc import Callable, Iterable
18
- from contextlib import AbstractContextManager, contextmanager, nullcontext
19
- from pathlib import Path
20
- from typing import TYPE_CHECKING, Any, cast
21
-
22
- import click
23
- import yaml
24
- from rich.console import Console, Group
25
- from rich.markdown import Markdown
26
- from rich.syntax import Syntax
15
+ import threading
16
+ import warnings
27
17
 
28
- from glaip_sdk import _version as _version_module
29
- from glaip_sdk.branding import (
30
- ACCENT_STYLE,
31
- SUCCESS_STYLE,
32
- WARNING_STYLE,
18
+ # Re-export from core modules
19
+ from glaip_sdk.cli.core.context import (
20
+ bind_slash_session_context,
21
+ get_client,
22
+ handle_best_effort_check,
23
+ restore_slash_session_context,
33
24
  )
34
- from glaip_sdk.cli import display as cli_display
35
- from glaip_sdk.cli import masking, pager
36
- from glaip_sdk.cli.config import load_config
37
- from glaip_sdk.cli.constants import LITERAL_STRING_THRESHOLD, TABLE_SORT_ENABLED
38
- from glaip_sdk.cli.context import (
39
- _get_view,
40
- get_ctx_value,
25
+ from glaip_sdk.cli.core.output import (
26
+ coerce_to_row,
27
+ detect_export_format,
28
+ fetch_resource_for_export,
29
+ format_datetime_fields,
30
+ format_size,
31
+ handle_ambiguous_resource,
32
+ handle_resource_export,
33
+ output_list,
34
+ output_result,
35
+ parse_json_line,
36
+ resolve_resource,
37
+ sdk_version,
38
+ # Private functions for backward compatibility (used in tests)
39
+ _build_table_group,
40
+ _build_yaml_renderable,
41
+ _coerce_result_payload,
42
+ _create_table,
43
+ _ensure_displayable,
44
+ _format_yaml_text,
45
+ _get_interface_order,
46
+ _handle_empty_items,
47
+ _handle_fallback_numeric_ambiguity,
48
+ _handle_fuzzy_pick_selection,
49
+ _handle_json_output,
50
+ _handle_json_view_ambiguity,
51
+ _handle_markdown_output,
52
+ _handle_plain_output,
53
+ _handle_questionary_ambiguity,
54
+ _handle_table_output,
55
+ _literal_str_representer,
56
+ _normalise_rows,
57
+ _normalize_interface_preference,
58
+ _print_selection_tip,
59
+ _render_markdown_list,
60
+ _render_markdown_output,
61
+ _render_plain_list,
62
+ _resolve_by_id,
63
+ _resolve_by_name_multiple_fuzzy,
64
+ _resolve_by_name_multiple_questionary,
65
+ _resolve_by_name_multiple_with_select,
66
+ _resource_tip_command,
67
+ _should_fallback_to_numeric_prompt,
68
+ _should_sort_rows,
69
+ _should_use_fuzzy_picker,
70
+ _try_fuzzy_pick,
71
+ _try_fuzzy_selection,
72
+ _try_interface_selection,
73
+ _try_questionary_selection,
74
+ _LiteralYamlDumper,
41
75
  )
42
- from glaip_sdk.cli.context import (
43
- detect_export_format as _detect_export_format,
76
+ from glaip_sdk.cli.core.prompting import (
77
+ _FuzzyCompleter, # Private class for backward compatibility (used in tests)
78
+ _fuzzy_pick_for_resources,
79
+ prompt_export_choice_questionary,
80
+ questionary_safe_ask,
81
+ # Private functions for backward compatibility (used in tests)
82
+ _asyncio_loop_running,
83
+ _basic_prompt,
84
+ _build_resource_labels,
85
+ _build_display_parts,
86
+ _build_primary_parts,
87
+ _build_unique_labels,
88
+ _calculate_consecutive_bonus,
89
+ _calculate_exact_match_bonus,
90
+ _calculate_length_bonus,
91
+ _check_fuzzy_pick_requirements,
92
+ _extract_display_fields,
93
+ _extract_fallback_values,
94
+ _extract_id_suffix,
95
+ _get_fallback_columns,
96
+ _fuzzy_pick,
97
+ _fuzzy_score,
98
+ _is_fuzzy_match,
99
+ _is_standard_field,
100
+ _load_questionary_module,
101
+ _make_questionary_choice,
102
+ _perform_fuzzy_search,
103
+ _prompt_with_auto_select,
104
+ _rank_labels,
105
+ _row_display,
106
+ _run_questionary_in_thread,
107
+ _strip_spaces_for_matching,
44
108
  )
45
- from glaip_sdk.cli.hints import command_hint
46
- from glaip_sdk.cli.io import export_resource_to_file_with_validation
47
- from glaip_sdk.cli.rich_helpers import markup_text, print_markup
48
- from glaip_sdk.icons import ICON_AGENT
49
- from glaip_sdk.rich_components import AIPPanel, AIPTable
50
- from glaip_sdk.utils import format_datetime, is_uuid
51
- from glaip_sdk.utils.rendering.renderer import (
52
- CapturingConsole,
53
- RendererFactoryOptions,
54
- RichStreamRenderer,
55
- make_default_renderer,
56
- make_verbose_renderer,
109
+ from glaip_sdk.cli.core.rendering import (
110
+ build_renderer,
111
+ spinner_context,
112
+ stop_spinner,
113
+ update_spinner,
114
+ with_client_and_spinner,
115
+ # Private functions for backward compatibility (used in tests)
116
+ _can_use_spinner,
117
+ _register_renderer_with_session,
118
+ _spinner_stop,
119
+ _spinner_update,
120
+ _stream_supports_tty,
57
121
  )
58
122
 
59
- questionary = None # type: ignore[assignment]
60
-
61
-
62
- def _load_questionary_module() -> tuple[Any | None, Any | None]:
63
- """Return the questionary module and Choice class if available."""
64
- module = questionary
65
- if module is not None:
66
- return module, getattr(module, "Choice", None)
67
-
68
- try: # pragma: no cover - optional dependency
69
- module = __import__("questionary")
70
- except ImportError:
71
- return None, None
72
-
73
- return module, getattr(module, "Choice", None)
74
-
75
-
76
- def _make_questionary_choice(choice_cls: Any | None, **kwargs: Any) -> Any:
77
- """Create a questionary Choice instance or lightweight fallback."""
78
- if choice_cls is None:
79
- return kwargs
80
- return choice_cls(**kwargs)
81
-
82
-
83
- @contextmanager
84
- def bind_slash_session_context(ctx: Any, session: Any) -> Any:
85
- """Temporarily attach a slash session to the Click context.
86
-
87
- Args:
88
- ctx: Click context object.
89
- session: SlashSession instance to bind.
90
-
91
- Yields:
92
- None - context manager for use in with statement.
93
- """
94
- ctx_obj = getattr(ctx, "obj", None)
95
- has_context = isinstance(ctx_obj, dict)
96
- previous_session = ctx_obj.get("_slash_session") if has_context else None
97
- if has_context:
98
- ctx_obj["_slash_session"] = session
99
- try:
100
- yield
101
- finally:
102
- if has_context:
103
- if previous_session is None:
104
- ctx_obj.pop("_slash_session", None)
105
- else:
106
- ctx_obj["_slash_session"] = previous_session
107
-
108
-
109
- def restore_slash_session_context(ctx_obj: dict[str, Any], previous_session: Any | None) -> None:
110
- """Restore slash session context after operation.
111
-
112
- Args:
113
- ctx_obj: Click context obj dictionary.
114
- previous_session: Previous session to restore, or None to remove.
115
- """
116
- if previous_session is None:
117
- ctx_obj.pop("_slash_session", None)
118
- else:
119
- ctx_obj["_slash_session"] = previous_session
120
-
121
-
122
- def handle_best_effort_check(
123
- check_func: Callable[[], None],
124
- ) -> None:
125
- """Handle best-effort duplicate/existence checks with proper exception handling.
126
-
127
- Args:
128
- check_func: Function that performs the check and raises ClickException if duplicate found.
129
- """
130
- try:
131
- check_func()
132
- except click.ClickException:
133
- raise
134
- except Exception:
135
- # Non-fatal: best-effort duplicate check
136
- pass
137
-
138
-
139
- def prompt_export_choice_questionary(
140
- default_path: Path,
141
- default_display: str,
142
- ) -> tuple[str, Path | None] | None:
143
- """Prompt user for export destination using questionary with numeric shortcuts.
144
-
145
- Args:
146
- default_path: Default export path.
147
- default_display: Formatted display string for default path.
148
-
149
- Returns:
150
- Tuple of (choice, path) or None if cancelled/unavailable.
151
- Choice can be "default", "custom", or "cancel".
152
- """
153
- questionary_module, choice_cls = _load_questionary_module()
154
- if questionary_module is None or choice_cls is None:
155
- return None
156
-
157
- try:
158
- question = questionary_module.select(
159
- "Export transcript",
160
- choices=[
161
- _make_questionary_choice(
162
- choice_cls,
163
- title=f"Save to default ({default_display})",
164
- value=("default", default_path),
165
- shortcut_key="1",
166
- ),
167
- _make_questionary_choice(
168
- choice_cls,
169
- title="Choose a different path",
170
- value=("custom", None),
171
- shortcut_key="2",
172
- ),
173
- _make_questionary_choice(
174
- choice_cls,
175
- title="Cancel",
176
- value=("cancel", None),
177
- shortcut_key="3",
178
- ),
179
- ],
180
- use_shortcuts=True,
181
- instruction="Press 1-3 (or arrows) then Enter.",
182
- )
183
- answer = questionary_safe_ask(question)
184
- except Exception:
185
- return None
186
-
187
- if answer is None:
188
- return ("cancel", None)
189
- return answer
190
-
191
-
192
- def questionary_safe_ask(question: Any, *, patch_stdout: bool = False) -> Any:
193
- """Run `questionary.Question` safely even when an asyncio loop is active."""
194
- ask_fn = getattr(question, "unsafe_ask", None)
195
- if not callable(ask_fn):
196
- raise RuntimeError("Questionary prompt is missing unsafe_ask()")
197
-
198
- if not _asyncio_loop_running():
199
- return ask_fn(patch_stdout=patch_stdout)
200
-
201
- return _run_questionary_in_thread(question, patch_stdout=patch_stdout)
202
-
203
-
204
- def _asyncio_loop_running() -> bool:
205
- """Return True when an asyncio event loop is already running."""
206
- try:
207
- asyncio.get_running_loop()
208
- except RuntimeError:
209
- return False
210
- return True
211
-
212
-
213
- def _run_questionary_in_thread(question: Any, *, patch_stdout: bool = False) -> Any:
214
- """Execute a questionary prompt in a background thread."""
215
- if getattr(question, "should_skip_question", False):
216
- return getattr(question, "default", None)
217
-
218
- application = getattr(question, "application", None)
219
- run_callable = getattr(application, "run", None) if application is not None else None
220
- if callable(run_callable):
221
- try:
222
- if patch_stdout and pt_patch_stdout is not None:
223
- with pt_patch_stdout():
224
- return run_callable(in_thread=True)
225
- return run_callable(in_thread=True)
226
- except TypeError:
227
- pass
228
-
229
- return question.unsafe_ask(patch_stdout=patch_stdout)
230
-
231
-
232
- class _LiteralYamlDumper(yaml.SafeDumper):
233
- """YAML dumper that emits literal scalars for multiline strings."""
234
-
235
-
236
- def _literal_str_representer(dumper: yaml.Dumper, data: str) -> yaml.nodes.ScalarNode:
237
- """Represent strings in YAML, using literal blocks for verbose values."""
238
- needs_literal = "\n" in data or "\r" in data
239
- if not needs_literal and LITERAL_STRING_THRESHOLD and len(data) >= LITERAL_STRING_THRESHOLD: # pragma: no cover
240
- needs_literal = True
241
-
242
- style = "|" if needs_literal else None
243
- return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style)
244
-
245
-
246
- _LiteralYamlDumper.add_representer(str, _literal_str_representer)
247
-
248
- # Optional interactive deps (fuzzy palette)
249
- try:
250
- from prompt_toolkit.buffer import Buffer
251
- from prompt_toolkit.completion import Completion
252
- from prompt_toolkit.patch_stdout import patch_stdout as pt_patch_stdout
253
- from prompt_toolkit.selection import SelectionType
254
- from prompt_toolkit.shortcuts import PromptSession, prompt
255
-
256
- _HAS_PTK = True
257
- except Exception: # pragma: no cover - optional dependency
258
- Buffer = None # type: ignore[assignment]
259
- SelectionType = None # type: ignore[assignment]
260
- PromptSession = None # type: ignore[assignment]
261
- prompt = None # type: ignore[assignment]
262
- pt_patch_stdout = None # type: ignore[assignment]
263
- _HAS_PTK = False
123
+ # Re-export from other modules for backward compatibility
124
+ from glaip_sdk.cli.context import get_ctx_value
125
+ from glaip_sdk.cli.hints import command_hint
126
+ from glaip_sdk.utils import is_uuid
264
127
 
265
- if TYPE_CHECKING: # pragma: no cover - import-only during type checking
266
- from glaip_sdk import Client
128
+ # Re-export module-level variables for backward compatibility
129
+ # Note: console is re-exported from output.py since that's where _handle_table_output uses it
130
+ from glaip_sdk.cli.core.output import console
131
+ import logging
267
132
 
268
- console = Console()
269
- pager.console = console
270
133
  logger = logging.getLogger("glaip_sdk.cli.utils")
271
- _version_logger = logging.getLogger("glaip_sdk.cli.version")
272
- _WARNED_SDK_VERSION_FALLBACK = False
273
-
274
-
275
- # ----------------------------- Context helpers ---------------------------- #
276
-
277
-
278
- def detect_export_format(file_path: str | os.PathLike[str]) -> str:
279
- """Backward-compatible proxy to `glaip_sdk.cli.context.detect_export_format`."""
280
- return _detect_export_format(file_path)
281
-
282
-
283
- def format_size(num: int | None) -> str:
284
- """Format byte counts using short human-friendly units.
285
-
286
- Args:
287
- num: Number of bytes to format (can be None or 0)
288
-
289
- Returns:
290
- Human-readable size string (e.g., "1.5KB", "2MB")
291
- """
292
- if not num or num <= 0:
293
- return "0B"
294
-
295
- units = ["B", "KB", "MB", "GB", "TB"]
296
- value = float(num)
297
- for unit in units:
298
- if value < 1024 or unit == units[-1]:
299
- if unit == "B" or value >= 100:
300
- return f"{value:.0f}{unit}"
301
- if value >= 10:
302
- return f"{value:.1f}{unit}"
303
- return f"{value:.2f}{unit}"
304
- value /= 1024
305
- return f"{value:.1f}TB" # pragma: no cover - defensive fallback
306
-
307
-
308
- def parse_json_line(line: str) -> dict[str, Any] | None:
309
- """Parse a JSON line into a dictionary payload.
310
-
311
- Args:
312
- line: JSON line string to parse
313
-
314
- Returns:
315
- Parsed dictionary or None if parsing fails or result is not a dict
316
- """
317
- line = line.strip()
318
- if not line:
319
- return None
320
- try:
321
- payload = json.loads(line)
322
- except json.JSONDecodeError:
323
- return None
324
- return payload if isinstance(payload, dict) else None
325
-
326
-
327
- def format_datetime_fields(
328
- data: dict[str, Any], fields: tuple[str, ...] = ("created_at", "updated_at")
329
- ) -> dict[str, Any]:
330
- """Format datetime fields in a data dictionary for display.
331
-
332
- Args:
333
- data: Dictionary containing the data to format
334
- fields: Tuple of field names to format (default: created_at, updated_at)
335
-
336
- Returns:
337
- New dictionary with formatted datetime fields
338
- """
339
- formatted = data.copy()
340
- for field in fields:
341
- if field in formatted:
342
- formatted[field] = format_datetime(formatted[field])
343
- return formatted
344
-
345
-
346
- def fetch_resource_for_export(
347
- ctx: Any,
348
- resource: Any,
349
- resource_type: str,
350
- get_by_id_func: Callable[[str], Any],
351
- console_override: Console | None = None,
352
- ) -> Any:
353
- """Fetch full resource details for export, handling errors gracefully.
354
-
355
- Args:
356
- ctx: Click context for spinner management
357
- resource: Resource object to fetch details for
358
- resource_type: Type of resource (e.g., "MCP", "Agent", "Tool")
359
- get_by_id_func: Function to fetch resource by ID
360
- console_override: Optional console override
361
-
362
- Returns:
363
- Resource object with full details, or original resource if fetch fails
364
- """
365
- active_console = console_override or console
366
- resource_id = str(getattr(resource, "id", "")).strip()
367
-
368
- if not resource_id:
369
- return resource
370
-
371
- try:
372
- with spinner_context(
373
- ctx,
374
- f"[bold blue]Fetching {resource_type} details…[/bold blue]",
375
- console_override=active_console,
376
- ):
377
- return get_by_id_func(resource_id)
378
- except Exception:
379
- # Return original resource if fetch fails
380
- return resource
381
-
382
-
383
- def handle_resource_export(
384
- ctx: Any,
385
- resource: Any,
386
- export_path: Path,
387
- resource_type: str,
388
- get_by_id_func: Callable[[str], Any],
389
- console_override: Console | None = None,
390
- ) -> None:
391
- """Handle resource export to file with format detection and error handling.
392
-
393
- Args:
394
- ctx: Click context for spinner management
395
- resource: Resource object to export
396
- export_path: Target file path (format detected from extension)
397
- resource_type: Type of resource (e.g., "agent", "tool")
398
- get_by_id_func: Function to fetch resource by ID
399
- console_override: Optional console override
400
- """
401
- active_console = console_override or console
402
-
403
- # Auto-detect format from file extension
404
- detected_format = detect_export_format(export_path)
405
-
406
- # Try to fetch full details for export
407
- full_resource = fetch_resource_for_export(
408
- ctx,
409
- resource,
410
- resource_type.capitalize(),
411
- get_by_id_func,
412
- console_override=active_console,
413
- )
414
-
415
- # Export the resource
416
- try:
417
- with spinner_context(
418
- ctx,
419
- f"[bold blue]Exporting {resource_type}…[/bold blue]",
420
- console_override=active_console,
421
- ):
422
- export_resource_to_file_with_validation(full_resource, export_path, detected_format)
423
- except Exception:
424
- cli_display.handle_rich_output(
425
- ctx,
426
- markup_text(f"[{WARNING_STYLE}]⚠️ Failed to fetch full details, using available data[/]"),
427
- )
428
- # Fallback: export with available data
429
- export_resource_to_file_with_validation(resource, export_path, detected_format)
430
-
431
- print_markup(
432
- f"[{SUCCESS_STYLE}]✅ {resource_type.capitalize()} exported to: {export_path} (format: {detected_format})[/]",
433
- console=active_console,
434
- )
435
-
436
-
437
- def sdk_version() -> str:
438
- """Return the current SDK version, warning if metadata is unavailable."""
439
- version = getattr(_version_module, "__version__", None)
440
- if isinstance(version, str) and version:
441
- return version
442
-
443
- global _WARNED_SDK_VERSION_FALLBACK
444
- if not _WARNED_SDK_VERSION_FALLBACK:
445
- _version_logger.warning("Unable to resolve glaip-sdk version metadata; using fallback '0.0.0'.")
446
- _WARNED_SDK_VERSION_FALLBACK = True
447
-
448
- return "0.0.0"
449
-
450
-
451
- @contextmanager
452
- def with_client_and_spinner(
453
- ctx: Any,
454
- spinner_message: str,
455
- *,
456
- console_override: Console | None = None,
457
- ) -> Any:
458
- """Context manager for commands that need client and spinner.
459
-
460
- Args:
461
- ctx: Click context.
462
- spinner_message: Message to display in spinner.
463
- console_override: Optional console override.
464
-
465
- Yields:
466
- Client instance.
467
- """
468
- client = get_client(ctx)
469
- with spinner_context(ctx, spinner_message, console_override=console_override):
470
- yield client
471
-
472
-
473
- def spinner_context(
474
- ctx: Any | None,
475
- message: str,
476
- *,
477
- console_override: Console | None = None,
478
- spinner: str = "dots",
479
- spinner_style: str = ACCENT_STYLE,
480
- ) -> AbstractContextManager[Any]:
481
- """Return a context manager that renders a spinner when appropriate."""
482
- active_console = console_override or console
483
- if not _can_use_spinner(ctx, active_console):
484
- return nullcontext()
485
-
486
- status = active_console.status(
487
- message,
488
- spinner=spinner,
489
- spinner_style=spinner_style,
490
- )
491
-
492
- if not hasattr(status, "__enter__") or not hasattr(status, "__exit__"):
493
- return nullcontext()
494
-
495
- return status
496
-
497
-
498
- def _can_use_spinner(ctx: Any | None, active_console: Console) -> bool:
499
- """Check if spinner output is allowed in the current environment."""
500
- if ctx is not None:
501
- tty_enabled = bool(get_ctx_value(ctx, "tty", True))
502
- view = (_get_view(ctx) or "rich").lower()
503
- if not tty_enabled or view not in {"", "rich"}:
504
- return False
505
-
506
- if not active_console.is_terminal:
507
- return False
508
-
509
- return _stream_supports_tty(getattr(active_console, "file", None))
510
-
511
-
512
- def _stream_supports_tty(stream: Any) -> bool:
513
- """Return True if the provided stream can safely render a spinner."""
514
- target = stream if hasattr(stream, "isatty") else sys.stdout
515
- try:
516
- return bool(target.isatty())
517
- except Exception:
518
- return False
519
-
520
-
521
- def update_spinner(status_indicator: Any | None, message: str) -> None:
522
- """Update spinner text when a status indicator is active."""
523
- if status_indicator is None:
524
- return
134
+ questionary = None # type: ignore[assignment]
525
135
 
526
- try:
527
- status_indicator.update(message)
528
- except Exception: # pragma: no cover - defensive update
529
- pass
136
+ _warn_lock = threading.Lock()
137
+ _warned = False
530
138
 
531
139
 
532
- def stop_spinner(status_indicator: Any | None) -> None:
533
- """Stop an active spinner safely."""
534
- if status_indicator is None:
140
+ def _warn_once() -> None:
141
+ """Emit the deprecation warning once in a thread-safe way."""
142
+ global _warned
143
+ if _warned:
535
144
  return
536
-
537
- try:
538
- status_indicator.stop()
539
- except Exception: # pragma: no cover - defensive stop
540
- pass
541
-
542
-
543
- # Backwards compatibility aliases for legacy callers
544
- _spinner_update = update_spinner
545
- _spinner_stop = stop_spinner
546
-
547
-
548
- # ----------------------------- Client config ----------------------------- #
549
-
550
-
551
- def get_client(ctx: Any) -> Client: # pragma: no cover
552
- """Get configured client from context and account store (ctx > account)."""
553
- # Import here to avoid circular import
554
- from glaip_sdk.cli.auth import resolve_credentials # noqa: PLC0415
555
-
556
- module = importlib.import_module("glaip_sdk")
557
- client_class = cast("type[Client]", module.Client)
558
- context_config_obj = getattr(ctx, "obj", None)
559
- context_config = context_config_obj or {}
560
-
561
- account_name = context_config.get("account_name")
562
- api_url, api_key, _ = resolve_credentials(
563
- account_name=account_name,
564
- api_url=context_config.get("api_url"),
565
- api_key=context_config.get("api_key"),
566
- )
567
-
568
- if not api_url or not api_key:
569
- configure_hint = command_hint("accounts add", slash_command="login", ctx=ctx)
570
- actions: list[str] = []
571
- if configure_hint:
572
- actions.append(f"Run `{configure_hint}` to add an account profile")
573
- else:
574
- actions.append("add an account with 'aip accounts add'")
575
- raise click.ClickException(f"Missing api_url/api_key. {' or '.join(actions)}.")
576
-
577
- # Get timeout from context or config
578
- timeout = context_config.get("timeout")
579
- if timeout is None:
580
- raw_timeout = os.getenv("AIP_TIMEOUT", "0") or "0"
581
- try:
582
- timeout = float(raw_timeout) if raw_timeout != "0" else None
583
- except ValueError:
584
- timeout = None
585
- if timeout is None:
586
- # Fallback to legacy config
587
- file_config = load_config() or {}
588
- timeout = file_config.get("timeout")
589
-
590
- return client_class(
591
- api_url=api_url,
592
- api_key=api_key,
593
- timeout=float(timeout or 30.0),
594
- )
595
-
596
-
597
- # ----------------------------- Secret masking ---------------------------- #
598
-
599
- # ----------------------------- Fuzzy palette ----------------------------- #
600
-
601
-
602
- def _extract_display_fields(row: dict[str, Any]) -> tuple[str, str, str, str]:
603
- """Extract display fields from row data."""
604
- name = str(row.get("name", "")).strip()
605
- _id = str(row.get("id", "")).strip()
606
- type_ = str(row.get("type", "")).strip()
607
- fw = str(row.get("framework", "")).strip()
608
- return name, _id, type_, fw
609
-
610
-
611
- def _build_primary_parts(name: str, type_: str, fw: str) -> list[str]:
612
- """Build primary display parts from name, type, and framework."""
613
- parts = []
614
- if name:
615
- parts.append(name)
616
- if type_:
617
- parts.append(type_)
618
- if fw:
619
- parts.append(fw)
620
- return parts
621
-
622
-
623
- def _get_fallback_columns(columns: list[tuple]) -> list[tuple]:
624
- """Get first two visible columns for fallback display."""
625
- return columns[:2]
626
-
627
-
628
- def _is_standard_field(k: str) -> bool:
629
- """Check if field is a standard field to skip."""
630
- return k in ("id", "name", "type", "framework")
631
-
632
-
633
- def _extract_fallback_values(row: dict[str, Any], columns: list[tuple]) -> list[str]:
634
- """Extract fallback values from columns."""
635
- fallback_parts = []
636
- for k, _hdr, _style, _w in columns:
637
- if _is_standard_field(k):
638
- continue
639
- val = str(row.get(k, "")).strip()
640
- if val:
641
- fallback_parts.append(val)
642
- if len(fallback_parts) >= 2:
643
- break
644
- return fallback_parts
645
-
646
-
647
- def _build_display_parts(
648
- name: str, _id: str, type_: str, fw: str, row: dict[str, Any], columns: list[tuple]
649
- ) -> list[str]:
650
- """Build complete display parts list."""
651
- parts = _build_primary_parts(name, type_, fw)
652
-
653
- if not parts:
654
- # Use fallback columns
655
- fallback_columns = _get_fallback_columns(columns)
656
- parts.extend(_extract_fallback_values(row, fallback_columns))
657
-
658
- if _id:
659
- parts.append(f"[{_id}]")
660
-
661
- return parts
662
-
663
-
664
- def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
665
- """Build a compact text label for the palette.
666
-
667
- Prefers: name • type • framework • [id] (when available)
668
- Falls back to first 2 columns + [id].
669
- """
670
- name, _id, type_, fw = _extract_display_fields(row)
671
- parts = _build_display_parts(name, _id, type_, fw, row, columns)
672
- return " • ".join(parts) if parts else (_id or "(row)")
673
-
674
-
675
- def _check_fuzzy_pick_requirements() -> bool:
676
- """Check if fuzzy picking requirements are met."""
677
- return _HAS_PTK and console.is_terminal and os.isatty(1)
678
-
679
-
680
- def _build_unique_labels(
681
- rows: list[dict[str, Any]], columns: list[tuple]
682
- ) -> tuple[list[str], dict[str, dict[str, Any]]]:
683
- """Build unique display labels and reverse mapping."""
684
- labels = []
685
- by_label: dict[str, dict[str, Any]] = {}
686
-
687
- for r in rows:
688
- label = _row_display(r, columns)
689
- # Ensure uniqueness: if duplicate, suffix with …#n
690
- if label in by_label:
691
- i = 2
692
- base = label
693
- while f"{base} #{i}" in by_label:
694
- i += 1
695
- label = f"{base} #{i}"
696
- labels.append(label)
697
- by_label[label] = r
698
-
699
- return labels, by_label
700
-
701
-
702
- def _basic_prompt(
703
- message: str,
704
- completer: Any,
705
- ) -> str | None:
706
- """Fallback prompt handler when PromptSession is unavailable or fails."""
707
- if prompt is None: # pragma: no cover - optional dependency path
708
- return None
709
-
710
- try:
711
- return prompt(
712
- message=message,
713
- completer=completer,
714
- complete_in_thread=True,
715
- complete_while_typing=True,
716
- )
717
- except (KeyboardInterrupt, EOFError):
718
- return None
719
- except Exception as exc: # pragma: no cover - defensive
720
- logger.debug("Fallback prompt failed: %s", exc)
721
- return None
722
-
723
-
724
- def _prompt_with_auto_select(
725
- message: str,
726
- completer: Any,
727
- choices: Iterable[str],
728
- ) -> str | None:
729
- """Prompt with fuzzy completer that auto-selects suggested matches."""
730
- if not _HAS_PTK or PromptSession is None or Buffer is None or SelectionType is None:
731
- return _basic_prompt(message, completer)
732
-
733
- try:
734
- session = PromptSession(
735
- message,
736
- completer=completer,
737
- complete_in_thread=True,
738
- complete_while_typing=True,
739
- reserve_space_for_menu=8,
740
- )
741
- except Exception as exc: # pragma: no cover - depends on prompt_toolkit
742
- logger.debug("PromptSession init failed (%s); falling back to basic prompt.", exc)
743
- return _basic_prompt(message, completer)
744
-
745
- buffer = session.default_buffer
746
- valid_choices = set(choices)
747
-
748
- def _auto_select(_: Buffer) -> None:
749
- """Auto-select text when a valid choice is entered."""
750
- text = buffer.text
751
- if not text or text not in valid_choices:
752
- return
753
- buffer.cursor_position = 0
754
- buffer.start_selection(selection_type=SelectionType.CHARACTERS)
755
- buffer.cursor_position = len(text)
756
-
757
- handler_attached = False
758
- try:
759
- buffer.on_text_changed += _auto_select
760
- handler_attached = True
761
- except Exception as exc: # pragma: no cover - defensive
762
- logger.debug("Failed to attach auto-select handler: %s", exc)
763
-
764
- try:
765
- return session.prompt()
766
- except (KeyboardInterrupt, EOFError):
767
- return None
768
- except Exception as exc: # pragma: no cover - defensive
769
- logger.debug("PromptSession prompt failed (%s); falling back to basic prompt.", exc)
770
- return _basic_prompt(message, completer)
771
- finally:
772
- if handler_attached:
773
- try:
774
- buffer.on_text_changed -= _auto_select
775
- except Exception: # pragma: no cover - defensive
776
- pass
777
-
778
-
779
- class _FuzzyCompleter:
780
- """Fuzzy completer for prompt_toolkit."""
781
-
782
- def __init__(self, words: list[str]) -> None:
783
- """Initialize fuzzy completer with word list.
784
-
785
- Args:
786
- words: List of words to complete from.
787
- """
788
- self.words = words
789
-
790
- def get_completions(self, document: Any, _complete_event: Any) -> Any: # pragma: no cover
791
- """Get fuzzy completions for the current word, ranked by score.
792
-
793
- Args:
794
- document: Document object from prompt_toolkit.
795
- _complete_event: Completion event (unused).
796
-
797
- Yields:
798
- Completion objects matching the current word, in ranked order.
799
- """
800
- # Get the entire buffer text (not just word before cursor)
801
- buffer_text = document.text_before_cursor
802
- if not buffer_text or not isinstance(buffer_text, str):
145
+ with _warn_lock:
146
+ if _warned:
803
147
  return
804
-
805
- # Rank labels by fuzzy score
806
- ranked_labels = _rank_labels(self.words, buffer_text)
807
-
808
- # Yield ranked completions
809
- for label in ranked_labels:
810
- # Replace entire buffer text, not just the word before cursor
811
- # This prevents concatenation issues with hyphenated names
812
- yield Completion(label, start_position=-len(buffer_text))
813
-
814
-
815
- def _perform_fuzzy_search(answer: str, labels: list[str], by_label: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
816
- """Perform fuzzy search fallback and return best match.
817
-
818
- Returns:
819
- Selected resource dict or None if cancelled/no match.
820
- """
821
- # Exact label match
822
- if answer in by_label:
823
- return by_label[answer]
824
-
825
- # Fuzzy search fallback using ranked labels
826
- # Check if query actually matches anything before ranking
827
- query_lower = answer.lower()
828
- has_match = False
829
- for label in labels:
830
- if _fuzzy_score(query_lower, label.lower()) >= 0:
831
- has_match = True
832
- break
833
-
834
- if not has_match:
835
- return None
836
-
837
- ranked_labels = _rank_labels(labels, answer)
838
- if ranked_labels:
839
- # Return the top-ranked match
840
- best_match = ranked_labels[0]
841
- if best_match in by_label:
842
- return by_label[best_match]
843
-
844
- return None
845
-
846
-
847
- def _fuzzy_pick(
848
- rows: list[dict[str, Any]], columns: list[tuple], title: str
849
- ) -> dict[str, Any] | None: # pragma: no cover - requires interactive prompt toolkit
850
- """Open a minimal fuzzy palette using prompt_toolkit.
851
-
852
- Returns the selected row (dict) or None if cancelled/missing deps.
853
- """
854
- if not _check_fuzzy_pick_requirements():
855
- return None
856
-
857
- # Build display labels and mapping
858
- labels, by_label = _build_unique_labels(rows, columns)
859
-
860
- # Create fuzzy completer
861
- completer = _FuzzyCompleter(labels)
862
- answer = _prompt_with_auto_select(
863
- f"Find {title.rstrip('s')}: ",
864
- completer,
865
- labels,
866
- )
867
- if answer is None:
868
- return None
869
-
870
- return _perform_fuzzy_search(answer, labels, by_label) if answer else None
871
-
872
-
873
- def _strip_spaces_for_matching(value: str) -> str:
874
- """Remove whitespace from a query for consistent fuzzy matching."""
875
- return re.sub(r"\s+", "", value)
876
-
877
-
878
- def _is_fuzzy_match(search: Any, target: Any) -> bool:
879
- """Case-insensitive fuzzy match with optional spaces; returns False for non-string inputs."""
880
- # Ensure search is a string
881
- if not isinstance(search, str) or not isinstance(target, str):
882
- return False
883
-
884
- if not search:
885
- return True
886
-
887
- # Strip spaces from search query - treat them as optional separators
888
- # This allows "test agent" to match "test-agent", "test_agent", etc.
889
- search_no_spaces = _strip_spaces_for_matching(search).lower()
890
- if not search_no_spaces:
891
- # If search is only spaces, match everything
892
- return True
893
-
894
- search_idx = 0
895
- for char in target.lower():
896
- if search_idx < len(search_no_spaces) and search_no_spaces[search_idx] == char:
897
- search_idx += 1
898
- if search_idx == len(search_no_spaces):
899
- return True
900
- return False
901
-
902
-
903
- def _calculate_exact_match_bonus(search: str, target: str) -> int:
904
- """Calculate bonus for exact substring matches.
905
-
906
- Spaces in search are treated as optional separators (stripped before matching).
907
- """
908
- # Strip spaces from search - treat them as optional separators
909
- search_no_spaces = _strip_spaces_for_matching(search).lower()
910
- if not search_no_spaces:
911
- return 0
912
- return 100 if search_no_spaces in target.lower() else 0
913
-
914
-
915
- def _calculate_consecutive_bonus(search: str, target: str) -> int:
916
- """Case-insensitive consecutive-character bonus."""
917
- # Strip spaces from search - treat them as optional separators
918
- search_no_spaces = _strip_spaces_for_matching(search).lower()
919
- if not search_no_spaces:
920
- return 0
921
-
922
- consecutive = 0
923
- max_consecutive = 0
924
- search_idx = 0
925
-
926
- for char in target.lower():
927
- if search_idx < len(search_no_spaces) and search_no_spaces[search_idx] == char:
928
- consecutive += 1
929
- max_consecutive = max(max_consecutive, consecutive)
930
- search_idx += 1
931
- else:
932
- consecutive = 0
933
-
934
- return max_consecutive * 10
935
-
936
-
937
- def _calculate_length_bonus(search: str, target: str) -> int:
938
- """Calculate bonus for shorter search terms.
939
-
940
- Spaces in search are treated as optional separators (stripped before calculation).
941
- """
942
- # Strip spaces from search - treat them as optional separators
943
- search_no_spaces = _strip_spaces_for_matching(search)
944
- if not search_no_spaces:
945
- return 0
946
- return max(0, (len(target) - len(search_no_spaces)) * 2)
947
-
948
-
949
- def _fuzzy_score(search: Any, target: str) -> int:
950
- """Calculate fuzzy match score.
951
-
952
- Higher score = better match.
953
- Returns -1 if no match possible.
954
-
955
- Args:
956
- search: Search string (or any type - non-strings return -1)
957
- target: Target string to match against
958
- """
959
- # Ensure search is a string first
960
- if not isinstance(search, str):
961
- return -1
962
-
963
- if not search:
964
- return 0
965
-
966
- if not _is_fuzzy_match(search, target):
967
- return -1 # Not a fuzzy match
968
-
969
- # Calculate score based on different factors
970
- score = 0
971
- score += _calculate_exact_match_bonus(search, target)
972
- score += _calculate_consecutive_bonus(search, target)
973
- score += _calculate_length_bonus(search, target)
974
-
975
- return score
976
-
977
-
978
- def _extract_id_suffix(label: str) -> str:
979
- """Extract ID suffix from label for tie-breaking.
980
-
981
- Args:
982
- label: Display label (e.g., "name • [abc123...]")
983
-
984
- Returns:
985
- ID suffix string (e.g., "abc123") or empty string if not found
986
- """
987
- # Look for pattern like "[abc123...]" or "[abc123]"
988
- match = re.search(r"\[([^\]]+)\]", label)
989
- return match.group(1) if match else ""
990
-
991
-
992
- def _rank_labels(labels: list[str], query: Any) -> list[str]:
993
- """Rank labels by fuzzy score with deterministic tie-breaks.
994
-
995
- Args:
996
- labels: List of display labels to rank
997
- query: Search query string (or any type - non-strings return sorted labels)
998
-
999
- Returns:
1000
- Labels sorted by fuzzy score (descending), then case-insensitive label,
1001
- then id suffix for deterministic ordering.
1002
- """
1003
- suffix_cache = {label: _extract_id_suffix(label) for label in labels}
1004
-
1005
- if not query:
1006
- # No query: sort by case-insensitive label, then id suffix
1007
- return sorted(labels, key=lambda lbl: (lbl.lower(), suffix_cache[lbl]))
1008
-
1009
- # Ensure query is a string
1010
- if not isinstance(query, str):
1011
- return sorted(labels, key=lambda lbl: (lbl.lower(), suffix_cache[lbl]))
1012
-
1013
- query_lower = query.lower()
1014
-
1015
- # Calculate scores and create tuples for sorting
1016
- scored_labels = []
1017
- for label in labels:
1018
- label_lower = label.lower()
1019
- score = _fuzzy_score(query_lower, label_lower)
1020
- if score >= 0: # Only include matches
1021
- scored_labels.append((score, label_lower, suffix_cache[label], label))
1022
-
1023
- if not scored_labels:
1024
- # No fuzzy matches: fall back to deterministic label sorting
1025
- return sorted(labels, key=lambda lbl: (lbl.lower(), suffix_cache[lbl]))
1026
-
1027
- # Sort by: score (desc), label (case-insensitive), id suffix, original label
1028
- scored_labels.sort(key=lambda x: (-x[0], x[1], x[2], x[3]))
1029
-
1030
- return [label for _score, _label_lower, _id_suffix, label in scored_labels]
1031
-
1032
-
1033
- # ----------------------- Structured renderer helpers -------------------- #
1034
-
1035
-
1036
- def _coerce_result_payload(result: Any) -> Any:
1037
- """Convert renderer outputs into plain dict/list structures when possible."""
1038
- try:
1039
- to_dict = getattr(result, "to_dict", None)
1040
- if callable(to_dict):
1041
- return to_dict()
1042
- except Exception:
1043
- return result
1044
- return result
1045
-
1046
-
1047
- def _ensure_displayable(payload: Any) -> Any:
1048
- """Best-effort coercion into JSON/str-safe payloads for console rendering."""
1049
- if isinstance(payload, (dict, list, str, int, float, bool)) or payload is None:
1050
- return payload
1051
-
1052
- if hasattr(payload, "__dict__"):
1053
- try:
1054
- return dict(payload)
1055
- except Exception:
1056
- try:
1057
- return dict(payload.__dict__)
1058
- except Exception:
1059
- pass
1060
-
1061
- try:
1062
- return str(payload)
1063
- except Exception:
1064
- return repr(payload)
1065
-
1066
-
1067
- def _render_markdown_output(data: Any) -> None:
1068
- """Render markdown output using Rich when available."""
1069
- try:
1070
- console.print(Markdown(str(data)))
1071
- except ImportError:
1072
- click.echo(str(data))
1073
-
1074
-
1075
- def _format_yaml_text(data: Any) -> str:
1076
- """Convert structured payloads to YAML for readability."""
1077
- try:
1078
- yaml_text = yaml.dump(
1079
- data,
1080
- sort_keys=False,
1081
- default_flow_style=False,
1082
- allow_unicode=True,
1083
- Dumper=_LiteralYamlDumper,
148
+ warnings.warn(
149
+ "Importing from glaip_sdk.cli.utils is deprecated. Use glaip_sdk.cli.core.* modules instead.",
150
+ DeprecationWarning,
151
+ stacklevel=3,
1084
152
  )
1085
- except Exception: # pragma: no cover - defensive YAML fallback
1086
- try:
1087
- return str(data)
1088
- except Exception: # pragma: no cover - defensive str fallback
1089
- return repr(data)
1090
-
1091
- yaml_text = yaml_text.rstrip()
1092
- if yaml_text.endswith("..."): # pragma: no cover - defensive YAML cleanup
1093
- yaml_text = yaml_text[:-3].rstrip()
1094
- return yaml_text
1095
-
1096
-
1097
- def _build_yaml_renderable(data: Any) -> Any:
1098
- """Return a syntax-highlighted YAML renderable when possible."""
1099
- yaml_text = _format_yaml_text(data) or "# No data"
1100
- try:
1101
- return Syntax(yaml_text, "yaml", word_wrap=False)
1102
- except Exception: # pragma: no cover - defensive syntax highlighting fallback
1103
- return yaml_text
1104
-
1105
-
1106
- def output_result(
1107
- ctx: Any,
1108
- result: Any,
1109
- title: str = "Result",
1110
- panel_title: str | None = None,
1111
- ) -> None:
1112
- """Output a result to the console with optional title.
1113
-
1114
- Args:
1115
- ctx: Click context
1116
- result: Result data to output
1117
- title: Optional title for the output
1118
- panel_title: Optional Rich panel title for structured output
1119
- """
1120
- fmt = _get_view(ctx)
1121
-
1122
- data = _coerce_result_payload(result)
1123
- data = masking.mask_payload(data)
1124
- data = _ensure_displayable(data)
1125
-
1126
- if fmt == "json":
1127
- click.echo(json.dumps(data, indent=2, default=str))
1128
- return
1129
-
1130
- if fmt == "plain":
1131
- click.echo(str(data))
1132
- return
1133
-
1134
- if fmt == "md":
1135
- _render_markdown_output(data)
1136
- return
1137
-
1138
- renderable = _build_yaml_renderable(data)
1139
- if panel_title:
1140
- console.print(AIPPanel(renderable, title=panel_title))
1141
- else:
1142
- console.print(markup_text(f"[{ACCENT_STYLE}]{title}:[/]"))
1143
- console.print(renderable)
1144
-
1145
-
1146
- # ----------------------------- List rendering ---------------------------- #
1147
-
1148
- # Threshold no longer used - fuzzy palette is always default for TTY
1149
- # _PICK_THRESHOLD = 5
1150
-
1151
-
1152
- def _normalise_rows(items: list[Any], transform_func: Callable[[Any], dict[str, Any]] | None) -> list[dict[str, Any]]:
1153
- """Convert heterogeneous item lists into table rows."""
1154
- try:
1155
- rows: list[dict[str, Any]] = []
1156
- for item in items:
1157
- if transform_func:
1158
- rows.append(transform_func(item))
1159
- elif hasattr(item, "to_dict"):
1160
- rows.append(item.to_dict())
1161
- elif hasattr(item, "__dict__"):
1162
- rows.append(vars(item))
1163
- elif isinstance(item, dict):
1164
- rows.append(item)
1165
- else:
1166
- rows.append({"value": item})
1167
- return rows
1168
- except Exception:
1169
- return []
1170
-
1171
-
1172
- def _render_plain_list(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
1173
- """Display tabular data as a simple pipe-delimited list."""
1174
- if not rows:
1175
- click.echo(f"No {title.lower()} found.")
1176
- return
1177
- for row in rows:
1178
- row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
1179
- click.echo(row_str)
1180
-
1181
-
1182
- def _render_markdown_list(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
1183
- """Display tabular data using markdown table syntax."""
1184
- if not rows:
1185
- click.echo(f"No {title.lower()} found.")
1186
- return
1187
- headers = [header for _, header, _, _ in columns]
1188
- click.echo(f"| {' | '.join(headers)} |")
1189
- click.echo(f"| {' | '.join('---' for _ in headers)} |")
1190
- for row in rows:
1191
- row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
1192
- click.echo(f"| {row_str} |")
1193
-
1194
-
1195
- def _should_sort_rows(rows: list[dict[str, Any]]) -> bool:
1196
- """Return True when rows should be name-sorted prior to rendering."""
1197
- return TABLE_SORT_ENABLED and rows and isinstance(rows[0], dict) and "name" in rows[0]
1198
-
1199
-
1200
- def _create_table(columns: list[tuple[str, str, str, int | None]], title: str) -> Any:
1201
- """Build a configured Rich table for the provided columns."""
1202
- table = AIPTable(title=title, expand=True)
1203
- for _key, header, style, width in columns:
1204
- table.add_column(header, style=style, width=width)
1205
- return table
1206
-
1207
-
1208
- def _build_table_group(rows: list[dict[str, Any]], columns: list[tuple], title: str) -> Group:
1209
- """Return a Rich group containing the table and a small footer summary."""
1210
- table = _create_table(columns, title)
1211
- for row in rows:
1212
- table.add_row(*[str(row.get(key, "N/A")) for key, _, _, _ in columns])
1213
- footer = markup_text(f"\n[dim]Total {len(rows)} items[/dim]")
1214
- return Group(table, footer)
1215
-
1216
-
1217
- def _handle_json_output(items: list[Any], rows: list[dict[str, Any]]) -> None:
1218
- """Handle JSON output format."""
1219
- data = rows if rows else [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
1220
- click.echo(json.dumps(data, indent=2, default=str))
1221
-
1222
-
1223
- def _handle_plain_output(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
1224
- """Handle plain text output format."""
1225
- _render_plain_list(rows, title, columns)
1226
-
1227
-
1228
- def _handle_markdown_output(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
1229
- """Handle markdown output format."""
1230
- _render_markdown_list(rows, title, columns)
1231
-
1232
-
1233
- def _handle_empty_items(title: str) -> None:
1234
- """Handle case when no items are found."""
1235
- console.print(markup_text(f"[{WARNING_STYLE}]No {title.lower()} found.[/]"))
1236
-
1237
-
1238
- def _should_use_fuzzy_picker() -> bool:
1239
- """Return True when the interactive fuzzy picker can be shown."""
1240
- return console.is_terminal and os.isatty(1)
1241
-
1242
-
1243
- def _try_fuzzy_pick(rows: list[dict[str, Any]], columns: list[tuple], title: str) -> dict[str, Any] | None:
1244
- """Best-effort fuzzy selection; returns None if the picker fails."""
1245
- if not _should_use_fuzzy_picker():
1246
- return None
1247
-
1248
- try:
1249
- return _fuzzy_pick(rows, columns, title)
1250
- except Exception:
1251
- logger.debug("Fuzzy picker failed; falling back to table output", exc_info=True)
1252
- return None
1253
-
1254
-
1255
- def _resource_tip_command(title: str) -> str | None:
1256
- """Resolve the follow-up command hint for the given table title."""
1257
- title_lower = title.lower()
1258
- mapping = {
1259
- "agent": ("agents get", "agents"),
1260
- "tool": ("tools get", None),
1261
- "mcp": ("mcps get", None),
1262
- "model": ("models list", None), # models only ship a list command
1263
- }
1264
- for keyword, (cli_command, slash_command) in mapping.items():
1265
- if keyword in title_lower:
1266
- return command_hint(cli_command, slash_command=slash_command)
1267
- return command_hint("agents get", slash_command="agents")
1268
-
1269
-
1270
- def _print_selection_tip(title: str) -> None:
1271
- """Print the contextual follow-up tip after a fuzzy selection."""
1272
- tip_cmd = _resource_tip_command(title)
1273
- if tip_cmd:
1274
- console.print(markup_text(f"\n[dim]Tip: use `{tip_cmd} <ID>` for details[/dim]"))
1275
-
1276
-
1277
- def _handle_fuzzy_pick_selection(rows: list[dict[str, Any]], columns: list[tuple], title: str) -> bool:
1278
- """Handle fuzzy picker selection.
1279
-
1280
- Returns:
1281
- True if a resource was selected and displayed,
1282
- False if cancelled/no selection.
1283
- """
1284
- picked = _try_fuzzy_pick(rows, columns, title)
1285
- if picked is None:
1286
- return False
1287
-
1288
- table = _create_table(columns, title)
1289
- table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
1290
- console.print(table)
1291
- _print_selection_tip(title)
1292
- return True
1293
-
1294
-
1295
- def _handle_table_output(
1296
- rows: list[dict[str, Any]],
1297
- columns: list[tuple],
1298
- title: str,
1299
- *,
1300
- use_pager: bool | None = None,
1301
- ) -> None:
1302
- """Handle table output with paging."""
1303
- content = _build_table_group(rows, columns, title)
1304
- should_page = (
1305
- pager._should_page_output(len(rows), console.is_terminal and os.isatty(1)) if use_pager is None else use_pager
1306
- )
1307
-
1308
- if should_page:
1309
- ansi = pager._render_ansi(content)
1310
- if not pager._page_with_system_pager(ansi):
1311
- with console.pager(styles=True):
1312
- console.print(content)
1313
- else:
1314
- console.print(content)
1315
-
1316
-
1317
- def output_list(
1318
- ctx: Any,
1319
- items: list[Any],
1320
- title: str,
1321
- columns: list[tuple[str, str, str, int | None]],
1322
- transform_func: Callable | None = None,
1323
- *,
1324
- skip_picker: bool = False,
1325
- use_pager: bool | None = None,
1326
- ) -> None:
1327
- """Display a list with optional fuzzy palette for quick selection."""
1328
- fmt = _get_view(ctx)
1329
- rows = _normalise_rows(items, transform_func)
1330
- rows = masking.mask_rows(rows)
1331
-
1332
- if fmt == "json":
1333
- _handle_json_output(items, rows)
1334
- return
1335
-
1336
- if fmt == "plain":
1337
- _handle_plain_output(rows, title, columns)
1338
- return
1339
-
1340
- if fmt == "md":
1341
- _handle_markdown_output(rows, title, columns)
1342
- return
1343
-
1344
- if not items:
1345
- _handle_empty_items(title)
1346
- return
1347
-
1348
- if _should_sort_rows(rows):
1349
- try:
1350
- rows = sorted(rows, key=lambda r: str(r.get("name", "")).lower())
1351
- except Exception:
1352
- pass
1353
-
1354
- if not skip_picker and _handle_fuzzy_pick_selection(rows, columns, title):
1355
- return
1356
-
1357
- _handle_table_output(rows, columns, title, use_pager=use_pager)
1358
-
1359
-
1360
- # ------------------------- Ambiguity handling --------------------------- #
1361
-
1362
-
1363
- def coerce_to_row(item: Any, keys: list[str]) -> dict[str, Any]:
1364
- """Coerce an item (dict or object) to a row dict with specified keys.
1365
-
1366
- Args:
1367
- item: The item to coerce (dict or object with attributes)
1368
- keys: List of keys/attribute names to extract
1369
-
1370
- Returns:
1371
- Dict with the extracted values, "N/A" for missing values
1372
- """
1373
- result = {}
1374
- for key in keys:
1375
- if isinstance(item, dict):
1376
- value = item.get(key, "N/A")
1377
- else:
1378
- value = getattr(item, key, "N/A")
1379
- result[key] = str(value) if value is not None else "N/A"
1380
- return result
1381
-
1382
-
1383
- def _register_renderer_with_session(ctx: Any, renderer: RichStreamRenderer) -> None:
1384
- """Attach renderer to an active slash session when present."""
1385
- try:
1386
- ctx_obj = getattr(ctx, "obj", None)
1387
- session = ctx_obj.get("_slash_session") if isinstance(ctx_obj, dict) else None
1388
- if session and hasattr(session, "register_active_renderer"):
1389
- session.register_active_renderer(renderer)
1390
- except Exception:
1391
- # Never let session bookkeeping break renderer creation
1392
- pass
1393
-
1394
-
1395
- def build_renderer(
1396
- _ctx: Any,
1397
- *,
1398
- save_path: str | os.PathLike[str] | None,
1399
- verbose: bool = False,
1400
- _tty_enabled: bool = True,
1401
- live: bool | None = None,
1402
- snapshots: bool | None = None,
1403
- ) -> tuple[RichStreamRenderer, Console | CapturingConsole]:
1404
- """Build renderer and capturing console for CLI commands.
1405
-
1406
- Args:
1407
- _ctx: Click context object for CLI operations.
1408
- save_path: Path to save output to (enables capturing console).
1409
- verbose: Whether to enable verbose mode.
1410
- _tty_enabled: Whether TTY is available for interactive features.
1411
- live: Whether to enable live rendering mode (overrides verbose default).
1412
- snapshots: Whether to capture and store snapshots.
1413
-
1414
- Returns:
1415
- Tuple of (renderer, capturing_console) for streaming output.
1416
- """
1417
- # Use capturing console if saving output
1418
- working_console = CapturingConsole(console, capture=True) if save_path else console
1419
-
1420
- # Configure renderer based on verbose mode and explicit overrides
1421
- live_enabled = bool(live) if live is not None else not verbose
1422
- cfg_overrides = {
1423
- "live": live_enabled,
1424
- "append_finished_snapshots": bool(snapshots) if snapshots is not None else False,
1425
- }
1426
- renderer_console = (
1427
- working_console.original_console if isinstance(working_console, CapturingConsole) else working_console
1428
- )
1429
- factory = make_verbose_renderer if verbose else make_default_renderer
1430
- factory_options = RendererFactoryOptions(
1431
- console=renderer_console,
1432
- cfg_overrides=cfg_overrides,
1433
- verbose=verbose if factory is make_default_renderer else None,
1434
- )
1435
- renderer = factory_options.build(factory)
1436
-
1437
- # Link the renderer back to the slash session when running from the palette.
1438
- _register_renderer_with_session(_ctx, renderer)
1439
-
1440
- return renderer, working_console
1441
-
1442
-
1443
- def _build_resource_labels(resources: list[Any]) -> tuple[list[str], dict[str, Any]]:
1444
- """Build unique display labels for resources."""
1445
- labels = []
1446
- by_label: dict[str, Any] = {}
1447
-
1448
- for resource in resources:
1449
- name = getattr(resource, "name", "Unknown")
1450
- _id = getattr(resource, "id", "Unknown")
1451
-
1452
- # Create display label
1453
- label_parts = []
1454
- if name and name != "Unknown":
1455
- label_parts.append(name)
1456
- label_parts.append(f"[{_id[:8]}...]") # Show first 8 chars of ID
1457
- label = " • ".join(label_parts)
1458
-
1459
- # Ensure uniqueness
1460
- if label in by_label:
1461
- i = 2
1462
- base = label
1463
- while f"{base} #{i}" in by_label:
1464
- i += 1
1465
- label = f"{base} #{i}"
1466
-
1467
- labels.append(label)
1468
- by_label[label] = resource
1469
-
1470
- return labels, by_label
1471
-
1472
-
1473
- def _fuzzy_pick_for_resources(
1474
- resources: list[Any], resource_type: str, _search_term: str
1475
- ) -> Any | None: # pragma: no cover - interactive selection helper
1476
- """Fuzzy picker for resource objects, similar to _fuzzy_pick but without column dependencies.
1477
-
1478
- Args:
1479
- resources: List of resource objects to choose from
1480
- resource_type: Type of resource (e.g., "agent", "tool")
1481
- search_term: The search term that led to multiple matches
1482
-
1483
- Returns:
1484
- Selected resource object or None if cancelled/no selection
1485
- """
1486
- if not _check_fuzzy_pick_requirements():
1487
- return None
1488
-
1489
- # Build labels and mapping
1490
- labels, by_label = _build_resource_labels(resources)
1491
-
1492
- # Create fuzzy completer
1493
- completer = _FuzzyCompleter(labels)
1494
- answer = _prompt_with_auto_select(
1495
- f"Find {ICON_AGENT} {resource_type.title()}: ",
1496
- completer,
1497
- labels,
1498
- )
1499
- if answer is None:
1500
- return None
1501
-
1502
- return _perform_fuzzy_search(answer, labels, by_label) if answer else None
1503
-
1504
-
1505
- def _resolve_by_id(ref: str, get_by_id: Callable) -> Any | None:
1506
- """Resolve resource by UUID if ref is a valid UUID."""
1507
- if is_uuid(ref):
1508
- return get_by_id(ref)
1509
- return None
1510
-
1511
-
1512
- def _resolve_by_name_multiple_with_select(matches: list[Any], select: int) -> Any:
1513
- """Resolve multiple matches using select parameter."""
1514
- idx = int(select) - 1
1515
- if not (0 <= idx < len(matches)):
1516
- raise click.ClickException(f"--select must be 1..{len(matches)}")
1517
- return matches[idx]
1518
-
1519
-
1520
- def _resolve_by_name_multiple_fuzzy(ctx: Any, ref: str, matches: list[Any], label: str) -> Any:
1521
- """Resolve multiple matches preferring the fuzzy picker interface."""
1522
- return handle_ambiguous_resource(ctx, label.lower(), ref, matches, interface_preference="fuzzy")
1523
-
1524
-
1525
- def _resolve_by_name_multiple_questionary(ctx: Any, ref: str, matches: list[Any], label: str) -> Any:
1526
- """Resolve multiple matches preferring the questionary interface."""
1527
- return handle_ambiguous_resource(ctx, label.lower(), ref, matches, interface_preference="questionary")
1528
-
1529
-
1530
- def resolve_resource(
1531
- ctx: Any,
1532
- ref: str,
1533
- *,
1534
- get_by_id: Callable,
1535
- find_by_name: Callable,
1536
- label: str,
1537
- select: int | None = None,
1538
- interface_preference: str = "fuzzy",
1539
- status_indicator: Any | None = None,
1540
- ) -> Any | None:
1541
- """Resolve resource reference (ID or name) with ambiguity handling.
1542
-
1543
- Args:
1544
- ctx: Click context
1545
- ref: Resource reference (ID or name)
1546
- get_by_id: Function to get resource by ID
1547
- find_by_name: Function to find resources by name
1548
- label: Resource type label for error messages
1549
- select: Optional selection index for ambiguity resolution
1550
- interface_preference: "fuzzy" for fuzzy picker, "questionary" for up/down list
1551
- status_indicator: Optional Rich status indicator for wait animations
1552
-
1553
- Returns:
1554
- Resolved resource object
1555
- """
1556
- spinner = status_indicator
1557
- _spinner_update(spinner, f"[bold blue]Resolving {label}…[/bold blue]")
1558
-
1559
- # Try to resolve by ID first
1560
- _spinner_update(spinner, f"[bold blue]Fetching {label} by ID…[/bold blue]")
1561
- result = _resolve_by_id(ref, get_by_id)
1562
- if result is not None:
1563
- _spinner_update(spinner, f"[{SUCCESS_STYLE}]{label} found[/]")
1564
- return result
1565
-
1566
- # If get_by_id returned None, the resource doesn't exist
1567
- if is_uuid(ref):
1568
- _spinner_stop(spinner)
1569
- raise click.ClickException(f"{label} '{ref}' not found")
1570
-
1571
- # Find resources by name
1572
- _spinner_update(spinner, f"[bold blue]Searching {label}s matching '{ref}'…[/bold blue]")
1573
- matches = find_by_name(name=ref)
1574
- if not matches:
1575
- _spinner_stop(spinner)
1576
- raise click.ClickException(f"{label} '{ref}' not found")
1577
-
1578
- if len(matches) == 1:
1579
- _spinner_update(spinner, f"[{SUCCESS_STYLE}]{label} found[/]")
1580
- return matches[0]
1581
-
1582
- # Multiple matches found, handle ambiguity
1583
- if select:
1584
- _spinner_stop(spinner)
1585
- return _resolve_by_name_multiple_with_select(matches, select)
1586
-
1587
- # Choose interface based on preference
1588
- _spinner_stop(spinner)
1589
- preference = (interface_preference or "fuzzy").lower()
1590
- if preference not in {"fuzzy", "questionary"}:
1591
- preference = "fuzzy"
1592
- if preference == "fuzzy":
1593
- return _resolve_by_name_multiple_fuzzy(ctx, ref, matches, label)
1594
- else:
1595
- return _resolve_by_name_multiple_questionary(ctx, ref, matches, label)
1596
-
1597
-
1598
- def _handle_json_view_ambiguity(matches: list[Any]) -> Any:
1599
- """Handle ambiguity in JSON view by returning first match."""
1600
- return matches[0]
1601
-
1602
-
1603
- def _handle_questionary_ambiguity(resource_type: str, ref: str, matches: list[Any]) -> Any:
1604
- """Handle ambiguity using questionary interactive interface."""
1605
- questionary_module, choice_cls = _load_questionary_module()
1606
- if not (questionary_module and os.getenv("TERM") and os.isatty(0) and os.isatty(1)):
1607
- raise click.ClickException("Interactive selection not available")
1608
-
1609
- # Escape special characters for questionary
1610
- safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
1611
- safe_ref = ref.replace("{", "{{").replace("}", "}}")
1612
-
1613
- picked_idx = questionary_module.select(
1614
- f"Multiple {safe_resource_type}s match '{safe_ref}'. Pick one:",
1615
- choices=[
1616
- _make_questionary_choice(
1617
- choice_cls,
1618
- title=(
1619
- f"{getattr(m, 'name', '—').replace('{', '{{').replace('}', '}}')} — "
1620
- f"{getattr(m, 'id', '').replace('{', '{{').replace('}', '}}')}"
1621
- ),
1622
- value=i,
1623
- )
1624
- for i, m in enumerate(matches)
1625
- ],
1626
- use_indicator=True,
1627
- qmark="🧭",
1628
- instruction="↑/↓ to select • Enter to confirm",
1629
- ).ask()
1630
- if picked_idx is None:
1631
- raise click.ClickException("Selection cancelled")
1632
- return matches[picked_idx]
1633
-
1634
-
1635
- def _handle_fallback_numeric_ambiguity(resource_type: str, ref: str, matches: list[Any]) -> Any:
1636
- """Handle ambiguity using numeric prompt fallback."""
1637
- # Escape special characters for display
1638
- safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
1639
- safe_ref = ref.replace("{", "{{").replace("}", "}}")
1640
-
1641
- console.print(markup_text(f"[{WARNING_STYLE}]Multiple {safe_resource_type}s found matching '{safe_ref}':[/]"))
1642
- table = AIPTable(
1643
- title=f"Select {safe_resource_type.title()}",
1644
- )
1645
- table.add_column("#", style="dim", width=3)
1646
- table.add_column("ID", style="dim", width=36)
1647
- table.add_column("Name", style=ACCENT_STYLE)
1648
- for i, m in enumerate(matches, 1):
1649
- table.add_row(str(i), str(getattr(m, "id", "")), str(getattr(m, "name", "")))
1650
- console.print(table)
1651
- choice_str = click.prompt(
1652
- f"Select {safe_resource_type} (1-{len(matches)})",
1653
- )
1654
- try:
1655
- choice = int(choice_str)
1656
- except ValueError as err:
1657
- raise click.ClickException("Invalid selection") from err
1658
- if 1 <= choice <= len(matches):
1659
- return matches[choice - 1]
1660
- raise click.ClickException("Invalid selection")
1661
-
1662
-
1663
- def _should_fallback_to_numeric_prompt(exception: Exception) -> bool:
1664
- """Determine if we should fallback to numeric prompt for this exception."""
1665
- # Re-raise cancellation - user explicitly cancelled
1666
- if "Selection cancelled" in str(exception):
1667
- return False
1668
-
1669
- # Fall back to numeric prompt for other exceptions
1670
- return True
1671
-
1672
-
1673
- def _normalize_interface_preference(preference: str) -> str:
1674
- """Normalize and validate interface preference."""
1675
- normalized = (preference or "questionary").lower()
1676
- return normalized if normalized in {"fuzzy", "questionary"} else "questionary"
1677
-
1678
-
1679
- def _get_interface_order(preference: str) -> tuple[str, str]:
1680
- """Get the ordered interface preferences."""
1681
- interface_orders = {
1682
- "fuzzy": ("fuzzy", "questionary"),
1683
- "questionary": ("questionary", "fuzzy"),
1684
- }
1685
- return interface_orders.get(preference, ("questionary", "fuzzy"))
1686
-
1687
-
1688
- def _try_fuzzy_selection(
1689
- resource_type: str,
1690
- ref: str,
1691
- matches: list[Any],
1692
- ) -> Any | None:
1693
- """Try fuzzy interface selection."""
1694
- picked = _fuzzy_pick_for_resources(matches, resource_type, ref)
1695
- return picked if picked else None
1696
-
1697
-
1698
- def _try_questionary_selection(
1699
- resource_type: str,
1700
- ref: str,
1701
- matches: list[Any],
1702
- ) -> Any | None:
1703
- """Try questionary interface selection."""
1704
- try:
1705
- return _handle_questionary_ambiguity(resource_type, ref, matches)
1706
- except Exception as exc:
1707
- if not _should_fallback_to_numeric_prompt(exc):
1708
- raise
1709
- return None
1710
-
1711
-
1712
- def _try_interface_selection(
1713
- interface_order: tuple[str, str],
1714
- resource_type: str,
1715
- ref: str,
1716
- matches: list[Any],
1717
- ) -> Any | None:
1718
- """Try interface selection in order, return result or None if all failed."""
1719
- interface_handlers = {
1720
- "fuzzy": _try_fuzzy_selection,
1721
- "questionary": _try_questionary_selection,
1722
- }
1723
-
1724
- for interface in interface_order:
1725
- handler = interface_handlers.get(interface)
1726
- if handler:
1727
- result = handler(resource_type, ref, matches)
1728
- if result:
1729
- return result
1730
-
1731
- return None
1732
-
1733
-
1734
- def handle_ambiguous_resource(
1735
- ctx: Any,
1736
- resource_type: str,
1737
- ref: str,
1738
- matches: list[Any],
1739
- *,
1740
- interface_preference: str = "questionary",
1741
- ) -> Any:
1742
- """Handle multiple resource matches gracefully."""
1743
- if _get_view(ctx) == "json":
1744
- return _handle_json_view_ambiguity(matches)
1745
-
1746
- preference = _normalize_interface_preference(interface_preference)
1747
- interface_order = _get_interface_order(preference)
1748
-
1749
- result = _try_interface_selection(interface_order, resource_type, ref, matches)
1750
-
1751
- if result is not None:
1752
- return result
1753
-
1754
- return _handle_fallback_numeric_ambiguity(resource_type, ref, matches)
153
+ _warned = True
154
+
155
+
156
+ _warn_once()
157
+
158
+ # Re-export everything for backward compatibility
159
+ __all__ = [
160
+ # Context
161
+ "bind_slash_session_context",
162
+ "get_client",
163
+ "get_ctx_value", # Re-exported from context module
164
+ "handle_best_effort_check",
165
+ "restore_slash_session_context",
166
+ # Prompting
167
+ "_FuzzyCompleter", # Private class for backward compatibility (used in tests)
168
+ "_asyncio_loop_running", # Private function for backward compatibility (used in tests)
169
+ "_basic_prompt", # Private function for backward compatibility (used in tests)
170
+ "_build_display_parts", # Private function for backward compatibility (used in tests)
171
+ "_build_primary_parts", # Private function for backward compatibility (used in tests)
172
+ "_build_resource_labels", # Private function for backward compatibility (used in tests)
173
+ "_build_unique_labels", # Private function for backward compatibility (used in tests)
174
+ "_calculate_consecutive_bonus", # Private function for backward compatibility (used in tests)
175
+ "_calculate_exact_match_bonus", # Private function for backward compatibility (used in tests)
176
+ "_calculate_length_bonus", # Private function for backward compatibility (used in tests)
177
+ "_check_fuzzy_pick_requirements", # Private function for backward compatibility (used in tests)
178
+ "_extract_display_fields", # Private function for backward compatibility (used in tests)
179
+ "_extract_fallback_values", # Private function for backward compatibility (used in tests)
180
+ "_extract_id_suffix", # Private function for backward compatibility (used in tests)
181
+ "_fuzzy_pick", # Private function for backward compatibility (used in tests)
182
+ "_fuzzy_pick_for_resources",
183
+ "_fuzzy_score", # Private function for backward compatibility (used in tests)
184
+ "_get_fallback_columns", # Private function for backward compatibility (used in tests)
185
+ "_is_fuzzy_match", # Private function for backward compatibility (used in tests)
186
+ "_is_standard_field", # Private function for backward compatibility (used in tests)
187
+ "_load_questionary_module", # Private function for backward compatibility (used in tests)
188
+ "_make_questionary_choice", # Private function for backward compatibility (used in tests)
189
+ "_perform_fuzzy_search", # Private function for backward compatibility (used in tests)
190
+ "_prompt_with_auto_select", # Private function for backward compatibility (used in tests)
191
+ "_rank_labels", # Private function for backward compatibility (used in tests)
192
+ "_row_display", # Private function for backward compatibility (used in tests)
193
+ "_run_questionary_in_thread", # Private function for backward compatibility (used in tests)
194
+ "_strip_spaces_for_matching", # Private function for backward compatibility (used in tests)
195
+ "prompt_export_choice_questionary",
196
+ "questionary_safe_ask",
197
+ # Rendering
198
+ "_can_use_spinner", # Private function for backward compatibility (used in tests)
199
+ "_register_renderer_with_session", # Private function for backward compatibility (used in tests)
200
+ "_spinner_stop", # Private function for backward compatibility (used in tests)
201
+ "_spinner_update", # Private function for backward compatibility (used in tests)
202
+ "_stream_supports_tty", # Private function for backward compatibility (used in tests)
203
+ "build_renderer",
204
+ "console", # Module-level variable for backward compatibility
205
+ "logger", # Module-level variable for backward compatibility
206
+ "questionary", # Module-level variable for backward compatibility
207
+ "spinner_context",
208
+ "stop_spinner",
209
+ "update_spinner",
210
+ "with_client_and_spinner",
211
+ # Output
212
+ "_LiteralYamlDumper", # Private class for backward compatibility (used in tests)
213
+ "_build_table_group", # Private function for backward compatibility (used in tests)
214
+ "_build_yaml_renderable", # Private function for backward compatibility (used in tests)
215
+ "_coerce_result_payload", # Private function for backward compatibility (used in tests)
216
+ "_create_table", # Private function for backward compatibility (used in tests)
217
+ "_ensure_displayable", # Private function for backward compatibility (used in tests)
218
+ "_format_yaml_text", # Private function for backward compatibility (used in tests)
219
+ "_get_interface_order", # Private function for backward compatibility (used in tests)
220
+ "_handle_empty_items", # Private function for backward compatibility (used in tests)
221
+ "_handle_fallback_numeric_ambiguity", # Private function for backward compatibility (used in tests)
222
+ "_handle_fuzzy_pick_selection", # Private function for backward compatibility (used in tests)
223
+ "_handle_json_output", # Private function for backward compatibility (used in tests)
224
+ "_handle_json_view_ambiguity", # Private function for backward compatibility (used in tests)
225
+ "_handle_markdown_output", # Private function for backward compatibility (used in tests)
226
+ "_handle_plain_output", # Private function for backward compatibility (used in tests)
227
+ "_handle_questionary_ambiguity", # Private function for backward compatibility (used in tests)
228
+ "_handle_table_output", # Private function for backward compatibility (used in tests)
229
+ "_literal_str_representer", # Private function for backward compatibility (used in tests)
230
+ "_normalise_rows", # Private function for backward compatibility (used in tests)
231
+ "_normalize_interface_preference", # Private function for backward compatibility (used in tests)
232
+ "_print_selection_tip", # Private function for backward compatibility (used in tests)
233
+ "_render_markdown_list", # Private function for backward compatibility (used in tests)
234
+ "_render_markdown_output", # Private function for backward compatibility (used in tests)
235
+ "_render_plain_list", # Private function for backward compatibility (used in tests)
236
+ "_resolve_by_id", # Private function for backward compatibility (used in tests)
237
+ "_resolve_by_name_multiple_fuzzy", # Private function for backward compatibility (used in tests)
238
+ "_resolve_by_name_multiple_questionary", # Private function for backward compatibility (used in tests)
239
+ "_resolve_by_name_multiple_with_select", # Private function for backward compatibility (used in tests)
240
+ "_resource_tip_command", # Private function for backward compatibility (used in tests)
241
+ "_should_fallback_to_numeric_prompt", # Private function for backward compatibility (used in tests)
242
+ "_should_sort_rows", # Private function for backward compatibility (used in tests)
243
+ "_should_use_fuzzy_picker", # Private function for backward compatibility (used in tests)
244
+ "_try_fuzzy_pick", # Private function for backward compatibility (used in tests)
245
+ "_try_fuzzy_selection", # Private function for backward compatibility (used in tests)
246
+ "_try_interface_selection", # Private function for backward compatibility (used in tests)
247
+ "_try_questionary_selection", # Private function for backward compatibility (used in tests)
248
+ "coerce_to_row",
249
+ "command_hint", # Re-exported from hints module
250
+ "detect_export_format",
251
+ "fetch_resource_for_export",
252
+ "format_datetime_fields",
253
+ "format_size",
254
+ "handle_ambiguous_resource",
255
+ "handle_resource_export",
256
+ "output_list",
257
+ "output_result",
258
+ "parse_json_line",
259
+ "resolve_resource",
260
+ "sdk_version",
261
+ # Utils
262
+ "is_uuid", # Re-exported from glaip_sdk.utils for backward compatibility
263
+ ]