glaip-sdk 0.6.0__py3-none-any.whl → 0.6.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.
- glaip_sdk/agents/base.py +41 -34
- 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/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-0.6.0.dist-info → glaip_sdk-0.6.1.dist-info}/METADATA +1 -1
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.1.dist-info}/RECORD +21 -15
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.1.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.1.dist-info}/entry_points.txt +0 -0
glaip_sdk/agents/base.py
CHANGED
|
@@ -44,6 +44,7 @@ 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
|
|
@@ -127,16 +128,8 @@ class Agent:
|
|
|
127
128
|
agents: list | None = None,
|
|
128
129
|
mcps: list | None = None,
|
|
129
130
|
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
131
|
_client: Any = None,
|
|
132
|
+
**kwargs: Any,
|
|
140
133
|
) -> None:
|
|
141
134
|
"""Initialize an Agent.
|
|
142
135
|
|
|
@@ -152,16 +145,17 @@ class Agent:
|
|
|
152
145
|
agents: List of sub-agents (Agent classes, instances, or strings).
|
|
153
146
|
mcps: List of MCPs.
|
|
154
147
|
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
148
|
_client: Internal client reference (set automatically).
|
|
149
|
+
**kwargs: Additional configuration parameters:
|
|
150
|
+
- timeout: Execution timeout in seconds.
|
|
151
|
+
- metadata: Optional metadata dictionary.
|
|
152
|
+
- framework: Agent framework identifier.
|
|
153
|
+
- version: Agent version string.
|
|
154
|
+
- agent_type: Agent type identifier.
|
|
155
|
+
- agent_config: Agent execution configuration.
|
|
156
|
+
- tool_configs: Per-tool configuration overrides.
|
|
157
|
+
- mcp_configs: Per-MCP configuration overrides.
|
|
158
|
+
- a2a_profile: A2A profile configuration.
|
|
165
159
|
"""
|
|
166
160
|
# Instance attributes for deployed agents
|
|
167
161
|
self._id = id
|
|
@@ -178,15 +172,24 @@ class Agent:
|
|
|
178
172
|
self._mcps = mcps
|
|
179
173
|
self._model = model
|
|
180
174
|
self._language_model_id: str | None = None
|
|
181
|
-
|
|
182
|
-
self.
|
|
183
|
-
self.
|
|
184
|
-
self.
|
|
185
|
-
self.
|
|
186
|
-
self.
|
|
187
|
-
self.
|
|
188
|
-
self.
|
|
189
|
-
self.
|
|
175
|
+
# Extract parameters from kwargs with _UNSET defaults
|
|
176
|
+
self._timeout = kwargs.pop("timeout", Agent._UNSET) # type: ignore[assignment]
|
|
177
|
+
self._metadata = kwargs.pop("metadata", Agent._UNSET) # type: ignore[assignment]
|
|
178
|
+
self._framework = kwargs.pop("framework", Agent._UNSET) # type: ignore[assignment]
|
|
179
|
+
self._version = kwargs.pop("version", Agent._UNSET) # type: ignore[assignment]
|
|
180
|
+
self._agent_type = kwargs.pop("agent_type", Agent._UNSET) # type: ignore[assignment]
|
|
181
|
+
self._agent_config = kwargs.pop("agent_config", Agent._UNSET) # type: ignore[assignment]
|
|
182
|
+
self._tool_configs = kwargs.pop("tool_configs", Agent._UNSET) # type: ignore[assignment]
|
|
183
|
+
self._mcp_configs = kwargs.pop("mcp_configs", Agent._UNSET) # type: ignore[assignment]
|
|
184
|
+
self._a2a_profile = kwargs.pop("a2a_profile", Agent._UNSET) # type: ignore[assignment]
|
|
185
|
+
|
|
186
|
+
# Warn about unexpected kwargs
|
|
187
|
+
if kwargs:
|
|
188
|
+
warnings.warn(
|
|
189
|
+
f"Unexpected keyword arguments: {list(kwargs.keys())}. These will be ignored.",
|
|
190
|
+
UserWarning,
|
|
191
|
+
stacklevel=2,
|
|
192
|
+
)
|
|
190
193
|
|
|
191
194
|
# ─────────────────────────────────────────────────────────────────
|
|
192
195
|
# Properties (override in subclasses OR pass to __init__)
|
|
@@ -880,13 +883,17 @@ class Agent:
|
|
|
880
883
|
agent_client = getattr(self._client, "agents", self._client)
|
|
881
884
|
response = agent_client.update_agent(agent_id=self.id, **kwargs)
|
|
882
885
|
|
|
883
|
-
# Update local properties from response
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
886
|
+
# Update local properties from response (read-only props via private attrs)
|
|
887
|
+
name = getattr(response, "name", None)
|
|
888
|
+
if name:
|
|
889
|
+
self._name = name
|
|
890
|
+
|
|
891
|
+
instruction = getattr(response, "instruction", None)
|
|
892
|
+
if instruction:
|
|
893
|
+
self._instruction = instruction
|
|
894
|
+
|
|
895
|
+
# Populate remaining fields like description, metadata, updated_at, etc.
|
|
896
|
+
type(self)._populate_from_response(self, response)
|
|
890
897
|
|
|
891
898
|
return self
|
|
892
899
|
|
|
@@ -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)
|
glaip_sdk/cli/slash/session.py
CHANGED
|
@@ -34,10 +34,12 @@ from glaip_sdk.branding import (
|
|
|
34
34
|
AIPBranding,
|
|
35
35
|
)
|
|
36
36
|
from glaip_sdk.cli.auth import resolve_api_url_from_context
|
|
37
|
+
from glaip_sdk.cli.account_store import get_account_store
|
|
37
38
|
from glaip_sdk.cli.commands import transcripts as transcripts_cmd
|
|
38
|
-
from glaip_sdk.cli.commands.configure import
|
|
39
|
+
from glaip_sdk.cli.commands.configure import _configure_interactive, load_config
|
|
39
40
|
from glaip_sdk.cli.commands.update import update_command
|
|
40
41
|
from glaip_sdk.cli.hints import format_command_hint
|
|
42
|
+
from glaip_sdk.cli.slash.accounts_controller import AccountsController
|
|
41
43
|
from glaip_sdk.cli.slash.agent_session import AgentRunSession
|
|
42
44
|
from glaip_sdk.cli.slash.prompt import (
|
|
43
45
|
FormattedText,
|
|
@@ -98,6 +100,12 @@ NEW_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
|
|
|
98
100
|
|
|
99
101
|
|
|
100
102
|
DEFAULT_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
|
|
103
|
+
{
|
|
104
|
+
"cli": None,
|
|
105
|
+
"slash": "accounts",
|
|
106
|
+
"description": "Switch account profile",
|
|
107
|
+
"priority": 5,
|
|
108
|
+
},
|
|
101
109
|
{
|
|
102
110
|
"cli": "status",
|
|
103
111
|
"slash": "status",
|
|
@@ -452,7 +460,8 @@ class SlashSession:
|
|
|
452
460
|
"""
|
|
453
461
|
self.console.print(f"[{ACCENT_STYLE}]Launching configuration wizard...[/]")
|
|
454
462
|
try:
|
|
455
|
-
|
|
463
|
+
# Use the modern account-aware wizard directly (bypasses legacy config gating)
|
|
464
|
+
_configure_interactive(account_name=None)
|
|
456
465
|
self._config_cache = None
|
|
457
466
|
if self._suppress_login_layout:
|
|
458
467
|
self._welcome_rendered = False
|
|
@@ -655,6 +664,11 @@ class SlashSession:
|
|
|
655
664
|
controller = RemoteRunsController(self)
|
|
656
665
|
return controller.handle_runs_command(args)
|
|
657
666
|
|
|
667
|
+
def _cmd_accounts(self, args: list[str], _invoked_from_agent: bool) -> bool:
|
|
668
|
+
"""Handle the /accounts command for listing and switching accounts."""
|
|
669
|
+
controller = AccountsController(self)
|
|
670
|
+
return controller.handle_accounts_command(args)
|
|
671
|
+
|
|
658
672
|
def _cmd_agents(self, args: list[str], _invoked_from_agent: bool) -> bool:
|
|
659
673
|
"""Handle the /agents command.
|
|
660
674
|
|
|
@@ -742,6 +756,7 @@ class SlashSession:
|
|
|
742
756
|
hints.append((f"/agents {agent_id}", f"Reopen {agent_label}"))
|
|
743
757
|
hints.extend(
|
|
744
758
|
[
|
|
759
|
+
("/accounts", "Switch account"),
|
|
745
760
|
(self.AGENTS_COMMAND, "Browse agents"),
|
|
746
761
|
(self.STATUS_COMMAND, "Check connection"),
|
|
747
762
|
]
|
|
@@ -796,6 +811,13 @@ class SlashSession:
|
|
|
796
811
|
handler=SlashSession._cmd_status,
|
|
797
812
|
)
|
|
798
813
|
)
|
|
814
|
+
self._register(
|
|
815
|
+
SlashCommand(
|
|
816
|
+
name="accounts",
|
|
817
|
+
help="✨ NEW · Browse and switch stored accounts (Textual with Rich fallback).",
|
|
818
|
+
handler=SlashSession._cmd_accounts,
|
|
819
|
+
)
|
|
820
|
+
)
|
|
799
821
|
self._register(
|
|
800
822
|
SlashCommand(
|
|
801
823
|
name="transcripts",
|
|
@@ -1262,13 +1284,26 @@ class SlashSession:
|
|
|
1262
1284
|
"""Render the main AIP environment header."""
|
|
1263
1285
|
config = self._load_config()
|
|
1264
1286
|
|
|
1287
|
+
account_name, account_host, env_lock = self._get_account_context()
|
|
1265
1288
|
api_url = self._get_api_url(config)
|
|
1266
|
-
status = "Configured" if config.get("api_key") else "Not configured"
|
|
1267
1289
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1290
|
+
host_display = account_host or "Not configured"
|
|
1291
|
+
account_segment = f"[dim]Account[/dim] • {account_name} ({host_display})"
|
|
1292
|
+
if env_lock:
|
|
1293
|
+
account_segment += " 🔒"
|
|
1294
|
+
|
|
1295
|
+
segments = [account_segment]
|
|
1296
|
+
|
|
1297
|
+
if api_url:
|
|
1298
|
+
base_label = "[dim]Base URL[/dim]"
|
|
1299
|
+
if env_lock:
|
|
1300
|
+
base_label = "[dim]Base URL (env)[/dim]"
|
|
1301
|
+
# Always show Base URL when env-lock is active to reveal overrides
|
|
1302
|
+
if env_lock or api_url != account_host:
|
|
1303
|
+
segments.append(f"{base_label} • {api_url}")
|
|
1304
|
+
elif not api_url:
|
|
1305
|
+
segments.append("[dim]Base URL[/dim] • Not configured")
|
|
1306
|
+
|
|
1272
1307
|
agent_info = self._build_agent_status_line(active_agent)
|
|
1273
1308
|
if agent_info:
|
|
1274
1309
|
segments.append(agent_info)
|
|
@@ -1295,6 +1330,21 @@ class SlashSession:
|
|
|
1295
1330
|
"""Get the API URL from context or account store (CLI/palette ignores env credentials)."""
|
|
1296
1331
|
return resolve_api_url_from_context(self.ctx)
|
|
1297
1332
|
|
|
1333
|
+
def _get_account_context(self) -> tuple[str, str, bool]:
|
|
1334
|
+
"""Return active account name, host, and env-lock flag."""
|
|
1335
|
+
try:
|
|
1336
|
+
store = get_account_store()
|
|
1337
|
+
active = store.get_active_account() or "default"
|
|
1338
|
+
account = store.get_account(active) if hasattr(store, "get_account") else None
|
|
1339
|
+
host = ""
|
|
1340
|
+
if account:
|
|
1341
|
+
host = account.get("api_url", "")
|
|
1342
|
+
env_lock = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
|
|
1343
|
+
return active, host, env_lock
|
|
1344
|
+
except Exception:
|
|
1345
|
+
env_lock = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
|
|
1346
|
+
return "default", "", env_lock
|
|
1347
|
+
|
|
1298
1348
|
def _build_agent_status_line(self, active_agent: Any | None) -> str | None:
|
|
1299
1349
|
"""Return a short status line about the active or recent agent."""
|
|
1300
1350
|
if active_agent is not None:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* Styling for /accounts Textual UI
|
|
2
|
+
*
|
|
3
|
+
* Keep layout compact: filter sits tight above the table; header shows active account.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
#header-info {
|
|
7
|
+
padding: 0 1 0 1;
|
|
8
|
+
margin: 0;
|
|
9
|
+
height: 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
#env-lock {
|
|
13
|
+
padding: 0 1 0 1;
|
|
14
|
+
color: yellow;
|
|
15
|
+
height: 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#filter-container {
|
|
19
|
+
padding: 0 1 0 1;
|
|
20
|
+
margin: 0 0 0 0;
|
|
21
|
+
height: auto;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#filter-label {
|
|
25
|
+
height: 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#filter-input {
|
|
29
|
+
padding: 0 1 0 1;
|
|
30
|
+
margin: 0;
|
|
31
|
+
height: 3;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#accounts-table {
|
|
35
|
+
padding: 0 1 0 1;
|
|
36
|
+
margin: 0 0 0 0;
|
|
37
|
+
height: 1fr;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#status-bar {
|
|
41
|
+
height: 3;
|
|
42
|
+
padding: 0 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#accounts-loading {
|
|
46
|
+
width: 8;
|
|
47
|
+
display: none;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#status {
|
|
51
|
+
padding: 0 1 0 1;
|
|
52
|
+
margin: 0;
|
|
53
|
+
color: cyan;
|
|
54
|
+
}
|