glaip-sdk 0.1.3__py3-none-any.whl → 0.6.19__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1196 -0
  5. glaip_sdk/branding.py +13 -0
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/auth.py +254 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents.py +213 -73
  11. glaip_sdk/cli/commands/common_config.py +104 -0
  12. glaip_sdk/cli/commands/configure.py +729 -113
  13. glaip_sdk/cli/commands/mcps.py +241 -72
  14. glaip_sdk/cli/commands/models.py +11 -5
  15. glaip_sdk/cli/commands/tools.py +49 -57
  16. glaip_sdk/cli/commands/transcripts.py +755 -0
  17. glaip_sdk/cli/config.py +48 -4
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +8 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +35 -19
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +6 -3
  28. glaip_sdk/cli/main.py +241 -121
  29. glaip_sdk/cli/masking.py +21 -33
  30. glaip_sdk/cli/pager.py +9 -10
  31. glaip_sdk/cli/parsers/__init__.py +1 -3
  32. glaip_sdk/cli/slash/__init__.py +0 -9
  33. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  34. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  35. glaip_sdk/cli/slash/agent_session.py +62 -21
  36. glaip_sdk/cli/slash/prompt.py +21 -0
  37. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  38. glaip_sdk/cli/slash/session.py +771 -140
  39. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  40. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  41. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  42. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  43. glaip_sdk/cli/slash/tui/loading.py +58 -0
  44. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  45. glaip_sdk/cli/transcript/__init__.py +12 -52
  46. glaip_sdk/cli/transcript/cache.py +255 -44
  47. glaip_sdk/cli/transcript/capture.py +27 -1
  48. glaip_sdk/cli/transcript/history.py +815 -0
  49. glaip_sdk/cli/transcript/viewer.py +72 -499
  50. glaip_sdk/cli/update_notifier.py +14 -5
  51. glaip_sdk/cli/utils.py +243 -1252
  52. glaip_sdk/cli/validators.py +5 -6
  53. glaip_sdk/client/__init__.py +2 -1
  54. glaip_sdk/client/_agent_payloads.py +45 -9
  55. glaip_sdk/client/agent_runs.py +147 -0
  56. glaip_sdk/client/agents.py +291 -35
  57. glaip_sdk/client/base.py +1 -0
  58. glaip_sdk/client/main.py +19 -10
  59. glaip_sdk/client/mcps.py +122 -12
  60. glaip_sdk/client/run_rendering.py +466 -89
  61. glaip_sdk/client/shared.py +21 -0
  62. glaip_sdk/client/tools.py +155 -10
  63. glaip_sdk/config/constants.py +11 -0
  64. glaip_sdk/hitl/__init__.py +15 -0
  65. glaip_sdk/hitl/local.py +151 -0
  66. glaip_sdk/mcps/__init__.py +21 -0
  67. glaip_sdk/mcps/base.py +345 -0
  68. glaip_sdk/models/__init__.py +90 -0
  69. glaip_sdk/models/agent.py +47 -0
  70. glaip_sdk/models/agent_runs.py +116 -0
  71. glaip_sdk/models/common.py +42 -0
  72. glaip_sdk/models/mcp.py +33 -0
  73. glaip_sdk/models/tool.py +33 -0
  74. glaip_sdk/payload_schemas/__init__.py +1 -13
  75. glaip_sdk/registry/__init__.py +55 -0
  76. glaip_sdk/registry/agent.py +164 -0
  77. glaip_sdk/registry/base.py +139 -0
  78. glaip_sdk/registry/mcp.py +253 -0
  79. glaip_sdk/registry/tool.py +232 -0
  80. glaip_sdk/rich_components.py +58 -2
  81. glaip_sdk/runner/__init__.py +59 -0
  82. glaip_sdk/runner/base.py +84 -0
  83. glaip_sdk/runner/deps.py +112 -0
  84. glaip_sdk/runner/langgraph.py +870 -0
  85. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  86. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  87. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  88. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  89. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  90. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  91. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  92. glaip_sdk/tools/__init__.py +22 -0
  93. glaip_sdk/tools/base.py +435 -0
  94. glaip_sdk/utils/__init__.py +58 -12
  95. glaip_sdk/utils/a2a/__init__.py +34 -0
  96. glaip_sdk/utils/a2a/event_processor.py +188 -0
  97. glaip_sdk/utils/bundler.py +267 -0
  98. glaip_sdk/utils/client.py +111 -0
  99. glaip_sdk/utils/client_utils.py +39 -7
  100. glaip_sdk/utils/datetime_helpers.py +58 -0
  101. glaip_sdk/utils/discovery.py +78 -0
  102. glaip_sdk/utils/display.py +23 -15
  103. glaip_sdk/utils/export.py +143 -0
  104. glaip_sdk/utils/general.py +0 -33
  105. glaip_sdk/utils/import_export.py +12 -7
  106. glaip_sdk/utils/import_resolver.py +492 -0
  107. glaip_sdk/utils/instructions.py +101 -0
  108. glaip_sdk/utils/rendering/__init__.py +115 -1
  109. glaip_sdk/utils/rendering/formatting.py +5 -30
  110. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  111. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  112. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  113. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  114. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  115. glaip_sdk/utils/rendering/models.py +1 -0
  116. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  117. glaip_sdk/utils/rendering/renderer/base.py +275 -1476
  118. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  119. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  120. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  121. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  122. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  123. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  124. glaip_sdk/utils/rendering/state.py +204 -0
  125. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  126. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  127. glaip_sdk/utils/rendering/steps/format.py +176 -0
  128. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  129. glaip_sdk/utils/rendering/timing.py +36 -0
  130. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  131. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  132. glaip_sdk/utils/resource_refs.py +25 -13
  133. glaip_sdk/utils/runtime_config.py +425 -0
  134. glaip_sdk/utils/serialization.py +18 -0
  135. glaip_sdk/utils/sync.py +142 -0
  136. glaip_sdk/utils/tool_detection.py +33 -0
  137. glaip_sdk/utils/tool_storage_provider.py +140 -0
  138. glaip_sdk/utils/validation.py +16 -24
  139. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/METADATA +56 -21
  140. glaip_sdk-0.6.19.dist-info/RECORD +163 -0
  141. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/WHEEL +2 -1
  142. glaip_sdk-0.6.19.dist-info/entry_points.txt +2 -0
  143. glaip_sdk-0.6.19.dist-info/top_level.txt +1 -0
  144. glaip_sdk/models.py +0 -240
  145. glaip_sdk-0.1.3.dist-info/RECORD +0 -83
  146. glaip_sdk-0.1.3.dist-info/entry_points.txt +0 -3
