glaip-sdk 0.1.0__py3-none-any.whl → 0.6.10__py3-none-any.whl

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