glaip-sdk 0.6.0__py3-none-any.whl → 0.6.2__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
@@ -44,12 +44,14 @@ from __future__ import annotations
44
44
 
45
45
  import inspect
46
46
  import logging
47
+ import warnings
47
48
  from collections.abc import AsyncGenerator
48
49
  from pathlib import Path
49
50
  from typing import TYPE_CHECKING, Any
50
51
 
51
52
  from glaip_sdk.registry import get_agent_registry, get_mcp_registry, get_tool_registry
52
53
  from glaip_sdk.utils.discovery import find_agent
54
+ from glaip_sdk.utils.runtime_config import normalize_runtime_config_keys
53
55
 
54
56
  if TYPE_CHECKING:
55
57
  from glaip_sdk.models import AgentResponse
@@ -127,16 +129,8 @@ class Agent:
127
129
  agents: list | None = None,
128
130
  mcps: list | None = None,
129
131
  model: str | None = _UNSET, # type: ignore[assignment]
130
- timeout: int | None = _UNSET, # type: ignore[assignment]
131
- metadata: dict[str, Any] | None = _UNSET, # type: ignore[assignment]
132
- framework: str | None = _UNSET, # type: ignore[assignment]
133
- version: str | None = _UNSET, # type: ignore[assignment]
134
- agent_type: str | None = _UNSET, # type: ignore[assignment]
135
- agent_config: dict[str, Any] | None = _UNSET, # type: ignore[assignment]
136
- tool_configs: dict[str, Any] | None = _UNSET, # type: ignore[assignment]
137
- mcp_configs: dict[str, Any] | None = _UNSET, # type: ignore[assignment]
138
- a2a_profile: dict[str, Any] | None = _UNSET, # type: ignore[assignment]
139
132
  _client: Any = None,
133
+ **kwargs: Any,
140
134
  ) -> None:
141
135
  """Initialize an Agent.
142
136
 
@@ -152,16 +146,17 @@ class Agent:
152
146
  agents: List of sub-agents (Agent classes, instances, or strings).
153
147
  mcps: List of MCPs.
154
148
  model: Model identifier.
155
- timeout: Execution timeout in seconds.
156
- metadata: Optional metadata dictionary.
157
- framework: Agent framework identifier.
158
- version: Agent version string.
159
- agent_type: Agent type identifier.
160
- agent_config: Agent execution configuration.
161
- tool_configs: Per-tool configuration overrides.
162
- mcp_configs: Per-MCP configuration overrides.
163
- a2a_profile: A2A profile configuration.
164
149
  _client: Internal client reference (set automatically).
150
+ **kwargs: Additional configuration parameters:
151
+ - timeout: Execution timeout in seconds.
152
+ - metadata: Optional metadata dictionary.
153
+ - framework: Agent framework identifier.
154
+ - version: Agent version string.
155
+ - agent_type: Agent type identifier.
156
+ - agent_config: Agent execution configuration.
157
+ - tool_configs: Per-tool configuration overrides.
158
+ - mcp_configs: Per-MCP configuration overrides.
159
+ - a2a_profile: A2A profile configuration.
165
160
  """
166
161
  # Instance attributes for deployed agents
167
162
  self._id = id
@@ -178,15 +173,24 @@ class Agent:
178
173
  self._mcps = mcps
179
174
  self._model = model
180
175
  self._language_model_id: str | None = None
181
- self._timeout = timeout
182
- self._metadata = metadata
183
- self._framework = framework
184
- self._version = version
185
- self._agent_type = agent_type
186
- self._agent_config = agent_config
187
- self._tool_configs = tool_configs
188
- self._mcp_configs = mcp_configs
189
- self._a2a_profile = a2a_profile
176
+ # Extract parameters from kwargs with _UNSET defaults
177
+ self._timeout = kwargs.pop("timeout", Agent._UNSET) # type: ignore[assignment]
178
+ self._metadata = kwargs.pop("metadata", Agent._UNSET) # type: ignore[assignment]
179
+ self._framework = kwargs.pop("framework", Agent._UNSET) # type: ignore[assignment]
180
+ self._version = kwargs.pop("version", Agent._UNSET) # type: ignore[assignment]
181
+ self._agent_type = kwargs.pop("agent_type", Agent._UNSET) # type: ignore[assignment]
182
+ self._agent_config = kwargs.pop("agent_config", Agent._UNSET) # type: ignore[assignment]
183
+ self._tool_configs = kwargs.pop("tool_configs", Agent._UNSET) # type: ignore[assignment]
184
+ self._mcp_configs = kwargs.pop("mcp_configs", Agent._UNSET) # type: ignore[assignment]
185
+ self._a2a_profile = kwargs.pop("a2a_profile", Agent._UNSET) # type: ignore[assignment]
186
+
187
+ # Warn about unexpected kwargs
188
+ if kwargs:
189
+ warnings.warn(
190
+ f"Unexpected keyword arguments: {list(kwargs.keys())}. These will be ignored.",
191
+ UserWarning,
192
+ stacklevel=2,
193
+ )
190
194
 