glaip_sdk/cli/utils.py CHANGED
@@ -1,1272 +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 importlib
11
- import json
12
- import logging
13
- import os
14
- import sys
15
- from collections.abc import Callable, Iterable
16
- from contextlib import AbstractContextManager, nullcontext
17
- from typing import TYPE_CHECKING, Any, cast
18
-
19
- import click
20
- from rich.console import Console, Group
21
- from rich.markdown import Markdown
22
- from rich.pretty import Pretty
15
+ import threading
16
+ import warnings
23
17
 
24
- from glaip_sdk.branding import (
25
- ACCENT_STYLE,
26
- HINT_COMMAND_STYLE,
27
- HINT_DESCRIPTION_COLOR,
28
- SUCCESS_STYLE,
29
- 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,
30
24
  )
31
- from glaip_sdk.cli.rich_helpers import markup_text
32
- from glaip_sdk.icons import ICON_AGENT
33
- from glaip_sdk.rich_components import AIPPanel
34
-
35
- # Optional interactive deps (fuzzy palette)
36
- try:
37
- from prompt_toolkit.buffer import Buffer
38
- from prompt_toolkit.completion import Completion
39
- from prompt_toolkit.selection import SelectionType
40
- from prompt_toolkit.shortcuts import PromptSession, prompt
41
-
42
- _HAS_PTK = True
43
- except Exception: # pragma: no cover - optional dependency
44
- Buffer = None # type: ignore[assignment]
45
- SelectionType = None # type: ignore[assignment]
46
- PromptSession = None # type: ignore[assignment]
47
- prompt = None # type: ignore[assignment]
48
- _HAS_PTK = False
49
-
50
- try:
51
- import questionary
52
- except Exception: # pragma: no cover - optional dependency
53
- questionary = None
54
-
55
- if TYPE_CHECKING: # pragma: no cover - import-only during type checking
56
- from glaip_sdk import Client
57
- from glaip_sdk.cli import masking, pager
58
- from glaip_sdk.cli.config import load_config
59
- from glaip_sdk.cli.context import (
60
- _get_view,
61
- 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,
62
75
  )
63
- from glaip_sdk.cli.context import (
64
- 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,
65
108
  )
66
- from glaip_sdk.rich_components import AIPTable
67
- from glaip_sdk.utils import is_uuid
68
- from glaip_sdk.utils.rendering.renderer import (
69
- CapturingConsole,
70
- RendererConfig,
71
- RichStreamRenderer,
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,
72
121
  )
73
122
 
74
- console = Console()
75
- pager.console = console
76
- logger = logging.getLogger("glaip_sdk.cli.utils")
77
-
78
-
79
- # ----------------------------- Context helpers ---------------------------- #
80
-
81
-
82
- def detect_export_format(file_path: str | os.PathLike[str]) -> str:
83
- """Backward-compatible proxy to `glaip_sdk.cli.context.detect_export_format`."""
84
- return _detect_export_format(file_path)
85
-
86
-
87
- def in_slash_mode(ctx: click.Context | None = None) -> bool:
88
- """Return True when running inside the slash command palette."""
89
- if ctx is None:
90
- try:
91
- ctx = click.get_current_context(silent=True)
92
- except RuntimeError:
93
- ctx = None
94
-
95
- if ctx is None:
96
- return False
97
-
98
- obj = getattr(ctx, "obj", None)
99
- if isinstance(obj, dict):
100
- return bool(obj.get("_slash_session"))
101
-
102
- return bool(getattr(obj, "_slash_session", False))
103
-
104
-
105
- def command_hint(
106
- cli_command: str | None,
107
- slash_command: str | None = None,
108
- *,
109
- ctx: click.Context | None = None,
110
- ) -> str | None:
111
- """Return the appropriate command string for the current mode.
112
-
113
- Args:
114
- cli_command: Command string without the ``aip`` prefix (e.g., ``"status"``).
115
- slash_command: Slash command counterpart (e.g., ``"status"`` or ``"/status"``).
116
- ctx: Optional Click context override.
117
-
118
- Returns:
119
- The formatted command string for the active mode, or ``None`` when no
120
- equivalent command exists in that mode.
121
- """
122
- if in_slash_mode(ctx):
123
- if not slash_command:
124
- return None
125
- return slash_command if slash_command.startswith("/") else f"/{slash_command}"
126
-
127
- if not cli_command:
128
- return None
129
- return f"aip {cli_command}"
130
-
131
-
132
- def format_command_hint(
133
- command: str | None,
134
- description: str | None = None,
135
- ) -> str | None:
136
- """Return a Rich markup string that highlights a command hint.
137
-
138
- Args:
139
- command: Command text to highlight (already formatted for the active mode).
140
- description: Optional short description to display alongside the command.
141
-
142
- Returns:
143
- Markup string suitable for Rich rendering, or ``None`` when ``command`` is falsy.
144
- """
145
- if not command:
146
- return None
147
-
148
- highlighted = f"[{HINT_COMMAND_STYLE}]{command}[/]"
149
- if description:
150
- highlighted += f" [{HINT_DESCRIPTION_COLOR}]{description}[/{HINT_DESCRIPTION_COLOR}]"
151
- return highlighted
152
-
153
-
154
- def spinner_context(
155
- ctx: Any | None,
156
- message: str,
157
- *,
158
- console_override: Console | None = None,
159
- spinner: str = "dots",
160
- spinner_style: str = ACCENT_STYLE,
161
- ) -> AbstractContextManager[Any]:
162
- """Return a context manager that renders a spinner when appropriate."""
163
- active_console = console_override or console
164
- if not _can_use_spinner(ctx, active_console):
165
- return nullcontext()
166
-
167
- status = active_console.status(
168
- message,
169
- spinner=spinner,
170
- spinner_style=spinner_style,
171
- )
172
-
173
- if not hasattr(status, "__enter__") or not hasattr(status, "__exit__"):
174
- return nullcontext()
175
-
176
- return status
177
-
178
-
179
- def _can_use_spinner(ctx: Any | None, active_console: Console) -> bool:
180
- """Check if spinner output is allowed in the current environment."""
181
- if ctx is not None:
182
- tty_enabled = bool(get_ctx_value(ctx, "tty", True))
183
- view = (_get_view(ctx) or "rich").lower()
184
- if not tty_enabled or view not in {"", "rich"}:
185
- return False
186
-
187
- if not active_console.is_terminal:
188
- return False
189
-
190
- return _stream_supports_tty(getattr(active_console, "file", None))
191
-
192
-
193
- def _stream_supports_tty(stream: Any) -> bool:
194
- """Return True if the provided stream can safely render a spinner."""
195
- target = stream if hasattr(stream, "isatty") else sys.stdout
196
- try:
197
- return bool(target.isatty())
198
- except Exception:
199
- return 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
200
127
 
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
201
132
 
