glaip-sdk 0.6.2__py3-none-any.whl → 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
glaip_sdk/agents/base.py CHANGED
@@ -51,10 +51,12 @@ from typing import TYPE_CHECKING, Any
51
51
 
52
52
  from glaip_sdk.registry import get_agent_registry, get_mcp_registry, get_tool_registry
53
53
  from glaip_sdk.utils.discovery import find_agent
54
+ from glaip_sdk.utils.resource_refs import is_uuid
54
55
  from glaip_sdk.utils.runtime_config import normalize_runtime_config_keys
55
56
 
56
57
  if TYPE_CHECKING:
57
58
  from glaip_sdk.models import AgentResponse
59
+ from glaip_sdk.registry import AgentRegistry, MCPRegistry, ToolRegistry
58
60
 
59
61
  logger = logging.getLogger(__name__)
60
62
 
@@ -519,7 +521,7 @@ class Agent:
519
521
 
520
522
  return self
521
523
 
522
- def _build_config(self, tool_registry: Any, mcp_registry: Any) -> dict[str, Any]:
524
+ def _build_config(self, tool_registry: ToolRegistry, mcp_registry: MCPRegistry) -> dict[str, Any]:
523
525
  """Build the base configuration dictionary.
524
526
 
525
527
  Args:
@@ -558,9 +560,9 @@ class Agent:
558
560
  if self.mcps:
559
561
  config["mcps"] = self._resolve_mcps(mcp_registry)
560
562
 
561
- # Handle mcp_configs
563
+ # Handle mcp_configs - normalize keys to MCP IDs
562
564
  if self.mcp_configs:
563
- config["mcp_configs"] = self.mcp_configs
565
+ config["mcp_configs"] = self._resolve_mcp_configs(mcp_registry)
564
566
 
565
567
  # Handle a2a_profile
566
568
  if self.a2a_profile:
@@ -568,7 +570,7 @@ class Agent:
568
570
 
569
571
  return config
570
572
 
571
- def _resolve_mcps(self, registry: Any) -> list[str]:
573
+ def _resolve_mcps(self, registry: MCPRegistry) -> list[str]:
572
574
  """Resolve MCP references to IDs using MCPRegistry.
573
575
 
574
576
  Uses the global MCPRegistry to cache MCP objects across deployments.
@@ -586,7 +588,7 @@ class Agent:
586
588
 
587
589
  return [registry.resolve(mcp_ref).id for mcp_ref in self.mcps]
588
590
 
589
- def _resolve_tools(self, registry: Any) -> list[str]:
591
+ def _resolve_tools(self, registry: ToolRegistry) -> list[str]:
590
592
  """Resolve tool references to IDs using ToolRegistry.
591
593
 
592
594
  Uses the global ToolRegistry to cache Tool objects across deployments.
@@ -605,7 +607,7 @@ class Agent:
605
607
  # Resolve each tool reference to a Tool object, extract ID
606
608
  return [registry.resolve(tool_ref).id for tool_ref in self.tools]
607
609
 
608
- def _resolve_tool_configs(self, registry: Any) -> dict[str, Any]:
610
+ def _resolve_tool_configs(self, registry: ToolRegistry) -> dict[str, Any]:
609
611
  """Resolve tool_configs keys from tool names/classes to tool IDs.
610
612
 
611
613
  Allows tool_configs to be defined with tool names, class names, or
@@ -639,7 +641,7 @@ class Agent:
639
641
 
640
642
  for key, config in self.tool_configs.items():
641
643
  # If key is already a UUID-like string, pass through
642
- if isinstance(key, str) and len(key) == 36 and "-" in key:
644
+ if isinstance(key, str) and is_uuid(key):
643
645
  resolved[key] = config
644
646
  continue
645
647
 
@@ -652,7 +654,51 @@ class Agent:
652
654
 
653
655
  return resolved
654
656
 
