glaip-sdk 0.6.26__py3-none-any.whl → 0.7.1__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.
@@ -14,8 +14,6 @@ from glaip_sdk.cli.commands.mcps._common import ( # noqa: E402
14
14
  console,
15
15
  _load_import_ready_payload,
16
16
  _merge_import_payload,
17
- _assemble_update_data_from_import_payload,
18
- _assemble_update_data_from_cli_options,
19
17
  _strip_server_only_fields,
20
18
  _coerce_cli_string,
21
19
  _collect_cli_overrides,
@@ -73,8 +71,6 @@ __all__ = [
73
71
  "console",
74
72
  "_load_import_ready_payload",
75
73
  "_merge_import_payload",
76
- "_assemble_update_data_from_import_payload",
77
- "_assemble_update_data_from_cli_options",
78
74
  "_strip_server_only_fields",
79
75
  "_coerce_cli_string",
80
76
  "_collect_cli_overrides",
@@ -328,66 +328,35 @@ def _handle_cli_error(ctx: Any, error: Exception, operation: str) -> None:
328
328
  ctx.exit(1)
329
329
 
330
330
 
331
- def _assemble_update_data_from_import_payload(import_payload: dict[str, Any] | None) -> dict[str, Any]:
332
- """Assemble update_data dictionary from import payload.
333
-
334
- Args:
335
- import_payload: Import payload dictionary or None
336
-
337
- Returns:
338
- Dictionary with update fields from import payload
339
- """
340
- update_data: dict[str, Any] = {}
341
- if import_payload:
342
- for field in ("name", "transport", "description", "config", "authentication"):
343
- if field in import_payload:
344
- update_data[field] = import_payload[field]
345
- return update_data
346
-
347
-
348
- def _assemble_update_data_from_cli_options(
349
- update_data: dict[str, Any],
350
- name: str | None,
351
- transport: str | None,
352
- description: str | None,
331
+ def _parse_and_validate_config_auth(
332
+ update_dict: dict[str, Any],
353
333
  config: str | None,
354
334
  auth: str | None,
335
+ transport: str | None,
355
336
  import_payload: dict[str, Any] | None,
356
337
  mcp: Any,
357
- ) -> dict[str, Any]:
358
- """Build update_data dictionary from CLI options, overriding import values.
338
+ ) -> None:
339
+ """Parse and validate config and auth CLI options, updating dict in-place.
359
340
 
360
341
  Args:
361
- update_data: Existing update_data dictionary (from import payload)
362
- name: MCP name option
363
- transport: Transport option
364
- description: Description option
365
- config: Config option
366
- auth: Auth option
342
+ update_dict: Dictionary to update with parsed config/auth
343
+ config: Config option string
344
+ auth: Auth option string
345
+ transport: Transport option for config validation
367
346
  import_payload: Import payload dictionary or None
368
347
  mcp: Current MCP object
369
-
370
- Returns:
371
- Updated dictionary with CLI option values
372
348
  """
373
- if name is not None:
374
- update_data["name"] = name
375
- if transport is not None:
376
- update_data["transport"] = transport
377
- if description is not None:
378
- update_data["description"] = description
379
349
  if config is not None:
380
350
  parsed_config = parse_json_input(config)
381
351
  config_transport = _get_config_transport(transport, import_payload, mcp)
382
- update_data["config"] = validate_mcp_config_structure(
352
+ update_dict["config"] = validate_mcp_config_structure(
383
353
  parsed_config,
384
354
  transport=config_transport,
385
355
  source="--config",
386
356
  )
387
357
  if auth is not None:
388
358
  parsed_auth = parse_json_input(auth)
389
- update_data["authentication"] = validate_mcp_auth_structure(parsed_auth, source="--auth")
390
- return update_data
359
+ update_dict["authentication"] = validate_mcp_auth_structure(parsed_auth, source="--auth")
391
360
 
392
361
 
393
362
  def _generate_update_preview(mcp: Any, update_data: dict[str, Any], cli_overrides: dict[str, Any]) -> str:
@@ -111,29 +111,28 @@ def create(
111
111
  effective_description = merged_payload.get("description")
112
112
  effective_config = merged_payload.get("config") or {}
113
113
  effective_auth = merged_payload.get("authentication")
114
+ mcp_metadata = merged_payload.get("mcp_metadata")
114
115
 
115
116
  with spinner_context(
116
117
  ctx,
117
118
  "[bold blue]Creating MCP…[/bold blue]",
118
119
  console_override=console,
119
120
  ):
121
+ # Use SDK client method to create MCP
120
122
  create_kwargs: dict[str, Any] = {
121
- "name": effective_name,
122
- "config": effective_config,
123
123
  "transport": effective_transport,
124
124
  }
125
-
126
- if effective_description is not None:
127
- create_kwargs["description"] = effective_description
128
-
129
125
  if effective_auth:
130
126
  create_kwargs["authentication"] = effective_auth
131
-
132
- mcp_metadata = merged_payload.get("mcp_metadata")
133
127
  if mcp_metadata is not None:
134
128
  create_kwargs["mcp_metadata"] = mcp_metadata
135
129
 
136
- mcp = api_client.mcps.create_mcp(**create_kwargs)
130
+ mcp = api_client.mcps.create_mcp(
131
+ name=effective_name,
132
+ description=effective_description,
133
+ config=effective_config,
134
+ **create_kwargs,
135
+ )
137
136
 
138
137
  # Handle JSON output
139
138
  handle_json_output(ctx, mcp.model_dump())
@@ -16,11 +16,10 @@ from glaip_sdk.cli.core.context import get_client
16
16
  from glaip_sdk.cli.core.rendering import spinner_context
17
17
 
18
18
  from ._common import (
19
- _assemble_update_data_from_cli_options,
20
- _assemble_update_data_from_import_payload,
21
19
  _handle_cli_error,
22
20
  _handle_update_preview_and_confirmation,
23
21
  _load_import_ready_payload,
22
+ _parse_and_validate_config_auth,
24
23
  _resolve_mcp,
25
24
  _validate_import_payload_fields,
26
25
  _validate_update_inputs,
@@ -29,6 +28,49 @@ from ._common import (
29
28
  )
30
29
 
31
30
 
31
+ def _merge_update_kwargs(
32
+ import_payload: dict[str, Any] | None,
33
+ name: str | None,
34
+ transport: str | None,
35
+ description: str | None,
36
+ config: str | None,
37
+ auth: str | None,
38
+ mcp: Any,
39
+ ) -> dict[str, Any]:
40
+ """Merge import payload and CLI options into kwargs for SDK builder.
41
+
42
+ Args:
43
+ import_payload: Import payload dictionary or None
44
+ name: MCP name option
45
+ transport: Transport option
46
+ description: Description option
47
+ config: Config option
48
+ auth: Auth option
49
+ mcp: Current MCP object
50
+
51
+ Returns:
52
+ Dictionary with merged update kwargs
53
+ """
54
+ update_kwargs: dict[str, Any] = {}
55
+
56
+ # Start with import payload fields
57
+ if import_payload:
58
+ for field in ("name", "transport", "description", "config", "authentication"):
59
+ if field in import_payload:
60
+ update_kwargs[field] = import_payload[field]
61
+
62
+ # Override with CLI options (CLI takes precedence)
63
+ if name is not None:
64
+ update_kwargs["name"] = name
65
+ if transport is not None:
66
+ update_kwargs["transport"] = transport
67
+ if description is not None:
68
+ update_kwargs["description"] = description
69
+ _parse_and_validate_config_auth(update_kwargs, config, auth, transport, import_payload, mcp)
70
+
71
+ return update_kwargs
72
+
73
+
32
74
  @mcps_group.command()
33
75
  @click.argument("mcp_ref")
34
76
  @click.option("--name", help="New MCP name")
@@ -87,7 +129,8 @@ def update(
87
129
  Note:
88
130
  Must specify either --import OR at least one CLI field.
89
131
  CLI options override imported values when both are specified.
90
- Uses PATCH for import-based updates, PUT/PATCH for CLI-only updates.
132
+ Method selection (PATCH vs PUT) is handled automatically by the SDK client
133
+ based on the fields provided.
91
134
 
92
135
  \b
93
136
  Examples:
@@ -116,28 +159,29 @@ def update(
116
159
  if not _validate_import_payload_fields(import_payload):
117
160
  return
118
161
 
119
- # Build update_data from import payload and CLI options
120
- update_data = _assemble_update_data_from_import_payload(import_payload)
121
- update_data = _assemble_update_data_from_cli_options(
122
- update_data, name, transport, description, config, auth, import_payload, mcp
123
- )
162
+ # Merge import payload and CLI options into kwargs for SDK builder
163
+ update_kwargs = _merge_update_kwargs(import_payload, name, transport, description, config, auth, mcp)
124
164
 
125
- if not update_data:
165
+ if not update_kwargs:
126
166
  raise click.ClickException("No update fields specified")
127
167
 
168
+ # Build preview data for confirmation (using the same structure as before)
169
+ preview_data = update_kwargs.copy()
170
+
128
171
  # Show confirmation preview for import-based updates (unless -y flag)
129
172
  if not _handle_update_preview_and_confirmation(
130
- import_payload, y, mcp, update_data, name, transport, description, config, auth
173
+ import_payload, y, mcp, preview_data, name, transport, description, config, auth
131
174
  ):
132
175
  return
133
176
 
134
- # Update MCP
177
+ # Use SDK client method to update MCP
178
+ # Pass mcp object (not mcp.id) to avoid extra fetch; SDK accepts str | MCP
135
179
  with spinner_context(
136
180
  ctx,
137
181
  "[bold blue]Updating MCP…[/bold blue]",
138
182
  console_override=console,
139
183
  ):
140
- updated_mcp = client.mcps.update_mcp(mcp, **update_data)
184
+ updated_mcp = client.mcps.update_mcp(mcp, **update_kwargs)
141
185
 
142
186
  handle_json_output(ctx, updated_mcp.model_dump())
143
187
  handle_rich_output(ctx, display_update_success("MCP", updated_mcp.name))
@@ -13,14 +13,14 @@ from typing import Any
13
13
  import click
14
14
 
15
15
  from glaip_sdk.cli.context import get_ctx_value, output_flags
16
+ from glaip_sdk.cli.core.context import get_client, handle_best_effort_check
17
+ from glaip_sdk.cli.core.rendering import spinner_context
16
18
  from glaip_sdk.cli.display import (
17
19
  display_api_error,
18
20
  display_creation_success,
19
21
  handle_json_output,
20
22
  handle_rich_output,
21
23
  )
22
- from glaip_sdk.cli.core.context import get_client, handle_best_effort_check
23
- from glaip_sdk.cli.core.rendering import spinner_context
24
24
  from glaip_sdk.cli.io import load_resource_from_file_with_validation as load_resource_from_file
25
25
  from glaip_sdk.utils.import_export import merge_import_with_cli_args
26
26
 
@@ -170,7 +170,9 @@ class AccountsController:
170
170
  callbacks = AccountsTUICallbacks(switch_account=_switch_in_textual)
171
171
  active = next((row["name"] for row in rows if row.get("active")), None)
172
172
  try:
173
- run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks)
173
+ # Inject TUI context for theme support
174
+ tui_ctx = getattr(self.session, "tui_ctx", None)
175
+ run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks, ctx=tui_ctx)
174
176
  except Exception as exc: # pragma: no cover - defensive around Textual failures
175
177
  self.console.print(f"[{WARNING_STYLE}]Accounts browser exited unexpectedly: {exc}[/]")
176
178
 
@@ -6,6 +6,7 @@ Authors:
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  import importlib
10
11
  import os
11
12
  import shlex
@@ -51,6 +52,7 @@ from glaip_sdk.cli.slash.prompt import (
51
52
  to_formatted_text,
52
53
  )
53
54
  from glaip_sdk.cli.slash.remote_runs_controller import RemoteRunsController
55
+ from glaip_sdk.cli.slash.tui.context import TUIContext
54
56
  from glaip_sdk.cli.transcript import (
55
57
  export_cached_transcript,
56
58
  load_history_snapshot,
@@ -186,6 +188,7 @@ class SlashSession:
186
188
  self._update_notifier = maybe_notify_update
187
189
  self._home_hint_shown = False
188
190
  self._agent_transcript_ready: dict[str, str] = {}
191
+ self.tui_ctx: TUIContext | None = None
189
192
 
190
193
  # ------------------------------------------------------------------
191
194
  # Session orchestration
@@ -215,6 +218,22 @@ class SlashSession:
215
218
 
216
219
  def run(self, initial_commands: Iterable[str] | None = None) -> None:
217
220
  """Start the command palette session loop."""
221
+ # Initialize TUI context asynchronously
222
+ try:
223
+ self.tui_ctx = asyncio.run(TUIContext.create())
224
+ except RuntimeError:
225
+ try:
226
+ loop = asyncio.get_event_loop()
227
+ except RuntimeError:
228
+ self.tui_ctx = None
229
+ else:
230
+ if loop.is_running():
231
+ self.tui_ctx = None
232
+ else:
233
+ self.tui_ctx = loop.run_until_complete(TUIContext.create())
234
+ except Exception:
235
+ self.tui_ctx = None
236
+
218
237
  ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else None
219
238
  previous_session = None
220
239
  if ctx_obj is not None:
@@ -1,9 +1,34 @@
1
1
  """Textual UI helpers for slash commands."""
2
2
 
3
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter, ClipboardResult
4
+ from glaip_sdk.cli.slash.tui.context import TUIContext
5
+ from glaip_sdk.cli.slash.tui.keybind_registry import (
6
+ Keybind,
7
+ KeybindRegistry,
8
+ format_key_sequence,
9
+ parse_key_sequence,
10
+ )
11
+ from glaip_sdk.cli.slash.tui.toast import ToastBus, ToastVariant
3
12
  from glaip_sdk.cli.slash.tui.remote_runs_app import (
4
13
  RemoteRunsTextualApp,
5
14
  RemoteRunsTUICallbacks,
6
15
  run_remote_runs_textual,
7
16
  )
17
+ from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities, detect_terminal_background
8
18
 
9
- __all__ = ["RemoteRunsTextualApp", "RemoteRunsTUICallbacks", "run_remote_runs_textual"]
19
+ __all__ = [
20
+ "TUIContext",
21
+ "ToastBus",
22
+ "ToastVariant",
23
+ "TerminalCapabilities",
24
+ "detect_terminal_background",
25
+ "RemoteRunsTextualApp",
26
+ "RemoteRunsTUICallbacks",
27
+ "run_remote_runs_textual",
28
+ "KeybindRegistry",
29
+ "Keybind",
30
+ "parse_key_sequence",
31
+ "format_key_sequence",
32
+ "ClipboardAdapter",
33
+ "ClipboardResult",
34
+ ]
@@ -11,7 +11,7 @@
11
11
 
12
12
  #env-lock {
13
13
  padding: 0 1 0 1;
14
- color: yellow;
14
+ color: $warning;
15
15
  height: 1;
16
16
  }
17
17
 
@@ -45,6 +45,7 @@
45
45
  padding: 0 1 0 1;
46
46
  margin: 0 0 0 0;
47
47
  height: 1fr;
48
+ border: tall $primary;
48
49
  }
49
50
 
50
51
  #status-bar {
@@ -58,11 +59,12 @@
58
59
  }
59
60
 
60
61
  #status {
61
- padding: 0 1 0 1;
62
- margin: 0;
63
- color: cyan;
62
+ height: 3;
63
+ padding: 0 1;
64
+ color: $secondary;
64
65
  }
65
66
 
67
+
66
68
  .form-label {
67
69
  padding: 0 1 0 1;
68
70
  }
@@ -73,7 +75,7 @@
73
75
 
74
76
  #form-status, #confirm-status {
75
77
  padding: 0 1;
76
- color: yellow;
78
+ color: $warning;
77
79
  }
78
80
 
79
81
  #form-test {
@@ -23,7 +23,10 @@ from glaip_sdk.cli.slash.accounts_shared import (
23
23
  env_credentials_present,
24
24
  )
25
25
  from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
26
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
27
+ from glaip_sdk.cli.slash.tui.context import TUIContext
26
28
  from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
29
+ from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
27
30
  from glaip_sdk.cli.validators import validate_api_key
28
31
  from glaip_sdk.utils.validation import validate_url
29
32
 
@@ -51,6 +54,13 @@ except Exception: # pragma: no cover - optional dependency
51
54
  LoadingIndicator = None # type: ignore[assignment]
52
55
  ModalScreen = None # type: ignore[assignment]
53
56
  Static = None # type: ignore[assignment]
57
+ Theme = None # type: ignore[assignment]
58
+
59
+ if App is not None:
60
+ try: # pragma: no cover - optional dependency
61
+ from textual.theme import Theme
62
+ except Exception: # pragma: no cover - optional dependency
63
+ Theme = None # type: ignore[assignment]
54
64
 
55
65
  TEXTUAL_SUPPORTED = App is not None and DataTable is not None
56
66
 
@@ -201,11 +211,12 @@ def run_accounts_textual(
201
211
  active_account: str | None,
202
212
  env_lock: bool,
203
213
  callbacks: AccountsTUICallbacks,
214
+ ctx: TUIContext | None = None,
204
215
  ) -> None:
205
216
  """Launch the Textual accounts browser if dependencies are available."""
206
217
  if not TEXTUAL_SUPPORTED:
207
218
  return
208
- app = AccountsTextualApp(rows, active_account, env_lock, callbacks)
219
+ app = AccountsTextualApp(rows, active_account, env_lock, callbacks, ctx=ctx)
209
220
  app.run()
210
221
 
211
222
 
@@ -379,16 +390,18 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
379
390
 
380
391
  CSS_PATH = CSS_FILE_NAME
381
392
  BINDINGS = [
382
- Binding("enter", "switch_row", "Switch", show=True),
383
- Binding("return", "switch_row", "Switch", show=False),
384
- Binding("/", "focus_filter", "Filter", show=True),
385
- Binding("a", "add_account", "Add", show=True),
386
- Binding("e", "edit_account", "Edit", show=True),
387
- Binding("d", "delete_account", "Delete", show=True),
393
+ Binding("enter", "switch_row", "Switch", show=True) if Binding else None,
394
+ Binding("return", "switch_row", "Switch", show=False) if Binding else None,
395
+ Binding("/", "focus_filter", "Filter", show=True) if Binding else None,
396
+ Binding("a", "add_account", "Add", show=True) if Binding else None,
397
+ Binding("e", "edit_account", "Edit", show=True) if Binding else None,
398
+ Binding("d", "delete_account", "Delete", show=True) if Binding else None,
399
+ Binding("c", "copy_account", "Copy", show=True) if Binding else None,
388
400
  # Esc clears filter when focused/non-empty; otherwise exits
389
- Binding("escape", "clear_or_exit", "Close", priority=True),
390
- Binding("q", "app_exit", "Close", priority=True),
401
+ Binding("escape", "clear_or_exit", "Close", priority=True) if Binding else None,
402
+ Binding("q", "app_exit", "Close", priority=True) if Binding else None,
391
403
  ]
404
+ BINDINGS = [b for b in BINDINGS if b is not None]
392
405
 
393
406
  def __init__(
394
407
  self,
@@ -396,6 +409,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
396
409
  active_account: str | None,
397
410
  env_lock: bool,
398
411
  callbacks: AccountsTUICallbacks,
412
+ ctx: TUIContext | None = None,
399
413
  ) -> None:
400
414
  """Initialize the Textual accounts app.
401
415
 
@@ -404,6 +418,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
404
418
  active_account: Name of the currently active account.
405
419
  env_lock: Whether environment credentials are locking account switching.
406
420
  callbacks: Callbacks for account switching operations.
421
+ ctx: Shared TUI context.
407
422
  """
408
423
  super().__init__()
409
424
  self._store = get_account_store()
@@ -411,6 +426,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
411
426
  self._active_account = active_account
412
427
  self._env_lock = env_lock
413
428
  self._callbacks = callbacks
429
+ self._ctx = ctx
414
430
  self._filter_text: str = ""
415
431
  self._is_switching = False
416
432
 
@@ -449,6 +465,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
449
465
 
450
466
  def on_mount(self) -> None:
451
467
  """Configure table columns and load rows."""
468
+ self._apply_theme()
452
469
  table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
453
470
  table.add_column("Name", width=20)
454
471
  table.add_column("API URL", width=40)
@@ -752,6 +769,26 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
752
769
  return
753
770
  self.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
754
771
 
772
+ def action_copy_account(self) -> None:
773
+ """Copy selected account name and URL to clipboard."""
774
+ name = self._get_selected_name()
775
+ if not name:
776
+ self._set_status("Select an account to copy.", "yellow")
777
+ return
778
+
779
+ account = self._store.get_account(name)
780
+ if not account:
781
+ return
782
+
783
+ text = f"Account: {name}\nURL: {account.get('api_url', '')}"
784
+ adapter = ClipboardAdapter()
785
+ result = adapter.copy(text)
786
+
787
+ if result.success:
788
+ self._set_status(f"Copied '{name}' to clipboard.", "green")
789
+ else:
790
+ self._set_status(f"Copy failed: {result.message}", "red")
791
+
755
792
  def _check_env_lock_hotkey(self) -> bool:
756
793
  """Prevent mutations when env credentials are present."""
757
794
  if not self._is_env_locked():
@@ -874,3 +911,23 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
874
911
  filter_input = self.query_one(FILTER_INPUT_ID, Input)
875
912
  clear_btn = self.query_one("#filter-clear", Button)
876
913
  clear_btn.display = bool(filter_input.value or self._filter_text)
914
+
915
+ def _apply_theme(self) -> None:
916
+ """Register built-in themes and set the active one from context."""
917
+ if not self._ctx or not self._ctx.theme or Theme is None:
918
+ return
919
+
920
+ for name, tokens in _BUILTIN_THEMES.items():
921
+ self.register_theme(
922
+ Theme(
923
+ name=name,
924
+ primary=tokens.primary,
925
+ secondary=tokens.secondary,
926
+ accent=tokens.accent,
927
+ warning=tokens.warning,
928
+ error=tokens.error,
929
+ success=tokens.success,
930
+ )
931
+ )
932
+
933
+ self.theme = self._ctx.theme.theme_name