202
- def update_spinner(status_indicator: Any | None, message: str) -> None:
203
- """Update spinner text when a status indicator is active."""
204
- if status_indicator is None:
205
- return
133
+ logger = logging.getLogger("glaip_sdk.cli.utils")
134
+ questionary = None # type: ignore[assignment]
206
135
 
207
- try:
208
- status_indicator.update(message)
209
- except Exception: # pragma: no cover - defensive update
210
- pass
136
+ _warn_lock = threading.Lock()
137
+ _warned = False
211
138
 
212
139
 
213
- def stop_spinner(status_indicator: Any | None) -> None:
214
- """Stop an active spinner safely."""
215
- 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:
216
144
  return
217
-
218
- try:
219
- status_indicator.stop()
220
- except Exception: # pragma: no cover - defensive stop
221
- pass
222
-
223
-
224
- # Backwards compatibility aliases for legacy callers
225
- _spinner_update = update_spinner
226
- _spinner_stop = stop_spinner
227
-
228
-
229
- # ----------------------------- Client config ----------------------------- #
230
-
231
-
232
- def get_client(ctx: Any) -> Client: # pragma: no cover
233
- """Get configured client from context, env, and config file (ctx > env > file)."""
234
- module = importlib.import_module("glaip_sdk")
235
- client_class = cast("type[Client]", module.Client)
236
- file_config = load_config() or {}
237
- context_config_obj = getattr(ctx, "obj", None)
238
- context_config = context_config_obj or {}
239
-
240
- raw_timeout = os.getenv("AIP_TIMEOUT", "0") or "0"
241
- try:
242
- timeout_value = float(raw_timeout)
243
- except ValueError:
244
- timeout_value = None
245
-
246
- env_config = {
247
- "api_url": os.getenv("AIP_API_URL"),
248
- "api_key": os.getenv("AIP_API_KEY"),
249
- "timeout": timeout_value if timeout_value else None,
250
- }
251
- env_config = {k: v for k, v in env_config.items() if v not in (None, "", 0)}
252
-
253
- # Merge config sources: context > env > file
254
- config = {
255
- **file_config,
256
- **env_config,
257
- **{k: v for k, v in context_config.items() if v is not None},
258
- }
259
-
260
- if not config.get("api_url") or not config.get("api_key"):
261
- configure_hint = command_hint("configure", slash_command="login", ctx=ctx)
262
- actions = []
263
- if configure_hint:
264
- actions.append(f"Run `{configure_hint}`")
265
- actions.append("set AIP_* env vars")
266
- raise click.ClickException(f"Missing api_url/api_key. {' or '.join(actions)}.")
267
-
268
- return client_class(
269
- api_url=config.get("api_url"),
270
- api_key=config.get("api_key"),
271
- timeout=float(config.get("timeout") or 30.0),
272
- )
273
-
274
-
275
- # ----------------------------- Secret masking ---------------------------- #
276
-
277
- # ----------------------------- Fuzzy palette ----------------------------- #
278
-
279
-
280
- def _extract_display_fields(row: dict[str, Any]) -> tuple[str, str, str, str]:
281
- """Extract display fields from row data."""
282
- name = str(row.get("name", "")).strip()
283
- _id = str(row.get("id", "")).strip()
284
- type_ = str(row.get("type", "")).strip()
285
- fw = str(row.get("framework", "")).strip()
286
- return name, _id, type_, fw
287
-
288
-
289
- def _build_primary_parts(name: str, type_: str, fw: str) -> list[str]:
290
- """Build primary display parts from name, type, and framework."""
291
- parts = []
292
- if name:
293
- parts.append(name)
294
- if type_:
295
- parts.append(type_)
296
- if fw:
297
- parts.append(fw)
298
- return parts
299
-
300
-
301
- def _get_fallback_columns(columns: list[tuple]) -> list[tuple]:
302
- """Get first two visible columns for fallback display."""
303
- return columns[:2]
304
-
305
-
306
- def _is_standard_field(k: str) -> bool:
307
- """Check if field is a standard field to skip."""
308
- return k in ("id", "name", "type", "framework")
309
-
310
-
311
- def _extract_fallback_values(row: dict[str, Any], columns: list[tuple]) -> list[str]:
312
- """Extract fallback values from columns."""
313
- fallback_parts = []
314
- for k, _hdr, _style, _w in columns:
315
- if _is_standard_field(k):
316
- continue
317
- val = str(row.get(k, "")).strip()
318
- if val:
319
- fallback_parts.append(val)
320
- if len(fallback_parts) >= 2:
321
- break
322
- return fallback_parts
323
-
324
-
325
- def _build_display_parts(
326
- name: str, _id: str, type_: str, fw: str, row: dict[str, Any], columns: list[tuple]
327
- ) -> list[str]:
328
- """Build complete display parts list."""
329
- parts = _build_primary_parts(name, type_, fw)
330
-
331
- if not parts:
332
- # Use fallback columns
333
- fallback_columns = _get_fallback_columns(columns)
334
- parts.extend(_extract_fallback_values(row, fallback_columns))
335
-
336
- if _id:
337
- parts.append(f"[{_id}]")
338
-
339
- return parts
340
-
341
-
342
- def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
343
- """Build a compact text label for the palette.
344
-
345
- Prefers: name • type • framework • [id] (when available)
346
- Falls back to first 2 columns + [id].
347
- """
348
- name, _id, type_, fw = _extract_display_fields(row)
349
- parts = _build_display_parts(name, _id, type_, fw, row, columns)
350
- return " • ".join(parts) if parts else (_id or "(row)")
351
-
352
-
353
- def _check_fuzzy_pick_requirements() -> bool:
354
- """Check if fuzzy picking requirements are met."""
355
- return _HAS_PTK and console.is_terminal and os.isatty(1)
356
-
357
-
358
- def _build_unique_labels(
359
- rows: list[dict[str, Any]], columns: list[tuple]
360
- ) -> tuple[list[str], dict[str, dict[str, Any]]]:
361
- """Build unique display labels and reverse mapping."""
362
- labels = []
363
- by_label: dict[str, dict[str, Any]] = {}
364
-
365
- for r in rows:
366
- label = _row_display(r, columns)
367
- # Ensure uniqueness: if duplicate, suffix with …#n
368
- if label in by_label:
369
- i = 2
370
- base = label
371
- while f"{base} #{i}" in by_label:
372
- i += 1
373
- label = f"{base} #{i}"
374
- labels.append(label)
375
- by_label[label] = r
376
-
377
- return labels, by_label
378
-
379
-
380
- def _basic_prompt(
381
- message: str,
382
- completer: Any,
383
- ) -> str | None:
384
- """Fallback prompt handler when PromptSession is unavailable or fails."""
385
- if prompt is None: # pragma: no cover - optional dependency path
386
- return None
387
-
388
- try:
389
- return prompt(
390
- message=message,
391
- completer=completer,
392
- complete_in_thread=True,
393
- complete_while_typing=True,
394
- )
395
- except (KeyboardInterrupt, EOFError):
396
- return None
397
- except Exception as exc: # pragma: no cover - defensive
398
- logger.debug("Fallback prompt failed: %s", exc)
399
- return None
400
-
401
-
402
- def _prompt_with_auto_select(
403
- message: str,
404
- completer: Any,
405
- choices: Iterable[str],
406
- ) -> str | None:
407
- """Prompt with fuzzy completer that auto-selects suggested matches."""
408
- if not _HAS_PTK or PromptSession is None or Buffer is None or SelectionType is None:
409
- return _basic_prompt(message, completer)
410
-
411
- try:
412
- session = PromptSession(
413
- message,
414
- completer=completer,
415
- complete_in_thread=True,
416
- complete_while_typing=True,
417
- reserve_space_for_menu=8,
418
- )
419
- except Exception as exc: # pragma: no cover - depends on prompt_toolkit
420
- logger.debug("PromptSession init failed (%s); falling back to basic prompt.", exc)
421
- return _basic_prompt(message, completer)
422
-
423
- buffer = session.default_buffer
424
- valid_choices = set(choices)
425
-
426
- def _auto_select(_: Buffer) -> None:
427
- text = buffer.text
428
- if not text or text not in valid_choices:
429
- return
430
- buffer.cursor_position = 0
431
- buffer.start_selection(selection_type=SelectionType.CHARACTERS)
432
- buffer.cursor_position = len(text)
433
-
434
- handler_attached = False
435
- try:
436
- buffer.on_text_changed += _auto_select
437
- handler_attached = True
438
- except Exception as exc: # pragma: no cover - defensive
439
- logger.debug("Failed to attach auto-select handler: %s", exc)
440
-
441
- try:
442
- return session.prompt()
443
- except (KeyboardInterrupt, EOFError):
444
- return None
445
- except Exception as exc: # pragma: no cover - defensive
446
- logger.debug("PromptSession prompt failed (%s); falling back to basic prompt.", exc)
447
- return _basic_prompt(message, completer)
448
- finally:
449
- if handler_attached:
450
- try:
451
- buffer.on_text_changed -= _auto_select
452
- except Exception: # pragma: no cover - defensive
453
- pass
454
-
455
-
456
- class _FuzzyCompleter:
457
- """Fuzzy completer for prompt_toolkit."""
458
-
459
- def __init__(self, words: list[str]) -> None:
460
- self.words = words
461
-
462
- def get_completions(self, document: Any, _complete_event: Any) -> Any: # pragma: no cover
463
- word = document.get_word_before_cursor()
464
- if not word:
145
+ with _warn_lock:
146
+ if _warned:
465
147
  return
