glaip-sdk 0.1.2__py3-none-any.whl → 0.6.5b3__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.
Files changed (129) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1090 -0
  5. glaip_sdk/branding.py +13 -0
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/auth.py +254 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents.py +214 -74
  11. glaip_sdk/cli/commands/common_config.py +101 -0
  12. glaip_sdk/cli/commands/configure.py +729 -113
  13. glaip_sdk/cli/commands/mcps.py +241 -72
  14. glaip_sdk/cli/commands/models.py +11 -5
  15. glaip_sdk/cli/commands/tools.py +49 -57
  16. glaip_sdk/cli/commands/transcripts.py +755 -0
  17. glaip_sdk/cli/config.py +48 -4
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +8 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +846 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +41 -20
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +6 -3
  28. glaip_sdk/cli/main.py +228 -119
  29. glaip_sdk/cli/masking.py +21 -33
  30. glaip_sdk/cli/pager.py +9 -10
  31. glaip_sdk/cli/parsers/__init__.py +1 -3
  32. glaip_sdk/cli/slash/__init__.py +0 -9
  33. glaip_sdk/cli/slash/accounts_controller.py +500 -0
  34. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  35. glaip_sdk/cli/slash/agent_session.py +58 -20
  36. glaip_sdk/cli/slash/prompt.py +10 -0
  37. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  38. glaip_sdk/cli/slash/session.py +736 -134
  39. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  40. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  41. glaip_sdk/cli/slash/tui/accounts_app.py +872 -0
  42. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  43. glaip_sdk/cli/slash/tui/loading.py +58 -0
  44. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  45. glaip_sdk/cli/transcript/__init__.py +12 -52
  46. glaip_sdk/cli/transcript/cache.py +255 -44
  47. glaip_sdk/cli/transcript/capture.py +66 -1
  48. glaip_sdk/cli/transcript/history.py +815 -0
  49. glaip_sdk/cli/transcript/viewer.py +70 -463
  50. glaip_sdk/cli/update_notifier.py +14 -5
  51. glaip_sdk/cli/utils.py +243 -1258
  52. glaip_sdk/cli/validators.py +5 -6
  53. glaip_sdk/client/__init__.py +2 -1
  54. glaip_sdk/client/_agent_payloads.py +45 -9
  55. glaip_sdk/client/agent_runs.py +147 -0
  56. glaip_sdk/client/agents.py +287 -29
  57. glaip_sdk/client/base.py +1 -0
  58. glaip_sdk/client/main.py +19 -10
  59. glaip_sdk/client/mcps.py +122 -12
  60. glaip_sdk/client/run_rendering.py +133 -90
  61. glaip_sdk/client/shared.py +21 -0
  62. glaip_sdk/client/tools.py +153 -10
  63. glaip_sdk/config/constants.py +11 -0
  64. glaip_sdk/mcps/__init__.py +21 -0
  65. glaip_sdk/mcps/base.py +345 -0
  66. glaip_sdk/models/__init__.py +90 -0
  67. glaip_sdk/models/agent.py +47 -0
  68. glaip_sdk/models/agent_runs.py +116 -0
  69. glaip_sdk/models/common.py +42 -0
  70. glaip_sdk/models/mcp.py +33 -0
  71. glaip_sdk/models/tool.py +33 -0
  72. glaip_sdk/payload_schemas/__init__.py +1 -13
  73. glaip_sdk/registry/__init__.py +55 -0
  74. glaip_sdk/registry/agent.py +164 -0
  75. glaip_sdk/registry/base.py +139 -0
  76. glaip_sdk/registry/mcp.py +253 -0
  77. glaip_sdk/registry/tool.py +238 -0
  78. glaip_sdk/rich_components.py +58 -2
  79. glaip_sdk/tools/__init__.py +22 -0
  80. glaip_sdk/tools/base.py +435 -0
  81. glaip_sdk/utils/__init__.py +58 -12
  82. glaip_sdk/utils/bundler.py +267 -0
  83. glaip_sdk/utils/client.py +111 -0
  84. glaip_sdk/utils/client_utils.py +39 -7
  85. glaip_sdk/utils/datetime_helpers.py +58 -0
  86. glaip_sdk/utils/discovery.py +78 -0
  87. glaip_sdk/utils/display.py +23 -15
  88. glaip_sdk/utils/export.py +143 -0
  89. glaip_sdk/utils/general.py +0 -33
  90. glaip_sdk/utils/import_export.py +12 -7
  91. glaip_sdk/utils/import_resolver.py +492 -0
  92. glaip_sdk/utils/instructions.py +101 -0
  93. glaip_sdk/utils/rendering/__init__.py +115 -1
  94. glaip_sdk/utils/rendering/formatting.py +5 -30
  95. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  96. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  97. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  98. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  99. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  100. glaip_sdk/utils/rendering/models.py +1 -0
  101. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  102. glaip_sdk/utils/rendering/renderer/base.py +241 -1434
  103. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  104. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  105. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  106. glaip_sdk/utils/rendering/renderer/stream.py +4 -33
  107. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  108. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  109. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  110. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  111. glaip_sdk/utils/rendering/state.py +204 -0
  112. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  113. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  114. glaip_sdk/utils/rendering/steps/format.py +176 -0
  115. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  116. glaip_sdk/utils/rendering/timing.py +36 -0
  117. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  118. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  119. glaip_sdk/utils/resource_refs.py +25 -13
  120. glaip_sdk/utils/runtime_config.py +306 -0
  121. glaip_sdk/utils/serialization.py +18 -0
  122. glaip_sdk/utils/sync.py +142 -0
  123. glaip_sdk/utils/validation.py +16 -24
  124. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.6.5b3.dist-info}/METADATA +39 -4
  125. glaip_sdk-0.6.5b3.dist-info/RECORD +145 -0
  126. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.6.5b3.dist-info}/WHEEL +1 -1
  127. glaip_sdk/models.py +0 -240
  128. glaip_sdk-0.1.2.dist-info/RECORD +0 -82
  129. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.6.5b3.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/masking.py CHANGED
