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 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 run(
799
+ def _prepare_run_kwargs(
799
800
  self,
800
801
  message: str,
801
- verbose: bool = False,
802
+ verbose: bool,
803
+ runtime_config: dict[str, Any] | None,
802
804
  **kwargs: Any,
803
- ) -> str:
804
- """Run the agent synchronously with a message.
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
- The agent's response as a string.
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
- return agent_client.run_agent(
826
- agent_id=self.id,
827
- message=message,
828
- verbose=verbose,
829
- **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
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
- if not self.id:
851
- raise ValueError(_AGENT_NOT_DEPLOYED_MSG)
852
- if not self._client:
853
- raise RuntimeError(_CLIENT_NOT_AVAILABLE_MSG)
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:
@@ -217,13 +217,14 @@ class AccountStore:
217
217
  api_key: API key or None.
218
218
 
219
219
  Returns:
220
- Dictionary with "default" account if credentials exist, empty dict otherwise.
220
+ Dictionary with "default" account if both credentials exist and are non-empty, empty dict otherwise.
221
221
  """
222
222
  accounts = {}
223
- if api_url or api_key:
223
+ # Only create default account if both URL and key are present and non-empty
224
+ if api_url and api_key and api_url.strip() and api_key.strip():
224
225
  accounts["default"] = {
225
- "api_url": api_url or "",
226
- "api_key": api_key or "",
226
+ "api_url": api_url.strip(),
227
+ "api_key": api_key.strip(),
227
228
  }
228
229
  return accounts
229
230
 
@@ -257,15 +258,30 @@ class AccountStore:
257
258
  "accounts": {},
258
259
  }
259
260
 
260
- # Extract legacy api_url and api_key
261
- api_url = config.get("api_url")
262
- api_key = config.get("api_key")
261
+ # Preserve existing accounts if they exist (shouldn't happen in true migration, but defensive)
262
+ existing_accounts = config.get("accounts", {})
263
+ if existing_accounts:
264
+ migrated["accounts"] = existing_accounts.copy()
265
+ existing_active = config.get("active_account")
266
+ if existing_active and existing_active in existing_accounts:
267
+ migrated["active_account"] = existing_active
268
+ elif "default" in existing_accounts:
269
+ migrated["active_account"] = "default"
270
+ else:
271
+ migrated["active_account"] = sorted(existing_accounts.keys())[0]
272
+ else:
273
+ # Extract legacy api_url and api_key only if no accounts exist
274
+ api_url = config.get("api_url")
275
+ api_key = config.get("api_key")
263
276
 
264
- # Check for auth.json from secure login MVP (only during migration)
265
- api_url, api_key = self._load_auth_json_credentials(api_url, api_key)
277
+ # Check for auth.json from secure login MVP (only during migration)
278
+ api_url, api_key = self._load_auth_json_credentials(api_url, api_key)
266
279
 
267
- # Create default account if we have credentials
268
- migrated["accounts"] = self._create_default_account(api_url, api_key)
280
+ # Create default account if we have valid credentials
281
+ migrated["accounts"] = self._create_default_account(api_url, api_key)
282
+ # Only set active_account to default if we actually created a default account
283
+ if not migrated["accounts"]:
284
+ migrated.pop("active_account", None)
269
285
 
270
286
  # Preserve other top-level keys for backward compatibility
271
287
  migrated.update(self._preserve_legacy_keys(config))
@@ -423,16 +439,18 @@ class AccountStore:
423
439
 
424
440
  del accounts[name]
425
441
 
426
- # If we removed the active account, switch to another
442
+ # If we removed the active account, switch to another account
427
443
  active_account = config.get("active_account")
428
444
  if active_account == name:
429
- # Try to switch to 'default' if it exists, otherwise first alphabetical
430
- remaining_names = sorted(accounts.keys())
431
- if "default" in remaining_names:
445
+ # Prefer "default" if it exists, otherwise use first alphabetical account
446
+ if "default" in accounts:
432
447
  config["active_account"] = "default"
433
- elif remaining_names:
434
- config["active_account"] = remaining_names[0]
435
- else: # pragma: no cover - defensive code, unreachable due to len check above
448
+ elif accounts:
449
+ # Sort accounts alphabetically and pick the first one
450
+ sorted_names = sorted(accounts.keys())
451
+ config["active_account"] = sorted_names[0]
452
+ else:
453
+ # No accounts remaining (shouldn't happen due to check above)
436
454
  config.pop("active_account", None)
437
455
 
438
456
  self._save_config(config)
@@ -6,7 +6,6 @@ Authors:
6
6
 
7
7
  import getpass
8
8
  import json
9
- import os
10
9
  import sys
11
10
  from pathlib import Path
12
11
 
@@ -33,6 +32,7 @@ from glaip_sdk.cli.account_store import (
33
32
  from glaip_sdk.cli.commands.common_config import check_connection, render_branding_header
34
33
  from glaip_sdk.cli.hints import format_command_hint
35
34
  from glaip_sdk.cli.masking import mask_api_key_display
35
+ from glaip_sdk.cli.slash.accounts_shared import env_credentials_present
36
36
  from glaip_sdk.cli.utils import command_hint
37
37
  from glaip_sdk.icons import ICON_TOOL
38
38
  from glaip_sdk.rich_components import AIPPanel, AIPTable
@@ -231,7 +231,7 @@ def show_account(name: str, output_json: bool) -> None:
231
231
  masked_key = _mask_api_key(api_key or "")
232
232
  active_account = store.get_active_account()
233
233
  is_active = active_account == name
234
- env_lock = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
234
+ env_lock = env_credentials_present(partial=True)
235
235
  config_path_raw = str(store.config_file)
236
236
  config_path_display = _format_config_path(config_path_raw)
237
237
 
@@ -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.slash.accounts_shared import build_account_status_string
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 = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
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._render_rich(rows, env_lock)
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
- 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
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); switching is disabled.[/]"
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: show active account + host after closing the TUI
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}[/]")