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