@@ -6,9 +6,10 @@ Authors:
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import os
10
9
  from typing import Any
11
10
 
11
+ from glaip_sdk.cli.constants import MASK_SENSITIVE_FIELDS, MASKING_ENABLED
12
+
12
13
  __all__ = [
13
14
  "mask_payload",
14
15
  "mask_rows",
@@ -16,20 +17,9 @@ __all__ = [
16
17
  "_mask_any",
17
18
  "_maybe_mask_row",
18
19
  "_resolve_mask_fields",
20
+ "mask_api_key_display",
19
21
  ]
20
22
 
21
- _DEFAULT_MASK_FIELDS = {
22
- "api_key",
23
- "apikey",
24
- "token",
25
- "access_token",
26
- "secret",
27
- "client_secret",
28
- "password",
29
- "private_key",
30
- "bearer",
31
- }
32
-
33
23
 
34
24
  def _mask_value(raw: Any) -> str:
35
25
  """Return a masked representation of the provided value.
@@ -90,22 +80,10 @@ def _maybe_mask_row(row: dict[str, Any], mask_fields: set[str]) -> dict[str, Any
90
80
 
91
81
 
92
82
  def _resolve_mask_fields() -> set[str]:
93
- """Resolve the set of sensitive fields to mask based on environment.
94
-
95
- Returns:
96
- set[str]: Set of field names to mask. Empty set if masking is disabled
97
- via AIP_MASK_OFF environment variable, custom fields from
98
- AIP_MASK_FIELDS, or default fields if neither is set.
99
- """
100
- if os.getenv("AIP_MASK_OFF", "0") in {"1", "true", "on", "yes"}:
83
+ """Return the configured set of fields that should be masked."""
84
+ if not MASKING_ENABLED:
101
85
  return set()
102
-
103
- env_fields = (os.getenv("AIP_MASK_FIELDS") or "").strip()
104
- if env_fields:
105
- parts = [part.strip().lower() for part in env_fields.split(",") if part.strip()]
106
- return set(parts)
107
-
108
- return set(_DEFAULT_MASK_FIELDS)
86
+ return set(MASK_SENSITIVE_FIELDS)
109
87
 
110
88
 
111
89
  def mask_payload(payload: Any) -> Any:
@@ -115,9 +93,7 @@ def mask_payload(payload: Any) -> Any:
115
93
  payload: Any data structure (dict, list, or primitive) to mask.
116
94
 
117
95
  Returns:
118
- Any: The payload with sensitive fields masked based on environment
119
- configuration. Returns original payload if masking is disabled
120
- or if an error occurs during masking.
96
+ Any: The payload with sensitive fields masked based on configuration.
121
97
  """
122
98
  mask_fields = _resolve_mask_fields()
123
99
  if not mask_fields:
@@ -136,8 +112,8 @@ def mask_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
136
112
 
137
113
  Returns:
138
114
  list[dict[str, Any]]: List of rows with sensitive fields masked based
139
- on environment configuration. Returns original
140
- rows if masking is disabled or if an error occurs.
115
+ on configuration. Returns original rows if
116
+ masking is disabled or if an error occurs.
141
117
  """
142
118
  mask_fields = _resolve_mask_fields()
143
119
  if not mask_fields:
@@ -146,3 +122,15 @@ def mask_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
146
122
  return [_maybe_mask_row(row, mask_fields) for row in rows]
147
123
  except Exception:
148
124
  return rows
125
+
126
+
127
+ def mask_api_key_display(value: str | None) -> str:
128
+ """Mask API keys for CLI display while preserving readability for short keys."""
129
+ if not value:
130
+ return ""
131
+ length = len(value)
132
+ if length <= 4:
133
+ return "***"
134
+ if length <= 8:
135
+ return value[:1] + "••••" + value[-1:]
136
+ return value[:4] + "••••" + value[-4:]
glaip_sdk/cli/pager.py CHANGED
@@ -19,6 +19,8 @@ from typing import Any
19
19
 
20
20
  from rich.console import Console
21
21
 
22
+ from glaip_sdk.cli.constants import PAGER_HEADER_ENABLED, PAGER_MODE, PAGER_WRAP_LINES
23
+
22
24
  __all__ = [
23
25
  "console",
24
26
  "_prepare_pager_env",
@@ -64,8 +66,7 @@ def _prepare_pager_env(clear_on_exit: bool = True) -> None:
64
66
  -R : pass ANSI color escapes
65
67
  -S : chop long lines (horizontal scroll with ←/→)
66
68
  (No -F, no -X) so we open a full-screen pager and clear on exit.
67
- Toggle wrapping with AIP_PAGER_WRAP=1 to drop -S.
68
- Power users can override via AIP_LESS_FLAGS.
69
+ Toggle wrapping via `PAGER_WRAP_LINES` (True drops -S).
69
70
 
70
71
  Args:
71
72
  clear_on_exit: Whether to clear the pager on exit (default: True)
@@ -75,10 +76,9 @@ def _prepare_pager_env(clear_on_exit: bool = True) -> None:
75
76
  """
76
77
  os.environ.pop("LESSSECURE", None)
77
78
  if os.getenv("LESS") is None:
78
- want_wrap = os.getenv("AIP_PAGER_WRAP", "0") == "1"
79
- base = "-R" if want_wrap else "-RS"
79
+ base = "-R" if PAGER_WRAP_LINES else "-RS"
80
80
  default_flags = base if clear_on_exit else (base + "FX")
81
- os.environ["LESS"] = os.getenv("AIP_LESS_FLAGS", default_flags)
81
+ os.environ["LESS"] = default_flags
82
82
 
83
83
 
84
84
  def _render_ansi(renderable: Any) -> str:
@@ -111,8 +111,7 @@ def _pager_header() -> str:
111
111
  Returns:
112
112
  str: Header text containing navigation help, or empty string if disabled
113
113
  """
114
- v = (os.getenv("AIP_PAGER_HEADER", "1") or "1").strip().lower()
115
- if v in {"0", "false", "off"}:
114
+ if not PAGER_HEADER_ENABLED:
116
115
  return ""
117
116
  return "\n".join(
118
117
  [
@@ -254,10 +253,10 @@ def _should_page_output(row_count: int, is_tty: bool) -> bool:
254
253
  bool: True if output should be paginated, False otherwise
255
254
  """
256
255
  active_console = _get_console()
257
- pager_env = (os.getenv("AIP_PAGER", "auto") or "auto").lower()
258
- if pager_env in ("0", "off", "false"):
256
+ pager_mode = (PAGER_MODE or "auto").lower()
257
+ if pager_mode in ("0", "off", "false"):
259
258
  return False
260
- if pager_env in ("1", "on", "true"):
259
+ if pager_mode in ("1", "on", "true"):
261
260
  return is_tty
262
261
  try:
263
262
  term_h = active_console.size.height or 24
@@ -4,6 +4,4 @@ Authors:
4
4
  Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
5
  """
6
6
 
7
- from glaip_sdk.cli.parsers.json_input import parse_json_input
8
-
9
- __all__ = ["parse_json_input"]
7
+ __all__: list[str] = []
@@ -6,19 +6,10 @@ Authors:
6
6
 
7
7
  from glaip_sdk.cli.commands.agents import get as agents_get_command
8
8
  from glaip_sdk.cli.commands.agents import run as agents_run_command
9
- from glaip_sdk.cli.commands.configure import configure_command, load_config
10
- from glaip_sdk.cli.slash.agent_session import AgentRunSession
11
- from glaip_sdk.cli.slash.prompt import _HAS_PROMPT_TOOLKIT
12
9
  from glaip_sdk.cli.slash.session import SlashSession
13
- from glaip_sdk.cli.utils import get_client
14
10
 
15
11
  __all__ = [
16
- "AgentRunSession",
17
12
  "SlashSession",
18
- "_HAS_PROMPT_TOOLKIT",
19
13
  "agents_get_command",
20
14
  "agents_run_command",
21
- "configure_command",
22
- "get_client",
23
- "load_config",
24
15
  ]
@@ -0,0 +1,500 @@
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 sys
13
+ from collections.abc import Iterable
14
+ from getpass import getpass
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from rich.console import Console
18
+ from rich.prompt import Prompt
19
+
20
+ from glaip_sdk.branding import ERROR_STYLE, INFO_STYLE, SUCCESS_STYLE, WARNING_STYLE
21
+ from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
22
+ from glaip_sdk.cli.commands.common_config import check_connection_with_reason
23
+ from glaip_sdk.cli.masking import mask_api_key_display
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
+ )
30
+ from glaip_sdk.cli.slash.tui.accounts_app import TEXTUAL_SUPPORTED, AccountsTUICallbacks, run_accounts_textual
31
+ from glaip_sdk.rich_components import AIPPanel, AIPTable
32
+ from glaip_sdk.utils.validation import validate_url
33
+
34
+ if TYPE_CHECKING: # pragma: no cover
35
+ from glaip_sdk.cli.slash.session import SlashSession
36
+
37
+ TEXTUAL_AVAILABLE = bool(TEXTUAL_SUPPORTED)
38
+
39
+
40
+ class AccountsController:
41
+ """Controller for listing and switching accounts inside the palette."""
42
+
43
+ def __init__(self, session: SlashSession) -> None:
44
+ """Initialize the accounts controller.
45
+
46
+ Args:
47
+ session: The slash session context.
48
+ """
49
+ self.session = session
50
+ self.console: Console = session.console
51
+ self.ctx = session.ctx
52
+
53
+ def handle_accounts_command(self, args: list[str]) -> bool:
54
+ """Handle `/accounts` with optional `/accounts <name>` quick switch."""
55
+ store = get_account_store()
56
+ env_lock = env_credentials_present(partial=True)
57
+ accounts = store.list_accounts()
58
+
59
+ if not accounts:
60
+ self.console.print(f"[{WARNING_STYLE}]No accounts found. Use `/login` to add credentials.[/]")
61
+ return self.session._continue_session()
62
+
63
+ if args:
64
+ name = args[0]
65
+ self._switch_account(store, name, env_lock)
66
+ return self.session._continue_session()
67
+
68
+ rows = self._build_rows(accounts, store.get_active_account(), env_lock)
69
+
70
+ if self._should_use_textual():
71
+ self._render_textual(rows, store, env_lock)
72
+ else:
73
+ self._render_rich_interactive(store, env_lock)
74
+
75
+ return self.session._continue_session()
76
+
77
+ def _should_use_textual(self) -> bool:
78
+ """Return whether Textual UI should be used."""
79
+ if not TEXTUAL_AVAILABLE:
80
+ return False
81
+
82
+ def _is_tty(stream: Any) -> bool:
83
+ isatty = getattr(stream, "isatty", None)
84
+ if not callable(isatty):
85
+ return False
86
+ try:
87
+ return bool(isatty())
88
+ except Exception:
89
+ return False
90
+
91
+ return _is_tty(sys.stdin) and _is_tty(sys.stdout)
92
+
93
+ def _build_rows(
94
+ self,
95
+ accounts: dict[str, dict[str, str]],
96
+ active_account: str | None,
97
+ env_lock: bool,
98
+ ) -> list[dict[str, str | bool]]:
99
+ """Normalize account rows for display."""
100
+ return build_account_rows(accounts, active_account, env_lock)
101
+
102
+ def _render_rich(self, rows: Iterable[dict[str, str | bool]], env_lock: bool) -> None:
103
+ """Render a Rich snapshot with columns matching TUI."""
104
+ if env_lock:
105
+ self.console.print(
106
+ f"[{WARNING_STYLE}]Env credentials detected (AIP_API_URL/AIP_API_KEY); add/edit/delete are disabled.[/]"
107
+ )
108
+
109
+ table = AIPTable(title="AIP Accounts")
110
+ table.add_column("Name", style=INFO_STYLE, width=20)
111
+ table.add_column("API URL", style=SUCCESS_STYLE, width=40)
112
+ table.add_column("Key (masked)", style="dim", width=20)
113
+ table.add_column("Status", style=SUCCESS_STYLE, width=14)
114
+
115
+ for row in rows:
116
+ status = build_account_status_string(row, use_markup=True)
117
+ # pylint: disable=duplicate-code
118
+ # Similar to accounts_app.py but uses Rich AIPTable API
119
+ table.add_row(
120
+ str(row.get("name", "")),
121
+ str(row.get("api_url", "")),
122
+ str(row.get("masked_key", "")),
123
+ status,
124
+ )
125
+
126
+ self.console.print(table)
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
+
152
+ def _render_textual(self, rows: list[dict[str, str | bool]], store: AccountStore, env_lock: bool) -> None:
153
+ """Launch the Textual accounts browser."""
154
+ callbacks = AccountsTUICallbacks(switch_account=lambda name: self._switch_account(store, name, env_lock))
155
+ active = next((row["name"] for row in rows if row.get("active")), None)
156
+ run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks)
157
+ # Exit snapshot: surface a success banner when a switch occurred inside the TUI
158
+ active_after = store.get_active_account() or "default"
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 ""
165
+ self.console.print(
166
+ AIPPanel(
167
+ f"[{SUCCESS_STYLE}]Active account ➜ {active_after}[/]{host_suffix}",
168
+ title="✅ Account Switched",
169
+ border_style=SUCCESS_STYLE,
170
+ )
171
+ )
172
+
173
+ def _switch_account(self, store: AccountStore, name: str, env_lock: bool) -> tuple[bool, str]:
174
+ """Validate and switch active account; returns (success, message)."""
175
+ if env_lock:
176
+ msg = "Env credentials detected (AIP_API_URL/AIP_API_KEY); switching is disabled."
177
+ self.console.print(f"[{WARNING_STYLE}]{msg}[/]")
178
+ return False, msg
179
+
180
+ account = store.get_account(name)
181
+ if not account:
182
+ msg = f"Account '{name}' not found."
183
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
184
+ return False, msg
185
+
186
+ api_url = account.get("api_url", "")
187
+ api_key = account.get("api_key", "")
188
+ if not api_url or not api_key:
189
+ edit_cmd = f"aip accounts edit {name}"
190
+ msg = f"Account '{name}' is missing credentials. Use `/login` or `{edit_cmd}`."
191
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
192
+ return False, msg
193
+
194
+ ok, error_reason = check_connection_with_reason(api_url, api_key, abort_on_error=False)
195
+ if not ok:
196
+ code, detail = self._parse_error_reason(error_reason)
197
+ if code == "connection_failed":
198
+ msg = f"Switch aborted: cannot reach {api_url}. Check URL or network."
199
+ elif code == "api_failed":
200
+ msg = f"Switch aborted: API error for '{name}'. Check credentials."
201
+ else:
202
+ detail_suffix = f": {detail}" if detail else ""
203
+ msg = f"Switch aborted: {code or 'Validation failed'}{detail_suffix}"
204
+ self.console.print(f"[{WARNING_STYLE}]{msg}[/]")
205
+ return False, msg
206
+
207
+ try:
208
+ store.set_active_account(name)
209
+ masked_key = mask_api_key_display(api_key)
210
+ self.console.print(
211
+ AIPPanel(
212
+ f"[{SUCCESS_STYLE}]Active account ➜ {name}[/]\nAPI URL: {api_url}\nKey: {masked_key}",
213
+ title="✅ Account Switched",
214
+ border_style=SUCCESS_STYLE,
215
+ )
216
+ )
217
+ return True, f"Switched to '{name}'."
218
+ except AccountStoreError as exc:
219
+ msg = f"Failed to set active account: {exc}"
220
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
221
+ return False, msg
222
+ except Exception as exc: # NOSONAR(S1045) - catch-all needed for unexpected errors
223
+ msg = f"Unexpected error while switching to '{name}': {exc}"
224
+ self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
225
+ return False, msg
226
+
227
+ @staticmethod
228
+ def _parse_error_reason(reason: str | None) -> tuple[str, str]:
229
+ """Parse error reason into (code, detail) to avoid fragile substring checks."""
230
+ if not reason:
231
+ return "", ""
232
+ if ":" in reason:
233
+ code, _, detail = reason.partition(":")
234
+ return code.strip(), detail.strip()
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}[/]")