655
- def _resolve_agents(self, registry: Any) -> list:
657
+ def _resolve_mcp_configs(self, registry: MCPRegistry) -> dict[str, Any]:
658
+ """Resolve mcp_configs keys from MCP names/objects to MCP IDs.
659
+
660
+ Allows mcp_configs to be defined with MCP names, MCP objects, or UUIDs
661
+ as keys. Keys are resolved to MCP IDs using the MCPRegistry.
662
+
663
+ Supported key formats:
664
+ - MCP object (with id): uses id directly
665
+ - MCP name string: resolved via registry to ID
666
+ - MCP ID (UUID string): passed through unchanged
667
+
668
+ Args:
669
+ registry: The MCP registry.
670
+
671
+ Returns:
672
+ Dict with resolved MCP IDs as keys and configs as values.
673
+ """
674
+ if not self.mcp_configs:
675
+ return {}
676
+
677
+ resolved: dict[str, Any] = {}
678
+
679
+ for key, config in self.mcp_configs.items():
680
+ try:
681
+ # If key is already a UUID-like string, pass through
682
+ if isinstance(key, str) and is_uuid(key):
683
+ resolved_id = key
684
+ else:
685
+ mcp = registry.resolve(key)
686
+ resolved_id = mcp.id
687
+
688
+ if resolved_id in resolved:
689
+ raise ValueError(
690
+ f"Duplicate mcp_configs entries resolve to the same MCP id '{resolved_id}' (key={key!r})"
691
+ )
692
+
693
+ resolved[resolved_id] = config
694
+ except (ValueError, KeyError) as exc:
695
+ raise ValueError(
696
+ f"Failed to resolve mcp config key {key!r} (type={type(key).__name__}): {exc}"
697
+ ) from exc
698
+
699
+ return resolved
700
+
701
+ def _resolve_agents(self, registry: AgentRegistry) -> list:
656
702
  """Resolve sub-agent references using AgentRegistry.
657
703
 
658
704
  Uses the global AgentRegistry to cache Agent objects across deployments.
@@ -217,13 +217,14 @@ class AccountStore:
217
217
  api_key: API key or None.
218
218
 
219
219
  Returns:
220
- Dictionary with "default" account if credentials exist, empty dict otherwise.
220
+ Dictionary with "default" account if both credentials exist and are non-empty, empty dict otherwise.
221
221
  """
222
222
  accounts = {}
223
- if api_url or api_key:
223
+ # Only create default account if both URL and key are present and non-empty
224
+ if api_url and api_key and api_url.strip() and api_key.strip():
224
225
  accounts["default"] = {
225
- "api_url": api_url or "",
226
- "api_key": api_key or "",
226
+ "api_url": api_url.strip(),
227
+ "api_key": api_key.strip(),
227
228
  }
228
229
  return accounts
229
230
 
@@ -257,15 +258,30 @@ class AccountStore:
257
258
  "accounts": {},
258
259
  }
259
260
 
260
- # Extract legacy api_url and api_key
261
- api_url = config.get("api_url")
262
- api_key = config.get("api_key")
261
+ # Preserve existing accounts if they exist (shouldn't happen in true migration, but defensive)
262
+ existing_accounts = config.get("accounts", {})
263
+ if existing_accounts:
264
+ migrated["accounts"] = existing_accounts.copy()
265
+ existing_active = config.get("active_account")
266
+ if existing_active and existing_active in existing_accounts:
267
+ migrated["active_account"] = existing_active
268
+ elif "default" in existing_accounts:
269
+ migrated["active_account"] = "default"
270
+ else:
271
+ migrated["active_account"] = sorted(existing_accounts.keys())[0]
272
+ else:
273
+ # Extract legacy api_url and api_key only if no accounts exist
274
+ api_url = config.get("api_url")
275
+ api_key = config.get("api_key")
263
276
 
264
- # Check for auth.json from secure login MVP (only during migration)
265
- api_url, api_key = self._load_auth_json_credentials(api_url, api_key)
277
+ # Check for auth.json from secure login MVP (only during migration)
278
+ api_url, api_key = self._load_auth_json_credentials(api_url, api_key)
266
279
 
267
- # Create default account if we have credentials
268
- migrated["accounts"] = self._create_default_account(api_url, api_key)
280
+ # Create default account if we have valid credentials
281
+ migrated["accounts"] = self._create_default_account(api_url, api_key)
282
+ # Only set active_account to default if we actually created a default account
283
+ if not migrated["accounts"]:
284
+ migrated.pop("active_account", None)
269
285
 
270
286
  # Preserve other top-level keys for backward compatibility
271
287
  migrated.update(self._preserve_legacy_keys(config))
@@ -423,16 +439,18 @@ class AccountStore:
423
439
 
424
440
  del accounts[name]
425
441
 
426
- # If we removed the active account, switch to another
442
+ # If we removed the active account, switch to another account
427
443
  active_account = config.get("active_account")
428
444
  if active_account == name:
429
- # Try to switch to 'default' if it exists, otherwise first alphabetical
430
- remaining_names = sorted(accounts.keys())
431
- if "default" in remaining_names:
445
+ # Prefer "default" if it exists, otherwise use first alphabetical account
446
+ if "default" in accounts:
432
447
  config["active_account"] = "default"