191
195
  # ─────────────────────────────────────────────────────────────────
192
196
  # Properties (override in subclasses OR pass to __init__)
@@ -792,21 +796,23 @@ class Agent:
792
796
  self._client = client
793
797
  return self
794
798
 
795
- def run(
799
+ def _prepare_run_kwargs(
796
800
  self,
797
801
  message: str,
798
- verbose: bool = False,
802
+ verbose: bool,
803
+ runtime_config: dict[str, Any] | None,
799
804
  **kwargs: Any,
800
- ) -> str:
801
- """Run the agent synchronously with a message.
805
+ ) -> tuple[Any, dict[str, Any]]:
806
+ """Prepare common arguments for run/arun methods.
802
807
 
803
808
  Args:
804
809
  message: The message to send to the agent.
805
810
  verbose: If True, print streaming output to console.
811
+ runtime_config: Optional runtime configuration.
806
812
  **kwargs: Additional arguments to pass to the run API.
807
813
 
808
814
  Returns:
809
- The agent's response as a string.
815
+ Tuple of (agent_client, call_kwargs).
810
816
 
811
817
  Raises:
812
818
  ValueError: If the agent hasn't been deployed yet.
@@ -817,24 +823,77 @@ class Agent:
817
823
  if not self._client:
818
824
  raise RuntimeError(_CLIENT_NOT_AVAILABLE_MSG)
819
825
 
820
- # _client can be either main Client or AgentClient
821
826
  agent_client = getattr(self._client, "agents", self._client)
822
- return agent_client.run_agent(
823
- agent_id=self.id,
824
- message=message,
825
- verbose=verbose,
826
- **kwargs,
827
+
828
+ call_kwargs: dict[str, Any] = {
829
+ "agent_id": self.id,
830
+ "message": message,
831
+ "verbose": verbose,
832
+ }
833
+
834
+ if runtime_config is not None:
835
+ call_kwargs["runtime_config"] = normalize_runtime_config_keys(
836
+ runtime_config,
837
+ tool_registry=get_tool_registry(),
838
+ mcp_registry=get_mcp_registry(),
839
+ agent_registry=get_agent_registry(),
840
+ )
841
+
842
+ call_kwargs.update(kwargs)
843
+ return agent_client, call_kwargs
844
+
845
+ def run(
846
+ self,
847
+ message: str,
848
+ verbose: bool = False,
849
+ runtime_config: dict[str, Any] | None = None,
850
+ **kwargs: Any,
851
+ ) -> str:
852
+ """Run the agent synchronously with a message.
853
+
854
+ Args:
855
+ message: The message to send to the agent.
856
+ verbose: If True, print streaming output to console.
857
+ runtime_config: Optional runtime configuration for tools, MCPs, and agents.
858
+ Keys can be SDK objects, UUIDs, or names. Example:
859
+ {
860
+ "tool_configs": {"tool-id": {"param": "value"}},
861
+ "mcp_configs": {"mcp-id": {"setting": "on"}},
862
+ "agent_config": {"planning": True},
863
+ }
864
+ **kwargs: Additional arguments to pass to the run API.
865
+
866
+ Returns:
867
+ The agent's response as a string.
868
+
869
+ Raises:
870
+ ValueError: If the agent hasn't been deployed yet.
871
+ RuntimeError: If client is not available.
872
+ """
873
+ agent_client, call_kwargs = self._prepare_run_kwargs(
874
+ message, verbose, runtime_config or kwargs.get("runtime_config"), **kwargs
827
875
  )
876
+ return agent_client.run_agent(**call_kwargs)
828
877
 
829
878
  async def arun(
830
879
  self,
831
880
  message: str,
881
+ verbose: bool = False,
882
+ runtime_config: dict[str, Any] | None = None,
832
883
  **kwargs: Any,
833
884
  ) -> AsyncGenerator[dict, None]:
834
885
  """Run the agent asynchronously with streaming output.
835
886
 
836
887
  Args:
837
888
  message: The message to send to the agent.
889
+ verbose: If True, print streaming output to console.
890
+ runtime_config: Optional runtime configuration for tools, MCPs, and agents.
891
+ Keys can be SDK objects, UUIDs, or names. Example:
892
+ {
893
+ "tool_configs": {"tool-id": {"param": "value"}},
894
+ "mcp_configs": {"mcp-id": {"setting": "on"}},
895
+ "agent_config": {"planning": True},
896
+ }
838
897
  **kwargs: Additional arguments to pass to the run API.
839
898
 
840
899
  Yields:
@@ -844,18 +903,10 @@ class Agent:
844
903
  ValueError: If the agent hasn't been deployed yet.
845
904
  RuntimeError: If client is not available.
846
905
  """
847
- if not self.id:
848
- raise ValueError(_AGENT_NOT_DEPLOYED_MSG)
849
- if not self._client:
850
- raise RuntimeError(_CLIENT_NOT_AVAILABLE_MSG)
851
-
852
- # _client can be either main Client or AgentClient
853
- agent_client = getattr(self._client, "agents", self._client)
854
- async for chunk in agent_client.arun_agent(
855
- agent_id=self.id,
856
- message=message,
857
- **kwargs,
858
- ):
906
+ agent_client, call_kwargs = self._prepare_run_kwargs(
907
+ message, verbose, runtime_config or kwargs.get("runtime_config"), **kwargs
908
+ )
909
+ async for chunk in agent_client.arun_agent(**call_kwargs):
859
910
  yield chunk
860
911
 
861
912
  def update(self, **kwargs: Any) -> Agent:
@@ -880,13 +931,17 @@ class Agent:
880
931
  agent_client = getattr(self._client, "agents", self._client)
881
932
  response = agent_client.update_agent(agent_id=self.id, **kwargs)
882
933
 
883
- # Update local properties from response
884
- if hasattr(response, "name") and response.name:
885
- self.name = response.name
886
- if hasattr(response, "instruction") and response.instruction:
887
- self.instruction = response.instruction
888
- if hasattr(response, "updated_at"):
889
- self.updated_at = response.updated_at
934
+ # Update local properties from response (read-only props via private attrs)
935
+ name = getattr(response, "name", None)
936
+ if name:
937
+ self._name = name
938
+
939
+ instruction = getattr(response, "instruction", None)
940
+ if instruction:
941
+ self._instruction = instruction
942
+
943
+ # Populate remaining fields like description, metadata, updated_at, etc.
944
+ type(self)._populate_from_response(self, response)
890
945
 
891
946
  return self
892
947
 
@@ -1,6 +1,7 @@
1
1
  """Shared helpers for configuration/account flows."""
2
2
 
3
3
  import click
4
+ import logging
4
5
  from rich.console import Console
5
6
  from rich.text import Text
6
7
 
@@ -63,3 +64,38 @@ def check_connection(
63
64
  finally:
64
65
  if client is not None:
65
66
  client.close()
67
+
68
+
69
+ def check_connection_with_reason(
70
+ api_url: str,
71
+ api_key: str,
72
+ *,
73
+ abort_on_error: bool = False,
74
+ ) -> tuple[bool, str]:
75
+ """Test connectivity and return structured reason."""
76
+ client: Client | None = None
77
+ try:
78
+ # Import lazily so test patches targeting glaip_sdk.Client are honored
79
+ from importlib import import_module # noqa: PLC0415
80
+
81
+ client_module = import_module("glaip_sdk")
82
+ client = client_module.Client(api_url=api_url, api_key=api_key)
83
+ try:
84
+ client.list_agents()
85
+ return True, ""
86
+ except Exception as exc: # pragma: no cover - API failures depend on network
87
+ if abort_on_error:
88
+ raise click.Abort() from exc
89
+ return False, f"api_failed: {exc}"
90
+ except Exception as exc:
91
+ # Log unexpected exceptions in debug while keeping CLI-friendly messaging
92
+ logging.getLogger(__name__).debug("Unexpected connection error", exc_info=exc)
93
+ if abort_on_error:
94
+ raise click.Abort() from exc
95
+ return False, f"connection_failed: {exc}"
96
+ finally:
97
+ if client is not None:
98
+ try:
99
+ client.close()
100
+ except Exception:
101
+ pass
glaip_sdk/cli/config.py CHANGED
@@ -12,15 +12,26 @@ from typing import Any
12
12
  import yaml
13
13
 
14
14
  _ENV_CONFIG_DIR = os.getenv("AIP_CONFIG_DIR")
15
- _TEST_ENV = os.getenv("PYTEST_CURRENT_TEST") or os.getenv("PYTEST_XDIST_WORKER")
15
+ # Detect pytest environment: check for pytest markers or test session
16
+ # This provides automatic test isolation even if conftest.py doesn't set AIP_CONFIG_DIR
17
+ # Note: conftest.py sets AIP_CONFIG_DIR before imports, which takes precedence
18
+ _TEST_ENV = os.getenv("PYTEST_CURRENT_TEST") or os.getenv("PYTEST_XDIST_WORKER") or os.getenv("_PYTEST_RAISE")
16
19
 
17
20
  if _ENV_CONFIG_DIR:
21
+ # Explicit override via environment variable (highest priority)
22
+ # This is set by conftest.py before imports, ensuring test isolation
18
23
  CONFIG_DIR = Path(_ENV_CONFIG_DIR)
19
24
  elif _TEST_ENV:
20
25
  # Isolate test runs (including xdist workers) from the real user config directory
26
+ # Use a per-process unique temp directory to avoid conflicts in parallel test runs
21
27
  import tempfile
28
+ import uuid
22
29
 
23
- CONFIG_DIR = Path(tempfile.gettempdir()) / "aip-test-config"
30
+ # Create a unique temp dir per test process to avoid conflicts
31
+ temp_base = Path(tempfile.gettempdir())
32
+ test_config_dir = temp_base / f"aip-test-config-{os.getpid()}-{uuid.uuid4().hex[:8]}"
33
+ CONFIG_DIR = test_config_dir
34
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
24
35
  else: # pragma: no cover - default path used outside test runs
25
36
  CONFIG_DIR = Path.home() / ".aip"
26
37
 
glaip_sdk/cli/main.py CHANGED
@@ -49,6 +49,24 @@ from glaip_sdk.config.constants import (
49
49
  from glaip_sdk.icons import ICON_AGENT
50
50
  from glaip_sdk.rich_components import AIPPanel, AIPTable
51
51
 
52
+
53
+ def _suppress_chatty_loggers() -> None:
54
+ """Silence noisy SDK/httpx logs for CLI output."""
55
+ noisy_loggers = [
56
+ "glaip_sdk.client",
57
+ "httpx",
58
+ "httpcore",
59
+ ]
60
+ for name in noisy_loggers:
61
+ logger = logging.getLogger(name)
62
+ # Respect existing configuration: only raise level when unset,
63
+ # and avoid changing propagation if a custom handler is already attached.
64
+ if logger.level == logging.NOTSET:
65
+ logger.setLevel(logging.WARNING)
66
+ if not logger.handlers:
67
+ logger.propagate = False
68
+
69
+
52
70
  # Import SlashSession for potential mocking in tests
53
71
  try:
54
72
  from glaip_sdk.cli.slash import SlashSession
@@ -123,6 +141,8 @@ def main(
123
141
  ctx.obj["view"] = view
124
142
  ctx.obj["account_name"] = account_name
125
143
 
144
+ _suppress_chatty_loggers()
145
+
126
146
  ctx.obj["tty"] = not no_tty
127
147
 
128
148
  launching_slash = (
@@ -0,0 +1,217 @@
1
+ """Accounts controller for the /accounts slash command.
2
+
3
+ Provides a lightweight Textual list with fallback Rich snapshot to switch
4
+ between stored accounts using the shared AccountStore and CLI validation.
5
+
6
+ Authors:
7
+ Raymond Christopher (raymond.christopher@gdplabs.id)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from collections.abc import Iterable
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from rich.console import Console
18
+
19
+ from glaip_sdk.branding import ERROR_STYLE, INFO_STYLE, SUCCESS_STYLE, WARNING_STYLE
20
+ from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
21
+ from glaip_sdk.cli.commands.common_config import check_connection_with_reason
22
+ from glaip_sdk.cli.masking import mask_api_key_display
23
+ from glaip_sdk.cli.slash.accounts_shared import build_account_status_string
24
+ from glaip_sdk.cli.slash.tui.accounts_app import TEXTUAL_SUPPORTED, AccountsTUICallbacks, run_accounts_textual
25
+ from glaip_sdk.rich_components import AIPPanel, AIPTable
26
+
27
+ if TYPE_CHECKING: # pragma: no cover
28
+ from glaip_sdk.cli.slash.session import SlashSession
29
+
30
+ TEXTUAL_AVAILABLE = bool(TEXTUAL_SUPPORTED)
31
+
32
+
33
+ class AccountsController:
34
+ """Controller for listing and switching accounts inside the palette."""
35
+
36
+ def __init__(self, session: SlashSession) -> None:
37
+ """Initialize the accounts controller.
38
+
39
+ Args:
40
+ session: The slash session context.
41
+ """
42
+ self.session = session
43
+ self.console: Console = session.console
44
+ self.ctx = session.ctx
45
+
46
+ def handle_accounts_command(self, args: list[str]) -> bool:
47
+ """Handle `/accounts` with optional `/accounts <name>` quick switch."""
48
+ store = get_account_store()
49
+ env_lock = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
50
+ accounts = store.list_accounts()
51
+
52
+ if not accounts:
53
+ self.console.print(f"[{WARNING_STYLE}]No accounts found. Use `/login` to add credentials.[/]")
54
+ return self.session._continue_session()
55
+
56
+ if args:
57
+ name = args[0]
58
+ self._switch_account(store, name, env_lock)
59
+ return self.session._continue_session()
60
+
61
+ rows = self._build_rows(accounts, store.get_active_account(), env_lock)
62
+
63
+ if self._should_use_textual():
64
+ self._render_textual(rows, store, env_lock)
65
+ else:
66
+ self._render_rich(rows, env_lock)
67
+
68
+ return self.session._continue_session()
69
+
70
+ def _should_use_textual(self) -> bool:
71
+ """Return whether Textual UI should be used."""
72
+ if not TEXTUAL_AVAILABLE:
73
+ return False
74
+
75
+ def _is_tty(stream: Any) -> bool:
76
+ isatty = getattr(stream, "isatty", None)
77
+ if not callable(isatty):
78
+ return False
79
+ try:
80
+ return bool(isatty())
81
+ except Exception:
82
+ return False
83
+
84
+ return _is_tty(sys.stdin) and _is_tty(sys.stdout)
85
+
86
+ def _build_rows(
87
+ self,
88
+ accounts: dict[str, dict[str, str]],
89
+ active_account: str | None,
90
+ env_lock: bool,
91
+ ) -> list[dict[str, str | bool]]:
92
+ """Normalize account rows for display."""
93
+ rows: list[dict[str, str | bool]] = []
94
+ for name, account in sorted(accounts.items()):
95
+ rows.append(
96
+ {
97
+ "name": name,
98
+ "api_url": account.get("api_url", ""),
99
+ "masked_key": mask_api_key_display(account.get("api_key", "")),
100
+ "active": name == active_account,
101
+ "env_lock": env_lock,
102
+ }
103
+ )
104
+ return rows
105
+
106
+ def _render_rich(self, rows: Iterable[dict[str, str | bool]], env_lock: bool) -> None:
107
+ """Render a Rich snapshot with columns matching TUI."""
108
+ if env_lock:
109
+ self.console.print(
110
+ f"[{WARNING_STYLE}]Env credentials detected (AIP_API_URL/AIP_API_KEY); switching is disabled.[/]"
111
+ )
112
+
113
+ table = AIPTable(title="AIP Accounts")
114
+ table.add_column("Name", style=INFO_STYLE, width=20)
115
+ table.add_column("API URL", style=SUCCESS_STYLE, width=40)
116
+ table.add_column("Key (masked)", style="dim", width=20)
117
+ table.add_column("Status", style=SUCCESS_STYLE, width=14)
118
+
119
+ for row in rows:
120
+ status = build_account_status_string(row, use_markup=True)
121
+ # pylint: disable=duplicate-code
122
+ # Similar to accounts_app.py but uses Rich AIPTable API
123
+ table.add_row(
124
+ str(row.get("name", "")),
125
+ str(row.get("api_url", "")),
126
+ str(row.get("masked_key", "")),
127
+ status,
128
+ )
129
+
130
+ self.console.print(table)
131
+
132
+ def _render_textual(self, rows: list[dict[str, str | bool]], store: AccountStore, env_lock: bool) -> None:
133
+ """Launch the Textual accounts browser."""
134
+ callbacks = AccountsTUICallbacks(switch_account=lambda name: self._switch_account(store, name, env_lock))
135
+ active = next((row["name"] for row in rows if row.get("active")), None)
136
+ run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks)
137
+ # Exit snapshot: show active account + host after closing the TUI
138
+ active_after = store.get_active_account() or "default"
139
+ host_after = ""
140
+ account_after = store.get_account(active_after) if hasattr(store, "get_account") else None
141
+ if account_after:
142
+ host_after = account_after.get("api_url", "")
143
+ host_suffix = f" • {host_after}" if host_after else ""
144
+ self.console.print(f"[dim]Active account: {active_after}{host_suffix}[/]")
145
+ # Surface a success banner when a switch occurred inside the TUI
146
+ if active_after != active:
147
+ self.console.print(
148
+ AIPPanel(
149
+ f"[{SUCCESS_STYLE}]Active account ➜ {active_after}[/]{host_suffix}",
150
+ title="✅ Account Switched",
151
+ border_style=SUCCESS_STYLE,
152
+ )
153
+ )
154
+
155
+ def _switch_account(self, store: AccountStore, name: str, env_lock: bool) -> tuple[bool, str]:
156
+ """Validate and switch active account; returns (success, message)."""
157
+ if env_lock:
158
+ msg = "Env credentials detected (AIP_API_URL/AIP_API_KEY); switching is disabled."
159
+ self.console.print(f"[{WARNING_STYLE}]{msg}[/]")
160
+ return False, msg
161
+
162
+ account = store.get_account(name)
163
+ if not account:
164
+ msg = f"Account '{name}' not found."
165
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
166
+ return False, msg
167
+
168
+ api_url = account.get("api_url", "")
169
+ api_key = account.get("api_key", "")
170
+ if not api_url or not api_key:
171
+ edit_cmd = f"aip accounts edit {name}"
172
+ msg = f"Account '{name}' is missing credentials. Use `/login` or `{edit_cmd}`."
173
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
174
+ return False, msg
175
+
176
+ ok, error_reason = check_connection_with_reason(api_url, api_key, abort_on_error=False)
177
+ if not ok:
178
+ code, detail = self._parse_error_reason(error_reason)
179
+ if code == "connection_failed":
180
+ msg = f"Switch aborted: cannot reach {api_url}. Check URL or network."
181
+ elif code == "api_failed":
182
+ msg = f"Switch aborted: API error for '{name}'. Check credentials."
183
+ else:
184
+ detail_suffix = f": {detail}" if detail else ""
185
+ msg = f"Switch aborted: {code or 'Validation failed'}{detail_suffix}"
186
+ self.console.print(f"[{WARNING_STYLE}]{msg}[/]")
187
+ return False, msg
188
+
189
+ try:
190
+ store.set_active_account(name)
191
+ masked_key = mask_api_key_display(api_key)
192
+ self.console.print(
193
+ AIPPanel(
194
+ f"[{SUCCESS_STYLE}]Active account ➜ {name}[/]\nAPI URL: {api_url}\nKey: {masked_key}",
195
+ title="✅ Account Switched",
196
+ border_style=SUCCESS_STYLE,
197
+ )
198
+ )
199
+ return True, f"Switched to '{name}'."
200
+ except AccountStoreError as exc:
201
+ msg = f"Failed to set active account: {exc}"
202
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
203
+ return False, msg
204
+ except Exception as exc: # NOSONAR(S1045) - catch-all needed for unexpected errors
205
+ msg = f"Unexpected error while switching to '{name}': {exc}"
206
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
207
+ return False, msg
208
+
209
+ @staticmethod
210
+ def _parse_error_reason(reason: str | None) -> tuple[str, str]:
211
+ """Parse error reason into (code, detail) to avoid fragile substring checks."""
212
+ if not reason:
213
+ return "", ""
214
+ if ":" in reason:
215
+ code, _, detail = reason.partition(":")
216
+ return code.strip(), detail.strip()
217
+ return reason.strip(), ""
@@ -0,0 +1,19 @@
1
+ """Shared helpers for palette `/accounts`.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ def build_account_status_string(row: dict[str, Any], *, use_markup: bool = False) -> str:
13
+ """Build status string for an account row (active/env-lock)."""
14
+ status_parts: list[str] = []
15
+ if row.get("active"):
16
+ status_parts.append("[bold green]● active[/]" if use_markup else "● active")
17
+ if row.get("env_lock"):
18
+ status_parts.append("[yellow]🔒 env-lock[/]" if use_markup else "🔒 env-lock")
19
+ return " · ".join(status_parts)