glaip-sdk 0.0.20__py3-none-any.whl → 0.7.7__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 (216) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1250 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +271 -45
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  12. glaip_sdk/cli/commands/agents/_common.py +561 -0
  13. glaip_sdk/cli/commands/agents/create.py +151 -0
  14. glaip_sdk/cli/commands/agents/delete.py +64 -0
  15. glaip_sdk/cli/commands/agents/get.py +89 -0
  16. glaip_sdk/cli/commands/agents/list.py +129 -0
  17. glaip_sdk/cli/commands/agents/run.py +264 -0
  18. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  19. glaip_sdk/cli/commands/agents/update.py +112 -0
  20. glaip_sdk/cli/commands/common_config.py +104 -0
  21. glaip_sdk/cli/commands/configure.py +734 -143
  22. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  23. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  24. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  25. glaip_sdk/cli/commands/mcps/create.py +152 -0
  26. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  27. glaip_sdk/cli/commands/mcps/get.py +212 -0
  28. glaip_sdk/cli/commands/mcps/list.py +69 -0
  29. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  30. glaip_sdk/cli/commands/mcps/update.py +190 -0
  31. glaip_sdk/cli/commands/models.py +14 -12
  32. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  33. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  34. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  35. glaip_sdk/cli/commands/tools/_common.py +80 -0
  36. glaip_sdk/cli/commands/tools/create.py +228 -0
  37. glaip_sdk/cli/commands/tools/delete.py +61 -0
  38. glaip_sdk/cli/commands/tools/get.py +103 -0
  39. glaip_sdk/cli/commands/tools/list.py +69 -0
  40. glaip_sdk/cli/commands/tools/script.py +49 -0
  41. glaip_sdk/cli/commands/tools/update.py +102 -0
  42. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  43. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  44. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  45. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  46. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  47. glaip_sdk/cli/commands/update.py +164 -23
  48. glaip_sdk/cli/config.py +49 -7
  49. glaip_sdk/cli/constants.py +38 -0
  50. glaip_sdk/cli/context.py +8 -0
  51. glaip_sdk/cli/core/__init__.py +79 -0
  52. glaip_sdk/cli/core/context.py +124 -0
  53. glaip_sdk/cli/core/output.py +851 -0
  54. glaip_sdk/cli/core/prompting.py +649 -0
  55. glaip_sdk/cli/core/rendering.py +187 -0
  56. glaip_sdk/cli/display.py +45 -32
  57. glaip_sdk/cli/entrypoint.py +20 -0
  58. glaip_sdk/cli/hints.py +57 -0
  59. glaip_sdk/cli/io.py +14 -17
  60. glaip_sdk/cli/main.py +344 -167
  61. glaip_sdk/cli/masking.py +21 -33
  62. glaip_sdk/cli/mcp_validators.py +5 -15
  63. glaip_sdk/cli/pager.py +15 -22
  64. glaip_sdk/cli/parsers/__init__.py +1 -3
  65. glaip_sdk/cli/parsers/json_input.py +11 -22
  66. glaip_sdk/cli/resolution.py +5 -10
  67. glaip_sdk/cli/rich_helpers.py +1 -3
  68. glaip_sdk/cli/slash/__init__.py +0 -9
  69. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  70. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  71. glaip_sdk/cli/slash/agent_session.py +65 -29
  72. glaip_sdk/cli/slash/prompt.py +24 -10
  73. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  74. glaip_sdk/cli/slash/session.py +827 -232
  75. glaip_sdk/cli/slash/tui/__init__.py +34 -0
  76. glaip_sdk/cli/slash/tui/accounts.tcss +88 -0
  77. glaip_sdk/cli/slash/tui/accounts_app.py +933 -0
  78. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  79. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  80. glaip_sdk/cli/slash/tui/context.py +59 -0
  81. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  82. glaip_sdk/cli/slash/tui/loading.py +58 -0
  83. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  84. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  85. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  86. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  87. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  88. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  89. glaip_sdk/cli/slash/tui/toast.py +123 -0
  90. glaip_sdk/cli/transcript/__init__.py +12 -52
  91. glaip_sdk/cli/transcript/cache.py +258 -60
  92. glaip_sdk/cli/transcript/capture.py +72 -21
  93. glaip_sdk/cli/transcript/history.py +815 -0
  94. glaip_sdk/cli/transcript/launcher.py +1 -3
  95. glaip_sdk/cli/transcript/viewer.py +79 -329
  96. glaip_sdk/cli/update_notifier.py +385 -24
  97. glaip_sdk/cli/validators.py +16 -18
  98. glaip_sdk/client/__init__.py +3 -1
  99. glaip_sdk/client/_schedule_payloads.py +89 -0
  100. glaip_sdk/client/agent_runs.py +147 -0
  101. glaip_sdk/client/agents.py +370 -100
  102. glaip_sdk/client/base.py +78 -35
  103. glaip_sdk/client/hitl.py +136 -0
  104. glaip_sdk/client/main.py +25 -10
  105. glaip_sdk/client/mcps.py +166 -27
  106. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  107. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +65 -74
  108. glaip_sdk/client/payloads/agent/responses.py +43 -0
  109. glaip_sdk/client/run_rendering.py +583 -79
  110. glaip_sdk/client/schedules.py +439 -0
  111. glaip_sdk/client/shared.py +21 -0
  112. glaip_sdk/client/tools.py +214 -56
  113. glaip_sdk/client/validators.py +20 -48
  114. glaip_sdk/config/constants.py +11 -0
  115. glaip_sdk/exceptions.py +1 -3
  116. glaip_sdk/hitl/__init__.py +48 -0
  117. glaip_sdk/hitl/base.py +64 -0
  118. glaip_sdk/hitl/callback.py +43 -0
  119. glaip_sdk/hitl/local.py +121 -0
  120. glaip_sdk/hitl/remote.py +523 -0
  121. glaip_sdk/icons.py +9 -3
  122. glaip_sdk/mcps/__init__.py +21 -0
  123. glaip_sdk/mcps/base.py +345 -0
  124. glaip_sdk/models/__init__.py +107 -0
  125. glaip_sdk/models/agent.py +47 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/mcp.py +33 -0
  129. glaip_sdk/models/schedule.py +224 -0
  130. glaip_sdk/models/tool.py +33 -0
  131. glaip_sdk/payload_schemas/__init__.py +1 -13
  132. glaip_sdk/payload_schemas/agent.py +1 -3
  133. glaip_sdk/registry/__init__.py +55 -0
  134. glaip_sdk/registry/agent.py +164 -0
  135. glaip_sdk/registry/base.py +139 -0
  136. glaip_sdk/registry/mcp.py +253 -0
  137. glaip_sdk/registry/tool.py +445 -0
  138. glaip_sdk/rich_components.py +58 -2
  139. glaip_sdk/runner/__init__.py +76 -0
  140. glaip_sdk/runner/base.py +84 -0
  141. glaip_sdk/runner/deps.py +112 -0
  142. glaip_sdk/runner/langgraph.py +872 -0
  143. glaip_sdk/runner/logging_config.py +77 -0
  144. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  145. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  146. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  147. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  148. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  149. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  150. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  151. glaip_sdk/schedules/__init__.py +22 -0
  152. glaip_sdk/schedules/base.py +291 -0
  153. glaip_sdk/tools/__init__.py +22 -0
  154. glaip_sdk/tools/base.py +468 -0
  155. glaip_sdk/utils/__init__.py +59 -12
  156. glaip_sdk/utils/a2a/__init__.py +34 -0
  157. glaip_sdk/utils/a2a/event_processor.py +188 -0
  158. glaip_sdk/utils/agent_config.py +4 -14
  159. glaip_sdk/utils/bundler.py +403 -0
  160. glaip_sdk/utils/client.py +111 -0
  161. glaip_sdk/utils/client_utils.py +46 -28
  162. glaip_sdk/utils/datetime_helpers.py +58 -0
  163. glaip_sdk/utils/discovery.py +78 -0
  164. glaip_sdk/utils/display.py +25 -21
  165. glaip_sdk/utils/export.py +143 -0
  166. glaip_sdk/utils/general.py +1 -36
  167. glaip_sdk/utils/import_export.py +15 -16
  168. glaip_sdk/utils/import_resolver.py +524 -0
  169. glaip_sdk/utils/instructions.py +101 -0
  170. glaip_sdk/utils/rendering/__init__.py +115 -1
  171. glaip_sdk/utils/rendering/formatting.py +38 -23
  172. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  173. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  174. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  175. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  176. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  177. glaip_sdk/utils/rendering/models.py +18 -8
  178. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  179. glaip_sdk/utils/rendering/renderer/base.py +534 -882
  180. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  181. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  182. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  183. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  184. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  185. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  186. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  187. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  188. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  189. glaip_sdk/utils/rendering/state.py +204 -0
  190. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  191. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  192. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  193. glaip_sdk/utils/rendering/steps/format.py +176 -0
  194. glaip_sdk/utils/rendering/{steps.py → steps/manager.py} +122 -26
  195. glaip_sdk/utils/rendering/timing.py +36 -0
  196. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  197. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  198. glaip_sdk/utils/resource_refs.py +29 -26
  199. glaip_sdk/utils/runtime_config.py +425 -0
  200. glaip_sdk/utils/serialization.py +32 -46
  201. glaip_sdk/utils/sync.py +162 -0
  202. glaip_sdk/utils/tool_detection.py +301 -0
  203. glaip_sdk/utils/tool_storage_provider.py +140 -0
  204. glaip_sdk/utils/validation.py +20 -28
  205. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/METADATA +78 -23
  206. glaip_sdk-0.7.7.dist-info/RECORD +213 -0
  207. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/WHEEL +2 -1
  208. glaip_sdk-0.7.7.dist-info/entry_points.txt +2 -0
  209. glaip_sdk-0.7.7.dist-info/top_level.txt +1 -0
  210. glaip_sdk/cli/commands/agents.py +0 -1412
  211. glaip_sdk/cli/commands/mcps.py +0 -1225
  212. glaip_sdk/cli/commands/tools.py +0 -597
  213. glaip_sdk/cli/utils.py +0 -1330
  214. glaip_sdk/models.py +0 -259
  215. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  216. glaip_sdk-0.0.20.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,580 @@
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
+ active_before = store.get_active_account()
155
+ notified = False
156
+
157
+ def _switch_in_textual(name: str) -> tuple[bool, str]:
158
+ nonlocal notified
159
+ switched, message = self._switch_account(
160
+ store,
161
+ name,
162
+ env_lock,
163
+ emit_console=False,
164
+ invalidate_session=True,
165
+ )
166
+ if switched:
167
+ notified = True
168
+ return switched, message
169
+
170
+ callbacks = AccountsTUICallbacks(switch_account=_switch_in_textual)
171
+ active = next((row["name"] for row in rows if row.get("active")), None)
172
+ try:
173
+ # Inject TUI context for theme support
174
+ tui_ctx = getattr(self.session, "tui_ctx", None)
175
+ run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks, ctx=tui_ctx)
176
+ except Exception as exc: # pragma: no cover - defensive around Textual failures
177
+ self.console.print(f"[{WARNING_STYLE}]Accounts browser exited unexpectedly: {exc}[/]")
178
+
179
+ # Exit snapshot: surface a success banner when a switch occurred inside the TUI.
180
+ # Always notify when the active account changed, even if Textual raised.
181
+ active_after = store.get_active_account()
182
+ if active_after != active_before and not notified:
183
+ self._notify_account_switched(active_after)
184
+ if active_after != active:
185
+ host_after = ""
186
+ display_account = active_after or "default"
187
+ account_after = store.get_account(display_account) if hasattr(store, "get_account") else None
188
+ if account_after:
189
+ host_after = account_after.get("api_url", "")
190
+ host_suffix = f" • {host_after}" if host_after else ""
191
+ self.console.print(
192
+ AIPPanel(
193
+ f"[{SUCCESS_STYLE}]Active account ➜ {display_account}[/]{host_suffix}",
194
+ title="✅ Account Switched",
195
+ border_style=SUCCESS_STYLE,
196
+ )
197
+ )
198
+
199
+ def _format_connection_error_message(self, error_reason: str, account_name: str, api_url: str) -> str:
200
+ """Format error message for connection validation failures."""
201
+ code, detail = self._parse_error_reason(error_reason)
202
+ if code == "connection_failed":
203
+ return f"Switch aborted: cannot reach {api_url}. Check URL or network."
204
+ if code == "api_failed":
205
+ return f"Switch aborted: API error for '{account_name}'. Check credentials."
206
+ detail_suffix = f": {detail}" if detail else ""
207
+ return f"Switch aborted: {code or 'Validation failed'}{detail_suffix}"
208
+
209
+ def _emit_error_message(self, msg: str, style: str = ERROR_STYLE) -> None:
210
+ """Emit an error or warning message to the console."""
211
+ self.console.print(f"[{style}]{msg}[/]")
212
+
213
+ def _validate_account_switch(
214
+ self, store: AccountStore, name: str, env_lock: bool, emit_console: bool
215
+ ) -> tuple[bool, str, dict[str, str] | None]:
216
+ """Validate account switch prerequisites; returns (is_valid, error_msg, account_dict)."""
217
+ if env_lock:
218
+ msg = "Env credentials detected (AIP_API_URL/AIP_API_KEY); switching is disabled."
219
+ if emit_console:
220
+ self._emit_error_message(msg, WARNING_STYLE)
221
+ return False, msg, None
222
+
223
+ account = store.get_account(name)
224
+ if not account:
225
+ msg = f"Account '{name}' not found."
226
+ if emit_console:
227
+ self._emit_error_message(msg)
228
+ return False, msg, None
229
+
230
+ api_url = account.get("api_url", "")
231
+ api_key = account.get("api_key", "")
232
+ if not api_url or not api_key:
233
+ edit_cmd = f"aip accounts edit {name}"
234
+ msg = f"Account '{name}' is missing credentials. Use `/login` or `{edit_cmd}`."
235
+ if emit_console:
236
+ self._emit_error_message(msg)
237
+ return False, msg, None
238
+
239
+ ok, error_reason = check_connection_with_reason(api_url, api_key, abort_on_error=False)
240
+ if not ok:
241
+ msg = self._format_connection_error_message(error_reason, name, api_url)
242
+ if emit_console:
243
+ self._emit_error_message(msg, WARNING_STYLE)
244
+ return False, msg, None
245
+
246
+ return True, "", account
247
+
248
+ def _execute_account_switch(
249
+ self, store: AccountStore, name: str, account: dict[str, str], invalidate_session: bool, emit_console: bool
250
+ ) -> tuple[bool, str]:
251
+ """Execute the account switch and emit success message."""
252
+ try:
253
+ store.set_active_account(name)
254
+ api_url = account.get("api_url", "")
255
+ api_key = account.get("api_key", "")
256
+ masked_key = mask_api_key_display(api_key)
257
+ if invalidate_session:
258
+ self._notify_account_switched(name)
259
+ if emit_console:
260
+ self.console.print(
261
+ AIPPanel(
262
+ f"[{SUCCESS_STYLE}]Active account ➜ {name}[/]\nAPI URL: {api_url}\nKey: {masked_key}",
263
+ title="✅ Account Switched",
264
+ border_style=SUCCESS_STYLE,
265
+ )
266
+ )
267
+ return True, f"Switched to '{name}'."
268
+ except AccountStoreError as exc:
269
+ msg = f"Failed to set active account: {exc}"
270
+ if emit_console:
271
+ self._emit_error_message(msg)
272
+ return False, msg
273
+ except Exception as exc: # NOSONAR(S1045) - catch-all needed for unexpected errors
274
+ msg = f"Unexpected error while switching to '{name}': {exc}"
275
+ if emit_console:
276
+ self._emit_error_message(msg)
277
+ return False, msg
278
+
279
+ def _switch_account(
280
+ self,
281
+ store: AccountStore,
282
+ name: str,
283
+ env_lock: bool,
284
+ *,
285
+ emit_console: bool = True,
286
+ invalidate_session: bool = True,
287
+ ) -> tuple[bool, str]:
288
+ """Validate and switch active account; returns (success, message)."""
289
+ is_valid, error_msg, account = self._validate_account_switch(store, name, env_lock, emit_console)
290
+ if not is_valid:
291
+ return False, error_msg
292
+
293
+ if account is None: # Defensive – should never happen, but avoid crashing in production
294
+ return False, "Unable to locate account after validation."
295
+ return self._execute_account_switch(store, name, account, invalidate_session, emit_console)
296
+
297
+ @staticmethod
298
+ def _parse_error_reason(reason: str | None) -> tuple[str, str]:
299
+ """Parse error reason into (code, detail) to avoid fragile substring checks."""
300
+ if not reason:
301
+ return "", ""
302
+ if ":" in reason:
303
+ code, _, detail = reason.partition(":")
304
+ return code.strip(), detail.strip()
305
+ return reason.strip(), ""
306
+
307
+ def _prompt_action(self) -> str:
308
+ """Prompt for add/edit/delete/quit action."""
309
+ try:
310
+ choice = Prompt.ask("(a)dd / (e)dit / (d)elete / (s)witch / (q)uit", default="q")
311
+ except Exception: # pragma: no cover - defensive around prompt failures
312
+ return "q"
313
+ return (choice or "").strip().lower()[:1]
314
+
315
+ def _prompt_yes_no(self, prompt: str, *, default: bool = True) -> bool:
316
+ """Prompt a yes/no question with a default."""
317
+ default_str = "Y/n" if default else "y/N"
318
+ try:
319
+ answer = Prompt.ask(f"{prompt} ({default_str})", default="y" if default else "n")
320
+ except Exception: # pragma: no cover - defensive around prompt failures
321
+ return default
322
+ normalized = (answer or "").strip().lower()
323
+ if not normalized:
324
+ return default
325
+ return normalized in {"y", "yes"}
326
+
327
+ def _prompt_account_name(self, store: AccountStore, *, for_edit: bool) -> str | None:
328
+ """Prompt for an account name, validating per store rules."""
329
+ while True: # pragma: no cover - interactive prompt loop
330
+ name = self._get_name_input(for_edit)
331
+ if name is None:
332
+ return None
333
+ if not name:
334
+ self.console.print(f"[{WARNING_STYLE}]Name is required.[/]")
335
+ continue
336
+ if not self._validate_name_format(store, name):
337
+ continue
338
+ if not self._validate_name_existence(store, name, for_edit):
339
+ continue
340
+ return name
341
+
342
+ def _get_name_input(self, for_edit: bool) -> str | None:
343
+ """Get account name input from user."""
344
+ try:
345
+ prompt_text = "Account name" + (" (existing)" if for_edit else "")
346
+ name = Prompt.ask(prompt_text)
347
+ return name.strip() if name else None
348
+ except Exception:
349
+ return None
350
+
351
+ def _validate_name_format(self, store: AccountStore, name: str) -> bool:
352
+ """Validate account name format."""
353
+ try:
354
+ store.validate_account_name(name)
355
+ return True
356
+ except Exception as exc:
357
+ self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
358
+ return False
359
+
360
+ def _validate_name_existence(self, store: AccountStore, name: str, for_edit: bool) -> bool:
361
+ """Validate account name existence based on mode."""
362
+ account_exists = store.get_account(name) is not None
363
+ if not for_edit and account_exists:
364
+ self.console.print(
365
+ f"[{WARNING_STYLE}]Account '{name}' already exists. Use edit instead or choose a new name.[/]"
366
+ )
367
+ return False
368
+ if for_edit and not account_exists:
369
+ self.console.print(f"[{WARNING_STYLE}]Account '{name}' not found. Try again or quit.[/]")
370
+ return False
371
+ return True
372
+
373
+ def _prompt_api_url(self, existing_url: str | None = None) -> str | None:
374
+ """Prompt for API URL with HTTPS validation."""
375
+ placeholder = existing_url or "https://your-aip-instance.com"
376
+ while True: # pragma: no cover - interactive prompt loop
377
+ try:
378
+ entered = Prompt.ask("API URL", default=placeholder)
379
+ except Exception:
380
+ return None
381
+ url = (entered or "").strip()
382
+ if not url and existing_url:
383
+ return existing_url
384
+ if not url:
385
+ self.console.print(f"[{WARNING_STYLE}]API URL is required.[/]")
386
+ continue
387
+ try:
388
+ return validate_url(url)
389
+ except Exception as exc:
390
+ self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
391
+
392
+ def _prompt_api_key(self, existing_key: str | None = None) -> str | None:
393
+ """Prompt for API key (masked)."""
394
+ mask_hint = "leave blank to keep current" if existing_key else None
395
+ while True: # pragma: no cover - interactive prompt loop
396
+ try:
397
+ entered = getpass(f"API key ({mask_hint or 'input hidden'}): ")
398
+ except Exception:
399
+ return None
400
+ if not entered and existing_key:
401
+ return existing_key
402
+ if not entered:
403
+ self.console.print(f"[{WARNING_STYLE}]API key is required.[/]")
404
+ continue
405
+ try:
406
+ return validate_api_key(entered)
407
+ except Exception as exc:
408
+ self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
409
+
410
+ def _rich_add_flow(self, store: AccountStore) -> None:
411
+ """Run Rich add prompts and save."""
412
+ name = self._prompt_account_name(store, for_edit=False)
413
+ if not name:
414
+ return
415
+ api_url = self._prompt_api_url()
416
+ if not api_url:
417
+ return
418
+ api_key = self._prompt_api_key()
419
+ if not api_key:
420
+ return
421
+ should_test = self._prompt_yes_no("Test connection before save?", default=True)
422
+ self._save_account(store, name, api_url, api_key, should_test, True, is_edit=False)
423
+
424
+ def _rich_edit_flow(self, store: AccountStore) -> None:
425
+ """Run Rich edit prompts and save."""
426
+ name = self._prompt_account_name(store, for_edit=True)
427
+ if not name:
428
+ return
429
+ existing = store.get_account(name) or {}
430
+ api_url = self._prompt_api_url(existing.get("api_url"))
431
+ if not api_url:
432
+ return
433
+ api_key = self._prompt_api_key(existing.get("api_key"))
434
+ if not api_key:
435
+ return
436
+ should_test = self._prompt_yes_no("Test connection before save?", default=True)
437
+ self._save_account(store, name, api_url, api_key, should_test, False, is_edit=True)
438
+
439
+ def _rich_switch_flow(self, store: AccountStore, env_lock: bool) -> None:
440
+ """Run Rich switch prompt and set active account."""
441
+ name = self._prompt_account_name(store, for_edit=True)
442
+ if not name:
443
+ return
444
+ self._switch_account(store, name, env_lock)
445
+
446
+ def _save_account(
447
+ self,
448
+ store: AccountStore,
449
+ name: str,
450
+ api_url: str,
451
+ api_key: str,
452
+ should_test: bool,
453
+ set_active: bool,
454
+ *,
455
+ is_edit: bool,
456
+ ) -> None:
457
+ """Validate, optionally test, and persist account changes."""
458
+ if should_test and not self._run_connection_test_with_retry(api_url, api_key):
459
+ return
460
+
461
+ try:
462
+ store.add_account(name, api_url, api_key, overwrite=is_edit)
463
+ except AccountStoreError as exc:
464
+ self.console.print(f"[{ERROR_STYLE}]Save failed: {exc}[/]")
465
+ return
466
+ except Exception as exc:
467
+ self.console.print(f"[{ERROR_STYLE}]Unexpected error while saving: {exc}[/]")
468
+ return
469
+
470
+ self.console.print(f"[{SUCCESS_STYLE}]Account '{name}' saved.[/]")
471
+ if set_active:
472
+ try:
473
+ store.set_active_account(name)
474
+ except Exception as exc:
475
+ self.console.print(f"[{WARNING_STYLE}]Account saved but could not set active: {exc}[/]")
476
+ else:
477
+ self._notify_account_switched(name)
478
+ self._announce_active_change(store, name)
479
+
480
+ def _notify_account_switched(self, name: str | None) -> None:
481
+ """Best-effort notify the hosting session that the active account changed."""
482
+ notify = getattr(self.session, "on_account_switched", None)
483
+ if callable(notify):
484
+ try:
485
+ notify(name)
486
+ except Exception: # pragma: no cover - best-effort callback
487
+ pass
488
+
489
+ def _confirm_delete_prompt(self, name: str) -> bool:
490
+ """Ask for delete confirmation; return True when confirmed."""
491
+ self.console.print(f"[{WARNING_STYLE}]Type '{name}' to confirm deletion. This cannot be undone.[/]")
492
+ while True: # pragma: no cover - interactive prompt loop
493
+ confirmation = Prompt.ask("Confirm name (or blank to cancel)", default="")
494
+ if confirmation is None or not confirmation.strip():
495
+ self.console.print(f"[{WARNING_STYLE}]Deletion cancelled.[/]")
496
+ return False
497
+ if confirmation.strip() != name:
498
+ self.console.print(f"[{WARNING_STYLE}]Name does not match; type '{name}' to confirm.[/]")
499
+ continue
500
+ return True
501
+
502
+ def _delete_account_and_notify(self, store: AccountStore, name: str, active_before: str | None) -> None:
503
+ """Remove account with error handling and announce active change."""
504
+ try:
505
+ store.remove_account(name)
506
+ except AccountStoreError as exc:
507
+ self.console.print(f"[{ERROR_STYLE}]Delete failed: {exc}[/]")
508
+ return
509
+ except Exception as exc:
510
+ self.console.print(f"[{ERROR_STYLE}]Unexpected error while deleting: {exc}[/]")
511
+ return
512
+
513
+ self.console.print(f"[{SUCCESS_STYLE}]Account '{name}' deleted.[/]")
514
+ # Announce active account change if it changed
515
+ active_after = store.get_active_account()
516
+ if active_after is not None and active_after != active_before:
517
+ self._announce_active_change(store, active_after)
518
+ elif active_after is None and active_before == name:
519
+ self.console.print(f"[{WARNING_STYLE}]No account is currently active. Select an account to activate it.[/]")
520
+
521
+ def _rich_delete_flow(self, store: AccountStore) -> None:
522
+ """Run Rich delete prompts with name confirmation."""
523
+ name = self._prompt_account_name(store, for_edit=True)
524
+ if not name:
525
+ return
526
+
527
+ # Check if this is the last remaining account before prompting for confirmation
528
+ accounts = store.list_accounts()
529
+ if len(accounts) <= 1 and name in accounts:
530
+ self.console.print(f"[{WARNING_STYLE}]Cannot remove the last remaining account.[/]")
531
+ return
532
+
533
+ if not self._confirm_delete_prompt(name):
534
+ return
535
+
536
+ # Re-check after confirmation prompt (race condition guard)
537
+ accounts = store.list_accounts()
538
+ if len(accounts) <= 1 and name in accounts:
539
+ self.console.print(f"[{WARNING_STYLE}]Cannot remove the last remaining account.[/]")
540
+ return
541
+
542
+ active_before = store.get_active_account()
543
+ self._delete_account_and_notify(store, name, active_before)
544
+
545
+ def _format_connection_failure(self, code: str, detail: str, api_url: str) -> str:
546
+ """Build a user-facing connection failure message."""
547
+ detail_suffix = f": {detail}" if detail else ""
548
+ if code == "connection_failed":
549
+ return f"Connection test failed: cannot reach {api_url}{detail_suffix}"
550
+ if code == "api_failed":
551
+ return f"Connection test failed: API error{detail_suffix}"
552
+ return f"Connection test failed{detail_suffix}"
553
+
554
+ def _run_connection_test_with_retry(self, api_url: str, api_key: str) -> bool:
555
+ """Run connection test with retry/skip prompts."""
556
+ skip_prompt_shown = False
557
+ while True:
558
+ ok, reason = check_connection_with_reason(api_url, api_key, abort_on_error=False)
559
+ if ok:
560
+ return True
561
+ code, detail = self._parse_error_reason(reason)
562
+ message = self._format_connection_failure(code, detail, api_url)
563
+ self.console.print(f"[{WARNING_STYLE}]{message}[/]")
564
+ retry = self._prompt_yes_no("Retry connection test?", default=True)
565
+ if retry:
566
+ continue
567
+ if not skip_prompt_shown:
568
+ skip_prompt_shown = True
569
+ skip = self._prompt_yes_no("Skip connection test and save?", default=False)
570
+ if skip:
571
+ return True
572
+ self.console.print(f"[{WARNING_STYLE}]Cancelled save after failed connection test.[/]")
573
+ return False
574
+
575
+ def _announce_active_change(self, store: AccountStore, name: str) -> None:
576
+ """Print active account change announcement."""
577
+ account = store.get_account(name) or {}
578
+ host = account.get("api_url", "")
579
+ host_suffix = f" • {host}" if host else ""
580
+ self.console.print(f"[{SUCCESS_STYLE}]Active account ➜ {name}{host_suffix}[/]")
@@ -0,0 +1,75 @@
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
+ import os
10
+ from typing import Any
11
+
12
+ from glaip_sdk.cli.masking import mask_api_key_display
13
+
14
+
15
+ def build_account_status_string(row: dict[str, Any], *, use_markup: bool = False) -> str:
16
+ """Build status string for an account row (active/env-lock).
17
+
18
+ When `use_markup` is True, returns Rich markup strings for Textual/Rich rendering;
19
+ when False, returns plain text for console output.
20
+
21
+ Example:
22
+ build_account_status_string({"active": True, "env_lock": True}, use_markup=True)
23
+ returns "[bold green]● active[/] · [yellow]🔒 env-lock[/]"
24
+ use_markup=False returns "● active · 🔒 env-lock"
25
+ """
26
+ status_parts: list[str] = []
27
+ if row.get("active"):
28
+ status_parts.append("[bold green]● active[/]" if use_markup else "● active")
29
+ if row.get("env_lock"):
30
+ status_parts.append("[yellow]🔒 env-lock[/]" if use_markup else "🔒 env-lock")
31
+ return " · ".join(status_parts)
32
+
33
+
34
+ def env_credentials_present(*, partial: bool = False) -> bool:
35
+ """Return True when env credentials are present.
36
+
37
+ Args:
38
+ partial: When True, treat either AIP_API_URL or AIP_API_KEY as present
39
+ (used by UIs that should lock on any env override). When False,
40
+ require both to be non-empty (used for context display).
41
+ """
42
+ api_url = (os.getenv("AIP_API_URL") or "").strip()
43
+ api_key = (os.getenv("AIP_API_KEY") or "").strip()
44
+ if partial:
45
+ return bool(api_url or api_key)
46
+ return bool(api_url and api_key)
47
+
48
+
49
+ def build_account_rows(
50
+ accounts: dict[str, dict[str, str]],
51
+ active_account: str | None,
52
+ env_lock: bool,
53
+ ) -> list[dict[str, str | bool]]:
54
+ """Build account rows for display from accounts dict.
55
+
56
+ Args:
57
+ accounts: Dictionary mapping account names to account data.
58
+ active_account: Name of the currently active account.
59
+ env_lock: Whether environment credentials are locking account switching.
60
+
61
+ Returns:
62
+ List of account row dictionaries with name, api_url, masked_key, active, and env_lock.
63
+ """
64
+ rows: list[dict[str, str | bool]] = []
65
+ for name, account in sorted(accounts.items()):
66
+ rows.append(
67
+ {
68
+ "name": name,
69
+ "api_url": account.get("api_url", ""),
70
+ "masked_key": mask_api_key_display(account.get("api_key", "")),
71
+ "active": name == active_account,
72
+ "env_lock": env_lock,
73
+ }
74
+ )
75
+ return rows