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