466
-
467
- word_lower = word.lower()
468
- for label in self.words:
469
- label_lower = label.lower()
470
- if self._fuzzy_match(word_lower, label_lower):
471
- yield Completion(label, start_position=-len(word))
472
-
473
- def _fuzzy_match(self, search: str, target: str) -> bool: # pragma: no cover
474
- """True fuzzy matching: checks if all characters in search appear in order in target."""
475
- if not search:
476
- return True
477
-
478
- search_idx = 0
479
- for char in target:
480
- if search_idx < len(search) and search[search_idx] == char:
481
- search_idx += 1
482
- if search_idx == len(search):
483
- return True
484
- return False
485
-
486
-
487
- def _perform_fuzzy_search(answer: str, labels: list[str], by_label: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
488
- """Perform fuzzy search fallback and return best match."""
489
- # Exact label match
490
- if answer in by_label:
491
- return by_label[answer]
492
-
493
- # Fuzzy search fallback
494
- best_match = None
495
- best_score = -1
496
-
497
- for label in labels:
498
- score = _fuzzy_score(answer.lower(), label.lower())
499
- if score > best_score:
500
- best_score = score
501
- best_match = label
502
-
503
- return by_label[best_match] if best_match and best_score > 0 else None
504
-
505
-
506
- def _fuzzy_pick(
507
- rows: list[dict[str, Any]], columns: list[tuple], title: str
508
- ) -> dict[str, Any] | None: # pragma: no cover - requires interactive prompt toolkit
509
- """Open a minimal fuzzy palette using prompt_toolkit.
510
-
511
- Returns the selected row (dict) or None if cancelled/missing deps.
512
- """
513
- if not _check_fuzzy_pick_requirements():
514
- return None
515
-
516
- # Build display labels and mapping
517
- labels, by_label = _build_unique_labels(rows, columns)
518
-
519
- # Create fuzzy completer
520
- completer = _FuzzyCompleter(labels)
521
- answer = _prompt_with_auto_select(
522
- f"Find {title.rstrip('s')}: ",
523
- completer,
524
- labels,
525
- )
526
- if answer is None:
527
- return None
528
-
529
- return _perform_fuzzy_search(answer, labels, by_label) if answer else None
530
-
531
-
532
- def _is_fuzzy_match(search: str, target: str) -> bool:
533
- """Check if search string is a fuzzy match for target."""
534
- if not search:
535
- return True
536
-
537
- search_idx = 0
538
- for char in target:
539
- if search_idx < len(search) and search[search_idx] == char:
540
- search_idx += 1
541
- if search_idx == len(search):
542
- return True
543
- return False
544
-
545
-
546
- def _calculate_exact_match_bonus(search: str, target: str) -> int:
547
- """Calculate bonus for exact substring matches."""
548
- return 100 if search.lower() in target.lower() else 0
549
-
550
-
551
- def _calculate_consecutive_bonus(search: str, target: str) -> int:
552
- """Calculate bonus for consecutive character matches."""
553
- consecutive = 0
554
- max_consecutive = 0
555
- search_idx = 0
556
-
557
- for char in target:
558
- if search_idx < len(search) and search[search_idx] == char:
559
- consecutive += 1
560
- max_consecutive = max(max_consecutive, consecutive)
561
- search_idx += 1
562
- else:
563
- consecutive = 0
564
-
565
- return max_consecutive * 10
566
-
567
-
568
- def _calculate_length_bonus(search: str, target: str) -> int:
569
- """Calculate bonus for shorter search terms."""
570
- return (len(target) - len(search)) * 2
571
-
572
-
573
- def _fuzzy_score(search: str, target: str) -> int:
574
- """Calculate fuzzy match score.
575
-
576
- Higher score = better match.
577
- Returns -1 if no match possible.
578
- """
579
- if not search:
580
- return 0
581
-
582
- if not _is_fuzzy_match(search, target):
583
- return -1 # Not a fuzzy match
584
-
585
- # Calculate score based on different factors
586
- score = 0
587
- score += _calculate_exact_match_bonus(search, target)
588
- score += _calculate_consecutive_bonus(search, target)
589
- score += _calculate_length_bonus(search, target)
590
-
591
- return score
592
-
593
-
594
- # ----------------------------- Pretty outputs ---------------------------- #
595
-
596
-
597
- def _coerce_result_payload(result: Any) -> Any:
598
- try:
599
- to_dict = getattr(result, "to_dict", None)
600
- if callable(to_dict):
601
- return to_dict()
602
- except Exception:
603
- return result
604
- return result
605
-
606
-
607
- def _ensure_displayable(payload: Any) -> Any:
608
- if isinstance(payload, (dict, list, str, int, float, bool)) or payload is None:
609
- return payload
610
-
611
- if hasattr(payload, "__dict__"):
612
- try:
613
- return dict(payload)
614
- except Exception:
615
- try:
616
- return dict(payload.__dict__)
617
- except Exception:
618
- pass
619
-
620
- try:
621
- return str(payload)
622
- except Exception:
623
- return repr(payload)
624
-
625
-
626
- def _render_markdown_output(data: Any) -> None:
627
- try:
628
- console.print(Markdown(str(data)))
629
- except ImportError:
630
- click.echo(str(data))
631
-
632
-
633
- def output_result(
634
- ctx: Any,
635
- result: Any,
636
- title: str = "Result",
637
- panel_title: str | None = None,
638
- ) -> None:
639
- """Output a result to the console with optional title.
640
-
641
- Args:
642
- ctx: Click context
643
- result: Result data to output
644
- title: Optional title for the output
645
- panel_title: Optional Rich panel title for structured output
646
- """
647
- fmt = _get_view(ctx)
648
-
649
- data = _coerce_result_payload(result)
650
- data = masking.mask_payload(data)
651
- data = _ensure_displayable(data)
652
-
653
- if fmt == "json":
654
- click.echo(json.dumps(data, indent=2, default=str))
655
- return
656
-
657
- if fmt == "plain":
658
- click.echo(str(data))
659
- return
660
-
661
- if fmt == "md":
662
- _render_markdown_output(data)
663
- return
664
-
665
- renderable = Pretty(data)
666
- if panel_title:
667
- console.print(AIPPanel(renderable, title=panel_title))
668
- else:
669
- console.print(markup_text(f"[{ACCENT_STYLE}]{title}:[/]"))
670
- console.print(renderable)
671
-
672
-
673
- # ----------------------------- List rendering ---------------------------- #
674
-
675
- # Threshold no longer used - fuzzy palette is always default for TTY
676
- # _PICK_THRESHOLD = int(os.getenv("AIP_PICK_THRESHOLD", "5") or "5")
677
-
678
-
679
- def _normalise_rows(items: list[Any], transform_func: Callable[[Any], dict[str, Any]] | None) -> list[dict[str, Any]]:
680
- try:
681
- rows: list[dict[str, Any]] = []
682
- for item in items:
683
- if transform_func:
684
- rows.append(transform_func(item))
685
- elif hasattr(item, "to_dict"):
686
- rows.append(item.to_dict())
687
- elif hasattr(item, "__dict__"):
688
- rows.append(vars(item))
689
- elif isinstance(item, dict):
690
- rows.append(item)
691
- else:
692
- rows.append({"value": item})
693
- return rows
694
- except Exception:
695
- return []
696
-
697
-
698
- def _render_plain_list(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
699
- if not rows:
700
- click.echo(f"No {title.lower()} found.")
701
- return
702
- for row in rows:
703
- row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
704
- click.echo(row_str)
705
-
706
-
707
- def _render_markdown_list(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
708
- if not rows:
709
- click.echo(f"No {title.lower()} found.")
710
- return
711
- headers = [header for _, header, _, _ in columns]
712
- click.echo(f"| {' | '.join(headers)} |")
713
- click.echo(f"| {' | '.join('---' for _ in headers)} |")
714
- for row in rows:
715
- row_str = " | ".join(str(row.get(key, "N/A")) for key, _, _, _ in columns)
716
- click.echo(f"| {row_str} |")
717
-
718
-
719
- def _should_sort_rows(rows: list[dict[str, Any]]) -> bool:
720
- return (
721
- os.getenv("AIP_TABLE_NO_SORT", "0") not in ("1", "true", "on")
722
- and rows
723
- and isinstance(rows[0], dict)
724
- and "name" in rows[0]
725
- )
726
-
727
-
728
- def _create_table(columns: list[tuple[str, str, str, int | None]], title: str) -> Any:
729
- table = AIPTable(title=title, expand=True)
730
- for _key, header, style, width in columns:
731
- table.add_column(header, style=style, width=width)
732
- return table
733
-
734
-
735
- def _build_table_group(rows: list[dict[str, Any]], columns: list[tuple], title: str) -> Group:
736
- table = _create_table(columns, title)
737
- for row in rows:
738
- table.add_row(*[str(row.get(key, "N/A")) for key, _, _, _ in columns])
739
- footer = markup_text(f"\n[dim]Total {len(rows)} items[/dim]")
740
- return Group(table, footer)
741
-
742
-
743
- def _handle_json_output(items: list[Any], rows: list[dict[str, Any]]) -> None:
744
- """Handle JSON output format."""
745
- data = rows if rows else [it.to_dict() if hasattr(it, "to_dict") else it for it in items]
746
- click.echo(json.dumps(data, indent=2, default=str))
747
-
748
-
749
- def _handle_plain_output(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
750
- """Handle plain text output format."""
751
- _render_plain_list(rows, title, columns)
752
-
753
-
754
- def _handle_markdown_output(rows: list[dict[str, Any]], title: str, columns: list[tuple]) -> None:
755
- """Handle markdown output format."""
756
- _render_markdown_list(rows, title, columns)
757
-
758
-
759
- def _handle_empty_items(title: str) -> None:
760
- """Handle case when no items are found."""
761
- console.print(markup_text(f"[{WARNING_STYLE}]No {title.lower()} found.[/]"))
762
-
763
-
764
- def _should_use_fuzzy_picker() -> bool:
765
- """Return True when the interactive fuzzy picker can be shown."""
766
- return console.is_terminal and os.isatty(1)
767
-
768
-
769
- def _try_fuzzy_pick(rows: list[dict[str, Any]], columns: list[tuple], title: str) -> dict[str, Any] | None:
770
- """Best-effort fuzzy selection; returns None if the picker fails."""
771
- if not _should_use_fuzzy_picker():
772
- return None
773
-
774
- try:
775
- return _fuzzy_pick(rows, columns, title)
776
- except Exception:
777
- logger.debug("Fuzzy picker failed; falling back to table output", exc_info=True)
778
- return None
779
-
780
-
781
- def _resource_tip_command(title: str) -> str | None:
782
- """Resolve the follow-up command hint for the given table title."""
783
- title_lower = title.lower()
784
- mapping = {
785
- "agent": ("agents get", "agents"),
786
- "tool": ("tools get", None),
787
- "mcp": ("mcps get", None),
788
- "model": ("models list", None), # models only ship a list command
789
- }
790
- for keyword, (cli_command, slash_command) in mapping.items():
791
- if keyword in title_lower:
792
- return command_hint(cli_command, slash_command=slash_command)
793
- return command_hint("agents get", slash_command="agents")
794
-
795
-
796
- def _print_selection_tip(title: str) -> None:
797
- """Print the contextual follow-up tip after a fuzzy selection."""
798
- tip_cmd = _resource_tip_command(title)
799
- if tip_cmd:
800
- console.print(markup_text(f"\n[dim]Tip: use `{tip_cmd} <ID>` for details[/dim]"))
801
-
802
-
803
- def _handle_fuzzy_pick_selection(rows: list[dict[str, Any]], columns: list[tuple], title: str) -> bool:
804
- """Handle fuzzy picker selection, returns True if selection was made."""
805
- picked = _try_fuzzy_pick(rows, columns, title)
806
- if picked is None:
807
- return False
808
-
809
- table = _create_table(columns, title)
810
- table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
811
- console.print(table)
812
- _print_selection_tip(title)
813
- return True
814
-
815
-
816
- def _handle_table_output(
817
- rows: list[dict[str, Any]],
818
- columns: list[tuple],
819
- title: str,
820
- *,
821
- use_pager: bool | None = None,
822
- ) -> None:
823
- """Handle table output with paging."""
824
- content = _build_table_group(rows, columns, title)
825
- should_page = (
826
- pager._should_page_output(len(rows), console.is_terminal and os.isatty(1)) if use_pager is None else use_pager
827
- )
828
-
829
- if should_page:
830
- ansi = pager._render_ansi(content)
831
- if not pager._page_with_system_pager(ansi):
832
- with console.pager(styles=True):
833
- console.print(content)
834
- else:
835
- console.print(content)
836
-
837
-
838
- def output_list(
839
- ctx: Any,
840
- items: list[Any],
841
- title: str,
842
- columns: list[tuple[str, str, str, int | None]],
843
- transform_func: Callable | None = None,
844
- *,
845
- skip_picker: bool = False,
846
- use_pager: bool | None = None,
847
- ) -> None:
848
- """Display a list with optional fuzzy palette for quick selection."""
849
- fmt = _get_view(ctx)
850
- rows = _normalise_rows(items, transform_func)
851
- rows = masking.mask_rows(rows)
852
-
853
- if fmt == "json":
854
- _handle_json_output(items, rows)
855
- return
856
-
857
- if fmt == "plain":
858
- _handle_plain_output(rows, title, columns)
859
- return
860
-
861
- if fmt == "md":
862
- _handle_markdown_output(rows, title, columns)
863
- return
864
-
865
- if not items:
866
- _handle_empty_items(title)
867
- return
868
-
869
- if _should_sort_rows(rows):
870
- try:
871
- rows = sorted(rows, key=lambda r: str(r.get("name", "")).lower())
872
- except Exception:
873
- pass
874
-
875
- if not skip_picker and _handle_fuzzy_pick_selection(rows, columns, title):
876
- return
877
-
878
- _handle_table_output(rows, columns, title, use_pager=use_pager)
879
-
880
-
881
- # ------------------------- Ambiguity handling --------------------------- #
882
-
883
-
884
- def coerce_to_row(item: Any, keys: list[str]) -> dict[str, Any]:
885
- """Coerce an item (dict or object) to a row dict with specified keys.
886
-
887
- Args:
888
- item: The item to coerce (dict or object with attributes)
889
- keys: List of keys/attribute names to extract
890
-
891
- Returns:
892
- Dict with the extracted values, "N/A" for missing values
893
- """
894
- result = {}
895
- for key in keys:
896
- if isinstance(item, dict):
897
- value = item.get(key, "N/A")
898
- else:
899
- value = getattr(item, key, "N/A")
900
- result[key] = str(value) if value is not None else "N/A"
901
- return result
902
-
903
-
904
- def _register_renderer_with_session(ctx: Any, renderer: RichStreamRenderer) -> None:
905
- """Attach renderer to an active slash session when present."""
906
- try:
907
- ctx_obj = getattr(ctx, "obj", None)
908
- session = ctx_obj.get("_slash_session") if isinstance(ctx_obj, dict) else None
909
- if session and hasattr(session, "register_active_renderer"):
910
- session.register_active_renderer(renderer)
911
- except Exception:
912
- # Never let session bookkeeping break renderer creation
913
- pass
914
-
915
-
916
- def build_renderer(
917
- _ctx: Any,
918
- *,
919
- save_path: str | os.PathLike[str] | None,
920
- verbose: bool = False,
921
- _tty_enabled: bool = True,
922
- live: bool | None = None,
923
- snapshots: bool | None = None,
924
- ) -> tuple[RichStreamRenderer, Console | CapturingConsole]:
925
- """Build renderer and capturing console for CLI commands.
926
-
927
- Args:
928
- _ctx: Click context object for CLI operations.
929
- save_path: Path to save output to (enables capturing console).
930
- verbose: Whether to enable verbose mode.
931
- _tty_enabled: Whether TTY is available for interactive features.
932
- live: Whether to enable live rendering mode (overrides verbose default).
933
- snapshots: Whether to capture and store snapshots.
934
-
935
- Returns:
936
- Tuple of (renderer, capturing_console) for streaming output.
937
- """
938
- # Use capturing console if saving output
939
- working_console = CapturingConsole(console, capture=True) if save_path else console
940
-
941
- # Configure renderer based on verbose mode and explicit overrides
942
- live_enabled = bool(live) if live is not None else not verbose
943
- renderer_cfg = RendererConfig(
944
- live=live_enabled,
945
- append_finished_snapshots=bool(snapshots)
946
- if snapshots is not None
947
- else RendererConfig.append_finished_snapshots,
948
- )
949
-
950
- # Create the renderer instance
951
- renderer = RichStreamRenderer(
952
- working_console.original_console if isinstance(working_console, CapturingConsole) else working_console,
953
- cfg=renderer_cfg,
954
- verbose=verbose,
955
- )
956
-
957
- # Link the renderer back to the slash session when running from the palette.
958
- _register_renderer_with_session(_ctx, renderer)
959
-
960
- return renderer, working_console
961
-
962
-
963
- def _build_resource_labels(resources: list[Any]) -> tuple[list[str], dict[str, Any]]:
964
- """Build unique display labels for resources."""
965
- labels = []
966
- by_label: dict[str, Any] = {}
967
-
968
- for resource in resources:
969
- name = getattr(resource, "name", "Unknown")
970
- _id = getattr(resource, "id", "Unknown")
971
-
972
- # Create display label
973
- label_parts = []
974
- if name and name != "Unknown":
975
- label_parts.append(name)
976
- label_parts.append(f"[{_id[:8]}...]") # Show first 8 chars of ID
977
- label = " • ".join(label_parts)
978
-
979
- # Ensure uniqueness
980
- if label in by_label:
981
- i = 2
982
- base = label
983
- while f"{base} #{i}" in by_label:
984
- i += 1
985
- label = f"{base} #{i}"
986
-
987
- labels.append(label)
988
- by_label[label] = resource
989
-
990
- return labels, by_label
991
-
992
-
993
- def _fuzzy_pick_for_resources(
994
- resources: list[Any], resource_type: str, _search_term: str
995
- ) -> Any | None: # pragma: no cover - interactive selection helper
996
- """Fuzzy picker for resource objects, similar to _fuzzy_pick but without column dependencies.
997
-
998
- Args:
999
- resources: List of resource objects to choose from
1000
- resource_type: Type of resource (e.g., "agent", "tool")
1001
- search_term: The search term that led to multiple matches
1002
-
1003
- Returns:
1004
- Selected resource object or None if cancelled/no selection
1005
- """
1006
- if not _check_fuzzy_pick_requirements():
1007
- return None
1008
-
1009
- # Build labels and mapping
1010
- labels, by_label = _build_resource_labels(resources)
1011
-
1012
- # Create fuzzy completer
1013
- completer = _FuzzyCompleter(labels)
1014
- answer = _prompt_with_auto_select(
1015
- f"Find {ICON_AGENT} {resource_type.title()}: ",
1016
- completer,
1017
- labels,
1018
- )
1019
- if answer is None:
1020
- return None
1021
-
1022
- return _perform_fuzzy_search(answer, labels, by_label) if answer else None
1023
-
1024
-
1025
- def _resolve_by_id(ref: str, get_by_id: Callable) -> Any | None:
1026
- """Resolve resource by UUID if ref is a valid UUID."""
1027
- if is_uuid(ref):
1028
- return get_by_id(ref)
1029
- return None
1030
-
1031
-
1032
- def _resolve_by_name_multiple_with_select(matches: list[Any], select: int) -> Any:
1033
- """Resolve multiple matches using select parameter."""
1034
- idx = int(select) - 1
1035
- if not (0 <= idx < len(matches)):
1036
- raise click.ClickException(f"--select must be 1..{len(matches)}")
1037
- return matches[idx]
1038
-
1039
-
1040
- def _resolve_by_name_multiple_fuzzy(ctx: Any, ref: str, matches: list[Any], label: str) -> Any:
1041
- """Resolve multiple matches preferring the fuzzy picker interface."""
1042
- return handle_ambiguous_resource(ctx, label.lower(), ref, matches, interface_preference="fuzzy")
1043
-
1044
-
1045
- def _resolve_by_name_multiple_questionary(ctx: Any, ref: str, matches: list[Any], label: str) -> Any:
1046
- """Resolve multiple matches preferring the questionary interface."""
1047
- return handle_ambiguous_resource(ctx, label.lower(), ref, matches, interface_preference="questionary")
1048
-
1049
-
1050
- def resolve_resource(
1051
- ctx: Any,
1052
- ref: str,
1053
- *,
1054
- get_by_id: Callable,
1055
- find_by_name: Callable,
1056
- label: str,
1057
- select: int | None = None,
1058
- interface_preference: str = "fuzzy",
1059
- status_indicator: Any | None = None,
1060
- ) -> Any | None:
1061
- """Resolve resource reference (ID or name) with ambiguity handling.
1062
-
1063
- Args:
1064
- ctx: Click context
1065
- ref: Resource reference (ID or name)
1066
- get_by_id: Function to get resource by ID
1067
- find_by_name: Function to find resources by name
1068
- label: Resource type label for error messages
1069
- select: Optional selection index for ambiguity resolution
1070
- interface_preference: "fuzzy" for fuzzy picker, "questionary" for up/down list
1071
- status_indicator: Optional Rich status indicator for wait animations
1072
-
1073
- Returns:
1074
- Resolved resource object
1075
- """
1076
- spinner = status_indicator
1077
- _spinner_update(spinner, f"[bold blue]Resolving {label}…[/bold blue]")
1078
-
1079
- # Try to resolve by ID first
1080
- _spinner_update(spinner, f"[bold blue]Fetching {label} by ID…[/bold blue]")
1081
- result = _resolve_by_id(ref, get_by_id)
1082
- if result is not None:
1083
- _spinner_update(spinner, f"[{SUCCESS_STYLE}]{label} found[/]")
1084
- return result
1085
-
1086
- # If get_by_id returned None, the resource doesn't exist
1087
- if is_uuid(ref):
1088
- _spinner_stop(spinner)
1089
- raise click.ClickException(f"{label} '{ref}' not found")
1090
-
1091
- # Find resources by name
1092
- _spinner_update(spinner, f"[bold blue]Searching {label}s matching '{ref}'…[/bold blue]")
1093
- matches = find_by_name(name=ref)
1094
- if not matches:
1095
- _spinner_stop(spinner)
1096
- raise click.ClickException(f"{label} '{ref}' not found")
1097
-
1098
- if len(matches) == 1:
1099
- _spinner_update(spinner, f"[{SUCCESS_STYLE}]{label} found[/]")
1100
- return matches[0]
1101
-
1102
- # Multiple matches found, handle ambiguity
1103
- if select:
1104
- _spinner_stop(spinner)
1105
- return _resolve_by_name_multiple_with_select(matches, select)
1106
-
1107
- # Choose interface based on preference
1108
- _spinner_stop(spinner)
1109
- preference = (interface_preference or "fuzzy").lower()
1110
- if preference not in {"fuzzy", "questionary"}:
1111
- preference = "fuzzy"
1112
- if preference == "fuzzy":
1113
- return _resolve_by_name_multiple_fuzzy(ctx, ref, matches, label)
1114
- else:
1115
- return _resolve_by_name_multiple_questionary(ctx, ref, matches, label)
1116
-
1117
-
1118
- def _handle_json_view_ambiguity(matches: list[Any]) -> Any:
1119
- """Handle ambiguity in JSON view by returning first match."""
1120
- return matches[0]
1121
-
1122
-
1123
- def _handle_questionary_ambiguity(resource_type: str, ref: str, matches: list[Any]) -> Any:
1124
- """Handle ambiguity using questionary interactive interface."""
1125
- if not (questionary and os.getenv("TERM") and os.isatty(0) and os.isatty(1)):
1126
- raise click.ClickException("Interactive selection not available")
1127
-
1128
- # Escape special characters for questionary
1129
- safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
1130
- safe_ref = ref.replace("{", "{{").replace("}", "}}")
1131
-
1132
- picked_idx = questionary.select(
1133
- f"Multiple {safe_resource_type}s match '{safe_ref}'. Pick one:",
1134
- choices=[
1135
- questionary.Choice(
1136
- title=(
1137
- f"{getattr(m, 'name', '—').replace('{', '{{').replace('}', '}}')} — "
1138
- f"{getattr(m, 'id', '').replace('{', '{{').replace('}', '}}')}"
1139
- ),
1140
- value=i,
1141
- )
1142
- for i, m in enumerate(matches)
1143
- ],
1144
- use_indicator=True,
1145
- qmark="🧭",
1146
- instruction="↑/↓ to select • Enter to confirm",
1147
- ).ask()
1148
- if picked_idx is None:
1149
- raise click.ClickException("Selection cancelled")
1150
- return matches[picked_idx]
1151
-
1152
-
1153
- def _handle_fallback_numeric_ambiguity(resource_type: str, ref: str, matches: list[Any]) -> Any:
1154
- """Handle ambiguity using numeric prompt fallback."""
1155
- # Escape special characters for display
1156
- safe_resource_type = resource_type.replace("{", "{{").replace("}", "}}")
1157
- safe_ref = ref.replace("{", "{{").replace("}", "}}")
1158
-
1159
- console.print(markup_text(f"[{WARNING_STYLE}]Multiple {safe_resource_type}s found matching '{safe_ref}':[/]"))
1160
- table = AIPTable(
1161
- title=f"Select {safe_resource_type.title()}",
1162
- )
1163
- table.add_column("#", style="dim", width=3)
1164
- table.add_column("ID", style="dim", width=36)
1165
- table.add_column("Name", style=ACCENT_STYLE)
1166
- for i, m in enumerate(matches, 1):
1167
- table.add_row(str(i), str(getattr(m, "id", "")), str(getattr(m, "name", "")))
1168
- console.print(table)
1169
- choice_str = click.prompt(
1170
- f"Select {safe_resource_type} (1-{len(matches)})",
1171
- )
1172
- try:
1173
- choice = int(choice_str)
1174
- except ValueError as err:
1175
- raise click.ClickException("Invalid selection") from err
1176
- if 1 <= choice <= len(matches):
1177
- return matches[choice - 1]
1178
- raise click.ClickException("Invalid selection")
1179
-
1180
-
1181
- def _should_fallback_to_numeric_prompt(exception: Exception) -> bool:
1182
- """Determine if we should fallback to numeric prompt for this exception."""
1183
- # Re-raise cancellation - user explicitly cancelled
1184
- if "Selection cancelled" in str(exception):
1185
- return False
1186
-
1187
- # Fall back to numeric prompt for other exceptions
1188
- return True
1189
-
1190
-
1191
- def _normalize_interface_preference(preference: str) -> str:
1192
- """Normalize and validate interface preference."""
1193
- normalized = (preference or "questionary").lower()
1194
- return normalized if normalized in {"fuzzy", "questionary"} else "questionary"
1195
-
1196
-
1197
- def _get_interface_order(preference: str) -> tuple[str, str]:
1198
- """Get the ordered interface preferences."""
1199
- interface_orders = {
1200
- "fuzzy": ("fuzzy", "questionary"),
1201
- "questionary": ("questionary", "fuzzy"),
1202
- }
1203
- return interface_orders.get(preference, ("questionary", "fuzzy"))
1204
-
1205
-
1206
- def _try_fuzzy_selection(
1207
- resource_type: str,
1208
- ref: str,
1209
- matches: list[Any],
1210
- ) -> Any | None:
1211
- """Try fuzzy interface selection."""
1212
- picked = _fuzzy_pick_for_resources(matches, resource_type, ref)
1213
- return picked if picked else None
1214
-
1215
-
1216
- def _try_questionary_selection(
1217
- resource_type: str,
1218
- ref: str,
1219
- matches: list[Any],
1220
- ) -> Any | None:
1221
- """Try questionary interface selection."""
1222
- try:
1223
- return _handle_questionary_ambiguity(resource_type, ref, matches)
1224
- except Exception as exc:
1225
- if not _should_fallback_to_numeric_prompt(exc):
1226
- raise
1227
- return None
1228
-
1229
-
1230
- def _try_interface_selection(
1231
- interface_order: tuple[str, str],
1232
- resource_type: str,
1233
- ref: str,
1234
- matches: list[Any],
1235
- ) -> Any | None:
1236
- """Try interface selection in order, return result or None if all failed."""
1237
- interface_handlers = {
1238
- "fuzzy": _try_fuzzy_selection,
1239
- "questionary": _try_questionary_selection,
1240
- }
1241
-
1242
- for interface in interface_order:
1243
- handler = interface_handlers.get(interface)
1244
- if handler:
1245
- result = handler(resource_type, ref, matches)
1246
- if result:
1247
- return result
1248
-
1249
- return None
1250
-
1251
-
1252
- def handle_ambiguous_resource(
1253
- ctx: Any,
1254
- resource_type: str,
1255
- ref: str,
1256
- matches: list[Any],
1257
- *,
1258
- interface_preference: str = "questionary",
1259
- ) -> Any:
1260
- """Handle multiple resource matches gracefully."""
1261
- if _get_view(ctx) == "json":
1262
- return _handle_json_view_ambiguity(matches)
1263
-
1264
- preference = _normalize_interface_preference(interface_preference)
1265
- interface_order = _get_interface_order(preference)
1266
-
1267
- result = _try_interface_selection(interface_order, resource_type, ref, matches)
1268
-
1269
- if result is not None:
1270
- return result
1271
-
1272
- return _handle_fallback_numeric_ambiguity(resource_type, ref, matches)
148
+ warnings.warn(
149
+ "Importing from glaip_sdk.cli.utils is deprecated. Use glaip_sdk.cli.core.* modules instead.",
150
+ DeprecationWarning,
151
+ stacklevel=3,
152
+ )
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
+ ]