433
- elif remaining_names:
434
- config["active_account"] = remaining_names[0]
435
- else: # pragma: no cover - defensive code, unreachable due to len check above
448
+ elif accounts:
449
+ # Sort accounts alphabetically and pick the first one
450
+ sorted_names = sorted(accounts.keys())
451
+ config["active_account"] = sorted_names[0]
452
+ else:
453
+ # No accounts remaining (shouldn't happen due to check above)
436
454
  config.pop("active_account", None)
437
455
 
438
456
  self._save_config(config)
glaip_sdk/cli/auth.py CHANGED
@@ -460,7 +460,7 @@ def _prompt_secret_with_placeholder(
460
460
  )
461
461
 
462
462
  attempts = 0
463
- while attempts <= retry_limit:
463
+ while attempts <= retry_limit: # pragma: no cover
464
464
  response = click.prompt(
465
465
  prompt_message,
466
466
  default="",
@@ -6,7 +6,6 @@ Authors:
6
6
 
7
7
  import getpass
8
8
  import json
9
- import os
10
9
  import sys
11
10
  from pathlib import Path
12
11
 
@@ -33,6 +32,7 @@ from glaip_sdk.cli.account_store import (
33
32
  from glaip_sdk.cli.commands.common_config import check_connection, render_branding_header
34
33
  from glaip_sdk.cli.hints import format_command_hint
35
34
  from glaip_sdk.cli.masking import mask_api_key_display
35
+ from glaip_sdk.cli.slash.accounts_shared import env_credentials_present
36
36
  from glaip_sdk.cli.utils import command_hint
37
37
  from glaip_sdk.icons import ICON_TOOL
38
38
  from glaip_sdk.rich_components import AIPPanel, AIPTable
@@ -231,7 +231,7 @@ def show_account(name: str, output_json: bool) -> None:
231
231
  masked_key = _mask_api_key(api_key or "")
232
232
  active_account = store.get_active_account()
233
233
  is_active = active_account == name
234
- env_lock = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
234
+ env_lock = env_credentials_present(partial=True)
235
235
  config_path_raw = str(store.config_file)
236
236
  config_path_display = _format_config_path(config_path_raw)
237
237
 
@@ -0,0 +1,79 @@
1
+ """CLI core modules for glaip-sdk.
2
+
3
+ This package contains focused modules extracted from the monolithic cli/utils.py:
4
+ - context: Click context helpers, config loading, credential resolution
5
+ - prompting: prompt_toolkit + questionary wrappers, validators
6
+ - rendering: Rich console helpers, viewer launchers, renderer builders
7
+ - output: Table/console output utilities, list rendering
8
+
9
+ Authors:
10
+ Raymond Christopher (raymond.christopher@gdplabs.id)
11
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
12
+ """ # pylint: disable=duplicate-code
13
+
14
+ from __future__ import annotations
15
+
16
+ # Re-export all public APIs from submodules for convenience
17
+ from glaip_sdk.cli.core.context import (
18
+ bind_slash_session_context,
19
+ get_client,
20
+ handle_best_effort_check,
21
+ restore_slash_session_context,
22
+ )
23
+ from glaip_sdk.cli.core.output import (
24
+ coerce_to_row,
25
+ detect_export_format,
26
+ fetch_resource_for_export,
27
+ format_datetime_fields,
28
+ format_size,
29
+ handle_resource_export,
30
+ output_list,
31
+ output_result,
32
+ parse_json_line,
33
+ resolve_resource,
34
+ handle_ambiguous_resource,
35
+ sdk_version,
36
+ )
37
+ from glaip_sdk.cli.core.prompting import (
38
+ _fuzzy_pick_for_resources,
39
+ prompt_export_choice_questionary,
40
+ questionary_safe_ask,
41
+ )
42
+ from glaip_sdk.cli.core.rendering import (
43
+ build_renderer,
44
+ spinner_context,
45
+ stop_spinner,
46
+ update_spinner,
47
+ with_client_and_spinner,
48
+ )
49
+
50
+ __all__ = [
51
+ # Context
52
+ "bind_slash_session_context",
53
+ "get_client",
54
+ "handle_best_effort_check",
55
+ "restore_slash_session_context",
56
+ # Prompting
57
+ "_fuzzy_pick_for_resources",
58
+ "prompt_export_choice_questionary",
59
+ "questionary_safe_ask",
60
+ # Rendering
61
+ "build_renderer",
62
+ "spinner_context",
63
+ "stop_spinner",
64
+ "update_spinner",
65
+ "with_client_and_spinner",
66
+ # Output
67
+ "coerce_to_row",
68
+ "detect_export_format",
69
+ "fetch_resource_for_export",
70
+ "format_datetime_fields",
71
+ "format_size",
72
+ "handle_resource_export",
73
+ "output_list",
74
+ "output_result",
75
+ "parse_json_line",
76
+ "resolve_resource",
77
+ "handle_ambiguous_resource",
78
+ "sdk_version",
79
+ ]
@@ -0,0 +1,124 @@
1
+ """CLI context helpers, config loading, and credential resolution.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import os
12
+ from collections.abc import Mapping
13
+ from contextlib import contextmanager
14
+ from typing import TYPE_CHECKING, Any, cast
15
+
16
+ import click
17
+
18
+ from glaip_sdk.cli.config import load_config
19
+ from glaip_sdk.cli.hints import command_hint
20
+
21
+ if TYPE_CHECKING: # pragma: no cover - import-only during type checking
22
+ from glaip_sdk import Client
23
+
24
+
25
+ @contextmanager
26
+ def bind_slash_session_context(ctx: Any, session: Any) -> Any:
27
+ """Temporarily attach a slash session to the Click context.
28
+
29
+ Args:
30
+ ctx: Click context object.
31
+ session: SlashSession instance to bind.
32
+
33
+ Yields:
34
+ None - context manager for use in with statement.
35
+ """
36
+ ctx_obj = getattr(ctx, "obj", None)
37
+ has_context = isinstance(ctx_obj, dict)
38
+ previous_session = ctx_obj.get("_slash_session") if has_context else None
39
+ if has_context:
40
+ ctx_obj["_slash_session"] = session
41
+ try:
42
+ yield
43
+ finally:
44
+ if has_context:
45
+ if previous_session is None:
46
+ ctx_obj.pop("_slash_session", None)
47
+ else:
48
+ ctx_obj["_slash_session"] = previous_session
49
+
50
+
51
+ def restore_slash_session_context(ctx_obj: dict[str, Any], previous_session: Any | None) -> None:
52
+ """Restore slash session context after operation.
53
+
54
+ Args:
55
+ ctx_obj: Click context obj dictionary.
56
+ previous_session: Previous session to restore, or None to remove.
57
+ """
58
+ if previous_session is None:
59
+ ctx_obj.pop("_slash_session", None)
60
+ else:
61
+ ctx_obj["_slash_session"] = previous_session
62
+
63
+
64
+ def handle_best_effort_check(
65
+ check_func: Any,
66
+ ) -> None:
67
+ """Handle best-effort duplicate/existence checks with proper exception handling.
68
+
69
+ Args:
70
+ check_func: Function that performs the check and raises ClickException if duplicate found.
71
+ """
72
+ try:
73
+ check_func()
74
+ except click.ClickException:
75
+ raise
76
+ except Exception:
77
+ # Non-fatal: best-effort duplicate check
78
+ pass
79
+
80
+
81
+ def get_client(ctx: Any) -> Client: # pragma: no cover
82
+ """Get configured client from context and account store (ctx > account)."""
83
+ # Import here to avoid circular import
84
+ from glaip_sdk.cli.auth import resolve_credentials # noqa: PLC0415
85
+
86
+ module = importlib.import_module("glaip_sdk")
87
+ client_class = cast("type[Client]", module.Client)
88
+ context_config_obj = getattr(ctx, "obj", None)
89
+ context_config = context_config_obj if isinstance(context_config_obj, Mapping) else {}
90
+
91
+ account_name = context_config.get("account_name")
92
+ api_url, api_key, _ = resolve_credentials(
93
+ account_name=account_name,
94
+ api_url=context_config.get("api_url"),
95
+ api_key=context_config.get("api_key"),
96
+ )
97
+
98
+ if not api_url or not api_key:
99
+ configure_hint = command_hint("accounts add", slash_command="login", ctx=ctx)
100
+ actions: list[str] = []
101
+ if configure_hint:
102
+ actions.append(f"Run `{configure_hint}` to add an account profile")
103
+ else:
104
+ actions.append("add an account with 'aip accounts add'")
105
+ raise click.ClickException(f"Missing api_url/api_key. {' or '.join(actions)}.")
106
+
107
+ # Get timeout from context or config
108
+ timeout = context_config.get("timeout")
109
+ if timeout is None:
110
+ raw_timeout = os.getenv("AIP_TIMEOUT", "0") or "0"
111
+ try:
112
+ timeout = float(raw_timeout) if raw_timeout != "0" else None
113
+ except ValueError:
114
+ timeout = None
115
+ if timeout is None:
116
+ # Fallback to legacy config
117
+ file_config = load_config() or {}
118
+ timeout = file_config.get("timeout")
119
+
120
+ return client_class(
121
+ api_url=api_url,
122
+ api_key=api_key,
123
+ timeout=float(timeout or 30.0),
124
+ )