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 +112 -57
- glaip_sdk/cli/commands/common_config.py +36 -0
- glaip_sdk/cli/config.py +13 -2
- glaip_sdk/cli/main.py +20 -0
- glaip_sdk/cli/slash/accounts_controller.py +217 -0
- glaip_sdk/cli/slash/accounts_shared.py +19 -0
- glaip_sdk/cli/slash/session.py +57 -7
- glaip_sdk/cli/slash/tui/accounts.tcss +54 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +379 -0
- glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
- glaip_sdk/cli/slash/tui/loading.py +58 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +9 -12
- glaip_sdk/client/agents.py +36 -2
- glaip_sdk/client/main.py +1 -1
- glaip_sdk/registry/__init__.py +1 -1
- glaip_sdk/registry/base.py +1 -1
- glaip_sdk/utils/__init__.py +1 -1
- glaip_sdk/utils/import_resolver.py +0 -8
- glaip_sdk/utils/runtime_config.py +306 -0
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.2.dist-info}/METADATA +1 -1
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.2.dist-info}/RECORD +23 -16
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.2.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.2.dist-info}/entry_points.txt +0 -0
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
|
-
|
|
182
|
-
self.
|
|
183
|
-
self.
|
|
184
|
-
self.
|
|
185
|
-
self.
|
|
186
|
-
self.
|
|
187
|
-
self.
|
|
188
|
-
self.
|
|
189
|
-
self.
|
|
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
|
|
799
|
+
def _prepare_run_kwargs(
|
|
796
800
|
self,
|
|
797
801
|
message: str,
|
|
798
|
-
verbose: bool
|
|
802
|
+
verbose: bool,
|
|
803
|
+
runtime_config: dict[str, Any] | None,
|
|
799
804
|
**kwargs: Any,
|
|
800
|
-
) -> str:
|
|
801
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|