glaip-sdk 0.6.1__py3-none-any.whl → 0.6.3__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 +71 -23
- glaip_sdk/cli/account_store.py +36 -18
- glaip_sdk/cli/commands/accounts.py +2 -2
- glaip_sdk/cli/slash/accounts_controller.py +308 -25
- glaip_sdk/cli/slash/accounts_shared.py +57 -1
- glaip_sdk/cli/slash/session.py +109 -24
- glaip_sdk/cli/slash/tui/accounts.tcss +33 -1
- glaip_sdk/cli/slash/tui/accounts_app.py +525 -32
- glaip_sdk/cli/slash/tui/remote_runs_app.py +3 -3
- glaip_sdk/client/agents.py +36 -2
- glaip_sdk/utils/runtime_config.py +306 -0
- glaip_sdk/utils/validation.py +3 -3
- {glaip_sdk-0.6.1.dist-info → glaip_sdk-0.6.3.dist-info}/METADATA +1 -1
- {glaip_sdk-0.6.1.dist-info → glaip_sdk-0.6.3.dist-info}/RECORD +16 -15
- {glaip_sdk-0.6.1.dist-info → glaip_sdk-0.6.3.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.6.1.dist-info → glaip_sdk-0.6.3.dist-info}/entry_points.txt +0 -0
glaip_sdk/agents/base.py
CHANGED
|
@@ -51,6 +51,7 @@ 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.runtime_config import normalize_runtime_config_keys
|
|
54
55
|
|
|
55
56
|
if TYPE_CHECKING:
|
|
56
57
|
from glaip_sdk.models import AgentResponse
|
|
@@ -795,21 +796,23 @@ class Agent:
|
|
|
795
796
|
self._client = client
|
|
796
797
|
return self
|
|
797
798
|
|
|
798
|
-
def
|
|
799
|
+
def _prepare_run_kwargs(
|
|
799
800
|
self,
|
|
800
801
|
message: str,
|
|
801
|
-
verbose: bool
|
|
802
|
+
verbose: bool,
|
|
803
|
+
runtime_config: dict[str, Any] | None,
|
|
802
804
|
**kwargs: Any,
|
|
803
|
-
) -> str:
|
|
804
|
-
"""
|
|
805
|
+
) -> tuple[Any, dict[str, Any]]:
|
|
806
|
+
"""Prepare common arguments for run/arun methods.
|
|
805
807
|
|
|
806
808
|
Args:
|
|
807
809
|
message: The message to send to the agent.
|
|
808
810
|
verbose: If True, print streaming output to console.
|
|
811
|
+
runtime_config: Optional runtime configuration.
|
|
809
812
|
**kwargs: Additional arguments to pass to the run API.
|
|
810
813
|
|
|
811
814
|
Returns:
|
|
812
|
-
|
|
815
|
+
Tuple of (agent_client, call_kwargs).
|
|
813
816
|
|
|
814
817
|
Raises:
|
|
815
818
|
ValueError: If the agent hasn't been deployed yet.
|
|
@@ -820,24 +823,77 @@ class Agent:
|
|
|
820
823
|
if not self._client:
|
|
821
824
|
raise RuntimeError(_CLIENT_NOT_AVAILABLE_MSG)
|
|
822
825
|
|
|
823
|
-
# _client can be either main Client or AgentClient
|
|
824
826
|
agent_client = getattr(self._client, "agents", self._client)
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
|
830
875
|
)
|
|
876
|
+
return agent_client.run_agent(**call_kwargs)
|
|
831
877
|
|
|
832
878
|
async def arun(
|
|
833
879
|
self,
|
|
834
880
|
message: str,
|
|
881
|
+
verbose: bool = False,
|
|
882
|
+
runtime_config: dict[str, Any] | None = None,
|
|
835
883
|
**kwargs: Any,
|
|
836
884
|
) -> AsyncGenerator[dict, None]:
|
|
837
885
|
"""Run the agent asynchronously with streaming output.
|
|
838
886
|
|
|
839
887
|
Args:
|
|
840
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
|
+
}
|
|
841
897
|
**kwargs: Additional arguments to pass to the run API.
|
|
842
898
|
|
|
843
899
|
Yields:
|
|
@@ -847,18 +903,10 @@ class Agent:
|
|
|
847
903
|
ValueError: If the agent hasn't been deployed yet.
|
|
848
904
|
RuntimeError: If client is not available.
|
|
849
905
|
"""
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
# _client can be either main Client or AgentClient
|
|
856
|
-
agent_client = getattr(self._client, "agents", self._client)
|
|
857
|
-
async for chunk in agent_client.arun_agent(
|
|
858
|
-
agent_id=self.id,
|
|
859
|
-
message=message,
|
|
860
|
-
**kwargs,
|
|
861
|
-
):
|
|
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):
|
|
862
910
|
yield chunk
|
|
863
911
|
|
|
864
912
|
def update(self, **kwargs: Any) -> Agent:
|
glaip_sdk/cli/account_store.py
CHANGED
|
@@ -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
|
|
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
|
|
226
|
-
"api_key": api_key
|
|
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
|
-
#
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
#
|
|
430
|
-
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
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)
|
|
@@ -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 =
|
|
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
|
|
|
@@ -9,20 +9,27 @@ Authors:
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
import os
|
|
13
12
|
import sys
|
|
14
13
|
from collections.abc import Iterable
|
|
14
|
+
from getpass import getpass
|
|
15
15
|
from typing import TYPE_CHECKING, Any
|
|
16
16
|
|
|
17
17
|
from rich.console import Console
|
|
18
|
+
from rich.prompt import Prompt
|
|
18
19
|
|
|
19
20
|
from glaip_sdk.branding import ERROR_STYLE, INFO_STYLE, SUCCESS_STYLE, WARNING_STYLE
|
|
20
21
|
from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
|
|
21
22
|
from glaip_sdk.cli.commands.common_config import check_connection_with_reason
|
|
22
23
|
from glaip_sdk.cli.masking import mask_api_key_display
|
|
23
|
-
from glaip_sdk.cli.
|
|
24
|
+
from glaip_sdk.cli.validators import validate_api_key
|
|
25
|
+
from glaip_sdk.cli.slash.accounts_shared import (
|
|
26
|
+
build_account_rows,
|
|
27
|
+
build_account_status_string,
|
|
28
|
+
env_credentials_present,
|
|
29
|
+
)
|
|
24
30
|
from glaip_sdk.cli.slash.tui.accounts_app import TEXTUAL_SUPPORTED, AccountsTUICallbacks, run_accounts_textual
|
|
25
31
|
from glaip_sdk.rich_components import AIPPanel, AIPTable
|
|
32
|
+
from glaip_sdk.utils.validation import validate_url
|
|
26
33
|
|
|
27
34
|
if TYPE_CHECKING: # pragma: no cover
|
|
28
35
|
from glaip_sdk.cli.slash.session import SlashSession
|
|
@@ -46,7 +53,7 @@ class AccountsController:
|
|
|
46
53
|
def handle_accounts_command(self, args: list[str]) -> bool:
|
|
47
54
|
"""Handle `/accounts` with optional `/accounts <name>` quick switch."""
|
|
48
55
|
store = get_account_store()
|
|
49
|
-
env_lock =
|
|
56
|
+
env_lock = env_credentials_present(partial=True)
|
|
50
57
|
accounts = store.list_accounts()
|
|
51
58
|
|
|
52
59
|
if not accounts:
|
|
@@ -63,7 +70,7 @@ class AccountsController:
|
|
|
63
70
|
if self._should_use_textual():
|
|
64
71
|
self._render_textual(rows, store, env_lock)
|
|
65
72
|
else:
|
|
66
|
-
self.
|
|
73
|
+
self._render_rich_interactive(store, env_lock)
|
|
67
74
|
|
|
68
75
|
return self.session._continue_session()
|
|
69
76
|
|
|
@@ -90,24 +97,13 @@ class AccountsController:
|
|
|
90
97
|
env_lock: bool,
|
|
91
98
|
) -> list[dict[str, str | bool]]:
|
|
92
99
|
"""Normalize account rows for display."""
|
|
93
|
-
|
|
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
|
|
100
|
+
return build_account_rows(accounts, active_account, env_lock)
|
|
105
101
|
|
|
106
102
|
def _render_rich(self, rows: Iterable[dict[str, str | bool]], env_lock: bool) -> None:
|
|
107
103
|
"""Render a Rich snapshot with columns matching TUI."""
|
|
108
104
|
if env_lock:
|
|
109
105
|
self.console.print(
|
|
110
|
-
f"[{WARNING_STYLE}]Env credentials detected (AIP_API_URL/AIP_API_KEY);
|
|
106
|
+
f"[{WARNING_STYLE}]Env credentials detected (AIP_API_URL/AIP_API_KEY); add/edit/delete are disabled.[/]"
|
|
111
107
|
)
|
|
112
108
|
|
|
113
109
|
table = AIPTable(title="AIP Accounts")
|
|
@@ -129,21 +125,43 @@ class AccountsController:
|
|
|
129
125
|
|
|
130
126
|
self.console.print(table)
|
|
131
127
|
|
|
128
|
+
def _render_rich_interactive(self, store: AccountStore, env_lock: bool) -> None:
|
|
129
|
+
"""Render Rich snapshot and run linear add/edit/delete prompts."""
|
|
130
|
+
if env_lock:
|
|
131
|
+
rows = self._build_rows(store.list_accounts(), store.get_active_account(), env_lock)
|
|
132
|
+
self._render_rich(rows, env_lock)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
while True: # pragma: no cover - interactive prompt loop
|
|
136
|
+
rows = self._build_rows(store.list_accounts(), store.get_active_account(), env_lock)
|
|
137
|
+
self._render_rich(rows, env_lock)
|
|
138
|
+
action = self._prompt_action()
|
|
139
|
+
if action == "q":
|
|
140
|
+
break
|
|
141
|
+
if action == "a":
|
|
142
|
+
self._rich_add_flow(store)
|
|
143
|
+
elif action == "e":
|
|
144
|
+
self._rich_edit_flow(store)
|
|
145
|
+
elif action == "d":
|
|
146
|
+
self._rich_delete_flow(store)
|
|
147
|
+
elif action == "s":
|
|
148
|
+
self._rich_switch_flow(store, env_lock)
|
|
149
|
+
else:
|
|
150
|
+
self.console.print(f"[{WARNING_STYLE}]Invalid choice. Use a/e/d/s/q.[/]")
|
|
151
|
+
|
|
132
152
|
def _render_textual(self, rows: list[dict[str, str | bool]], store: AccountStore, env_lock: bool) -> None:
|
|
133
153
|
"""Launch the Textual accounts browser."""
|
|
134
154
|
callbacks = AccountsTUICallbacks(switch_account=lambda name: self._switch_account(store, name, env_lock))
|
|
135
155
|
active = next((row["name"] for row in rows if row.get("active")), None)
|
|
136
156
|
run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks)
|
|
137
|
-
# Exit snapshot:
|
|
157
|
+
# Exit snapshot: surface a success banner when a switch occurred inside the TUI
|
|
138
158
|
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
159
|
if active_after != active:
|
|
160
|
+
host_after = ""
|
|
161
|
+
account_after = store.get_account(active_after) if hasattr(store, "get_account") else None
|
|
162
|
+
if account_after:
|
|
163
|
+
host_after = account_after.get("api_url", "")
|
|
164
|
+
host_suffix = f" • {host_after}" if host_after else ""
|
|
147
165
|
self.console.print(
|
|
148
166
|
AIPPanel(
|
|
149
167
|
f"[{SUCCESS_STYLE}]Active account ➜ {active_after}[/]{host_suffix}",
|
|
@@ -215,3 +233,268 @@ class AccountsController:
|
|
|
215
233
|
code, _, detail = reason.partition(":")
|
|
216
234
|
return code.strip(), detail.strip()
|
|
217
235
|
return reason.strip(), ""
|
|
236
|
+
|
|
237
|
+
def _prompt_action(self) -> str:
|
|
238
|
+
"""Prompt for add/edit/delete/quit action."""
|
|
239
|
+
try:
|
|
240
|
+
choice = Prompt.ask("(a)dd / (e)dit / (d)elete / (s)witch / (q)uit", default="q")
|
|
241
|
+
except Exception: # pragma: no cover - defensive around prompt failures
|
|
242
|
+
return "q"
|
|
243
|
+
return (choice or "").strip().lower()[:1]
|
|
244
|
+
|
|
245
|
+
def _prompt_yes_no(self, prompt: str, *, default: bool = True) -> bool:
|
|
246
|
+
"""Prompt a yes/no question with a default."""
|
|
247
|
+
default_str = "Y/n" if default else "y/N"
|
|
248
|
+
try:
|
|
249
|
+
answer = Prompt.ask(f"{prompt} ({default_str})", default="y" if default else "n")
|
|
250
|
+
except Exception: # pragma: no cover - defensive around prompt failures
|
|
251
|
+
return default
|
|
252
|
+
normalized = (answer or "").strip().lower()
|
|
253
|
+
if not normalized:
|
|
254
|
+
return default
|
|
255
|
+
return normalized in {"y", "yes"}
|
|
256
|
+
|
|
257
|
+
def _prompt_account_name(self, store: AccountStore, *, for_edit: bool) -> str | None:
|
|
258
|
+
"""Prompt for an account name, validating per store rules."""
|
|
259
|
+
while True: # pragma: no cover - interactive prompt loop
|
|
260
|
+
name = self._get_name_input(for_edit)
|
|
261
|
+
if name is None:
|
|
262
|
+
return None
|
|
263
|
+
if not name:
|
|
264
|
+
self.console.print(f"[{WARNING_STYLE}]Name is required.[/]")
|
|
265
|
+
continue
|
|
266
|
+
if not self._validate_name_format(store, name):
|
|
267
|
+
continue
|
|
268
|
+
if not self._validate_name_existence(store, name, for_edit):
|
|
269
|
+
continue
|
|
270
|
+
return name
|
|
271
|
+
|
|
272
|
+
def _get_name_input(self, for_edit: bool) -> str | None:
|
|
273
|
+
"""Get account name input from user."""
|
|
274
|
+
try:
|
|
275
|
+
prompt_text = "Account name" + (" (existing)" if for_edit else "")
|
|
276
|
+
name = Prompt.ask(prompt_text)
|
|
277
|
+
return name.strip() if name else None
|
|
278
|
+
except Exception:
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
def _validate_name_format(self, store: AccountStore, name: str) -> bool:
|
|
282
|
+
"""Validate account name format."""
|
|
283
|
+
try:
|
|
284
|
+
store.validate_account_name(name)
|
|
285
|
+
return True
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
def _validate_name_existence(self, store: AccountStore, name: str, for_edit: bool) -> bool:
|
|
291
|
+
"""Validate account name existence based on mode."""
|
|
292
|
+
account_exists = store.get_account(name) is not None
|
|
293
|
+
if not for_edit and account_exists:
|
|
294
|
+
self.console.print(
|
|
295
|
+
f"[{WARNING_STYLE}]Account '{name}' already exists. Use edit instead or choose a new name.[/]"
|
|
296
|
+
)
|
|
297
|
+
return False
|
|
298
|
+
if for_edit and not account_exists:
|
|
299
|
+
self.console.print(f"[{WARNING_STYLE}]Account '{name}' not found. Try again or quit.[/]")
|
|
300
|
+
return False
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
def _prompt_api_url(self, existing_url: str | None = None) -> str | None:
|
|
304
|
+
"""Prompt for API URL with HTTPS validation."""
|
|
305
|
+
placeholder = existing_url or "https://your-aip-instance.com"
|
|
306
|
+
while True: # pragma: no cover - interactive prompt loop
|
|
307
|
+
try:
|
|
308
|
+
entered = Prompt.ask("API URL", default=placeholder)
|
|
309
|
+
except Exception:
|
|
310
|
+
return None
|
|
311
|
+
url = (entered or "").strip()
|
|
312
|
+
if not url and existing_url:
|
|
313
|
+
return existing_url
|
|
314
|
+
if not url:
|
|
315
|
+
self.console.print(f"[{WARNING_STYLE}]API URL is required.[/]")
|
|
316
|
+
continue
|
|
317
|
+
try:
|
|
318
|
+
return validate_url(url)
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
|
|
321
|
+
|
|
322
|
+
def _prompt_api_key(self, existing_key: str | None = None) -> str | None:
|
|
323
|
+
"""Prompt for API key (masked)."""
|
|
324
|
+
mask_hint = "leave blank to keep current" if existing_key else None
|
|
325
|
+
while True: # pragma: no cover - interactive prompt loop
|
|
326
|
+
try:
|
|
327
|
+
entered = getpass(f"API key ({mask_hint or 'input hidden'}): ")
|
|
328
|
+
except Exception:
|
|
329
|
+
return None
|
|
330
|
+
if not entered and existing_key:
|
|
331
|
+
return existing_key
|
|
332
|
+
if not entered:
|
|
333
|
+
self.console.print(f"[{WARNING_STYLE}]API key is required.[/]")
|
|
334
|
+
continue
|
|
335
|
+
try:
|
|
336
|
+
return validate_api_key(entered)
|
|
337
|
+
except Exception as exc:
|
|
338
|
+
self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
|
|
339
|
+
|
|
340
|
+
def _rich_add_flow(self, store: AccountStore) -> None:
|
|
341
|
+
"""Run Rich add prompts and save."""
|
|
342
|
+
name = self._prompt_account_name(store, for_edit=False)
|
|
343
|
+
if not name:
|
|
344
|
+
return
|
|
345
|
+
api_url = self._prompt_api_url()
|
|
346
|
+
if not api_url:
|
|
347
|
+
return
|
|
348
|
+
api_key = self._prompt_api_key()
|
|
349
|
+
if not api_key:
|
|
350
|
+
return
|
|
351
|
+
should_test = self._prompt_yes_no("Test connection before save?", default=True)
|
|
352
|
+
self._save_account(store, name, api_url, api_key, should_test, True, is_edit=False)
|
|
353
|
+
|
|
354
|
+
def _rich_edit_flow(self, store: AccountStore) -> None:
|
|
355
|
+
"""Run Rich edit prompts and save."""
|
|
356
|
+
name = self._prompt_account_name(store, for_edit=True)
|
|
357
|
+
if not name:
|
|
358
|
+
return
|
|
359
|
+
existing = store.get_account(name) or {}
|
|
360
|
+
api_url = self._prompt_api_url(existing.get("api_url"))
|
|
361
|
+
if not api_url:
|
|
362
|
+
return
|
|
363
|
+
api_key = self._prompt_api_key(existing.get("api_key"))
|
|
364
|
+
if not api_key:
|
|
365
|
+
return
|
|
366
|
+
should_test = self._prompt_yes_no("Test connection before save?", default=True)
|
|
367
|
+
self._save_account(store, name, api_url, api_key, should_test, False, is_edit=True)
|
|
368
|
+
|
|
369
|
+
def _rich_switch_flow(self, store: AccountStore, env_lock: bool) -> None:
|
|
370
|
+
"""Run Rich switch prompt and set active account."""
|
|
371
|
+
name = self._prompt_account_name(store, for_edit=True)
|
|
372
|
+
if not name:
|
|
373
|
+
return
|
|
374
|
+
self._switch_account(store, name, env_lock)
|
|
375
|
+
|
|
376
|
+
def _save_account(
|
|
377
|
+
self,
|
|
378
|
+
store: AccountStore,
|
|
379
|
+
name: str,
|
|
380
|
+
api_url: str,
|
|
381
|
+
api_key: str,
|
|
382
|
+
should_test: bool,
|
|
383
|
+
set_active: bool,
|
|
384
|
+
*,
|
|
385
|
+
is_edit: bool,
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Validate, optionally test, and persist account changes."""
|
|
388
|
+
if should_test and not self._run_connection_test_with_retry(api_url, api_key):
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
store.add_account(name, api_url, api_key, overwrite=is_edit)
|
|
393
|
+
except AccountStoreError as exc:
|
|
394
|
+
self.console.print(f"[{ERROR_STYLE}]Save failed: {exc}[/]")
|
|
395
|
+
return
|
|
396
|
+
except Exception as exc:
|
|
397
|
+
self.console.print(f"[{ERROR_STYLE}]Unexpected error while saving: {exc}[/]")
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
self.console.print(f"[{SUCCESS_STYLE}]Account '{name}' saved.[/]")
|
|
401
|
+
if set_active:
|
|
402
|
+
try:
|
|
403
|
+
store.set_active_account(name)
|
|
404
|
+
except Exception as exc:
|
|
405
|
+
self.console.print(f"[{WARNING_STYLE}]Account saved but could not set active: {exc}[/]")
|
|
406
|
+
else:
|
|
407
|
+
self._announce_active_change(store, name)
|
|
408
|
+
|
|
409
|
+
def _confirm_delete_prompt(self, name: str) -> bool:
|
|
410
|
+
"""Ask for delete confirmation; return True when confirmed."""
|
|
411
|
+
self.console.print(f"[{WARNING_STYLE}]Type '{name}' to confirm deletion. This cannot be undone.[/]")
|
|
412
|
+
while True: # pragma: no cover - interactive prompt loop
|
|
413
|
+
confirmation = Prompt.ask("Confirm name (or blank to cancel)", default="")
|
|
414
|
+
if confirmation is None or not confirmation.strip():
|
|
415
|
+
self.console.print(f"[{WARNING_STYLE}]Deletion cancelled.[/]")
|
|
416
|
+
return False
|
|
417
|
+
if confirmation.strip() != name:
|
|
418
|
+
self.console.print(f"[{WARNING_STYLE}]Name does not match; type '{name}' to confirm.[/]")
|
|
419
|
+
continue
|
|
420
|
+
return True
|
|
421
|
+
|
|
422
|
+
def _delete_account_and_notify(self, store: AccountStore, name: str, active_before: str | None) -> None:
|
|
423
|
+
"""Remove account with error handling and announce active change."""
|
|
424
|
+
try:
|
|
425
|
+
store.remove_account(name)
|
|
426
|
+
except AccountStoreError as exc:
|
|
427
|
+
self.console.print(f"[{ERROR_STYLE}]Delete failed: {exc}[/]")
|
|
428
|
+
return
|
|
429
|
+
except Exception as exc:
|
|
430
|
+
self.console.print(f"[{ERROR_STYLE}]Unexpected error while deleting: {exc}[/]")
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
self.console.print(f"[{SUCCESS_STYLE}]Account '{name}' deleted.[/]")
|
|
434
|
+
# Announce active account change if it changed
|
|
435
|
+
active_after = store.get_active_account()
|
|
436
|
+
if active_after is not None and active_after != active_before:
|
|
437
|
+
self._announce_active_change(store, active_after)
|
|
438
|
+
elif active_after is None and active_before == name:
|
|
439
|
+
self.console.print(f"[{WARNING_STYLE}]No account is currently active. Select an account to activate it.[/]")
|
|
440
|
+
|
|
441
|
+
def _rich_delete_flow(self, store: AccountStore) -> None:
|
|
442
|
+
"""Run Rich delete prompts with name confirmation."""
|
|
443
|
+
name = self._prompt_account_name(store, for_edit=True)
|
|
444
|
+
if not name:
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
# Check if this is the last remaining account before prompting for confirmation
|
|
448
|
+
accounts = store.list_accounts()
|
|
449
|
+
if len(accounts) <= 1 and name in accounts:
|
|
450
|
+
self.console.print(f"[{WARNING_STYLE}]Cannot remove the last remaining account.[/]")
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
if not self._confirm_delete_prompt(name):
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
# Re-check after confirmation prompt (race condition guard)
|
|
457
|
+
accounts = store.list_accounts()
|
|
458
|
+
if len(accounts) <= 1 and name in accounts:
|
|
459
|
+
self.console.print(f"[{WARNING_STYLE}]Cannot remove the last remaining account.[/]")
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
active_before = store.get_active_account()
|
|
463
|
+
self._delete_account_and_notify(store, name, active_before)
|
|
464
|
+
|
|
465
|
+
def _format_connection_failure(self, code: str, detail: str, api_url: str) -> str:
|
|
466
|
+
"""Build a user-facing connection failure message."""
|
|
467
|
+
detail_suffix = f": {detail}" if detail else ""
|
|
468
|
+
if code == "connection_failed":
|
|
469
|
+
return f"Connection test failed: cannot reach {api_url}{detail_suffix}"
|
|
470
|
+
if code == "api_failed":
|
|
471
|
+
return f"Connection test failed: API error{detail_suffix}"
|
|
472
|
+
return f"Connection test failed{detail_suffix}"
|
|
473
|
+
|
|
474
|
+
def _run_connection_test_with_retry(self, api_url: str, api_key: str) -> bool:
|
|
475
|
+
"""Run connection test with retry/skip prompts."""
|
|
476
|
+
skip_prompt_shown = False
|
|
477
|
+
while True:
|
|
478
|
+
ok, reason = check_connection_with_reason(api_url, api_key, abort_on_error=False)
|
|
479
|
+
if ok:
|
|
480
|
+
return True
|
|
481
|
+
code, detail = self._parse_error_reason(reason)
|
|
482
|
+
message = self._format_connection_failure(code, detail, api_url)
|
|
483
|
+
self.console.print(f"[{WARNING_STYLE}]{message}[/]")
|
|
484
|
+
retry = self._prompt_yes_no("Retry connection test?", default=True)
|
|
485
|
+
if retry:
|
|
486
|
+
continue
|
|
487
|
+
if not skip_prompt_shown:
|
|
488
|
+
skip_prompt_shown = True
|
|
489
|
+
skip = self._prompt_yes_no("Skip connection test and save?", default=False)
|
|
490
|
+
if skip:
|
|
491
|
+
return True
|
|
492
|
+
self.console.print(f"[{WARNING_STYLE}]Cancelled save after failed connection test.[/]")
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
def _announce_active_change(self, store: AccountStore, name: str) -> None:
|
|
496
|
+
"""Print active account change announcement."""
|
|
497
|
+
account = store.get_account(name) or {}
|
|
498
|
+
host = account.get("api_url", "")
|
|
499
|
+
host_suffix = f" • {host}" if host else ""
|
|
500
|
+
self.console.print(f"[{SUCCESS_STYLE}]Active account ➜ {name}{host_suffix}[/]")
|