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.
- glaip_sdk/__init__.py +5 -2
- glaip_sdk/_version.py +9 -0
- glaip_sdk/agents/__init__.py +27 -0
- glaip_sdk/agents/base.py +1090 -0
- glaip_sdk/branding.py +13 -0
- glaip_sdk/cli/account_store.py +540 -0
- glaip_sdk/cli/auth.py +254 -15
- glaip_sdk/cli/commands/__init__.py +2 -2
- glaip_sdk/cli/commands/accounts.py +746 -0
- glaip_sdk/cli/commands/agents.py +214 -74
- glaip_sdk/cli/commands/common_config.py +101 -0
- glaip_sdk/cli/commands/configure.py +729 -113
- glaip_sdk/cli/commands/mcps.py +241 -72
- glaip_sdk/cli/commands/models.py +11 -5
- glaip_sdk/cli/commands/tools.py +49 -57
- glaip_sdk/cli/commands/transcripts.py +755 -0
- glaip_sdk/cli/config.py +48 -4
- 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 +41 -20
- glaip_sdk/cli/hints.py +57 -0
- glaip_sdk/cli/io.py +6 -3
- glaip_sdk/cli/main.py +228 -119
- glaip_sdk/cli/masking.py +21 -33
- glaip_sdk/cli/pager.py +9 -10
- glaip_sdk/cli/parsers/__init__.py +1 -3
- glaip_sdk/cli/slash/__init__.py +0 -9
- glaip_sdk/cli/slash/accounts_controller.py +500 -0
- glaip_sdk/cli/slash/accounts_shared.py +75 -0
- glaip_sdk/cli/slash/agent_session.py +58 -20
- glaip_sdk/cli/slash/prompt.py +10 -0
- glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
- glaip_sdk/cli/slash/session.py +736 -134
- 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 +872 -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 +255 -44
- glaip_sdk/cli/transcript/capture.py +66 -1
- glaip_sdk/cli/transcript/history.py +815 -0
- glaip_sdk/cli/transcript/viewer.py +70 -463
- glaip_sdk/cli/update_notifier.py +14 -5
- glaip_sdk/cli/utils.py +243 -1258
- glaip_sdk/cli/validators.py +5 -6
- glaip_sdk/client/__init__.py +2 -1
- glaip_sdk/client/_agent_payloads.py +45 -9
- glaip_sdk/client/agent_runs.py +147 -0
- glaip_sdk/client/agents.py +287 -29
- glaip_sdk/client/base.py +1 -0
- glaip_sdk/client/main.py +19 -10
- glaip_sdk/client/mcps.py +122 -12
- glaip_sdk/client/run_rendering.py +133 -90
- glaip_sdk/client/shared.py +21 -0
- glaip_sdk/client/tools.py +153 -10
- glaip_sdk/config/constants.py +11 -0
- 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/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 +238 -0
- glaip_sdk/rich_components.py +58 -2
- glaip_sdk/tools/__init__.py +22 -0
- glaip_sdk/tools/base.py +435 -0
- glaip_sdk/utils/__init__.py +58 -12
- glaip_sdk/utils/bundler.py +267 -0
- glaip_sdk/utils/client.py +111 -0
- glaip_sdk/utils/client_utils.py +39 -7
- glaip_sdk/utils/datetime_helpers.py +58 -0
- glaip_sdk/utils/discovery.py +78 -0
- glaip_sdk/utils/display.py +23 -15
- glaip_sdk/utils/export.py +143 -0
- glaip_sdk/utils/general.py +0 -33
- glaip_sdk/utils/import_export.py +12 -7
- 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 +5 -30
- glaip_sdk/utils/rendering/layout/__init__.py +64 -0
- glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
- glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
- glaip_sdk/utils/rendering/layout/summary.py +74 -0
- glaip_sdk/utils/rendering/layout/transcript.py +606 -0
- glaip_sdk/utils/rendering/models.py +1 -0
- glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
- glaip_sdk/utils/rendering/renderer/base.py +241 -1434
- glaip_sdk/utils/rendering/renderer/config.py +1 -5
- glaip_sdk/utils/rendering/renderer/debug.py +26 -20
- glaip_sdk/utils/rendering/renderer/factory.py +138 -0
- glaip_sdk/utils/rendering/renderer/stream.py +4 -33
- glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
- glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
- 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/steps/__init__.py +34 -0
- glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
- 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 +25 -13
- glaip_sdk/utils/runtime_config.py +306 -0
- glaip_sdk/utils/serialization.py +18 -0
- glaip_sdk/utils/sync.py +142 -0
- glaip_sdk/utils/validation.py +16 -24
- {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.6.5b3.dist-info}/METADATA +39 -4
- glaip_sdk-0.6.5b3.dist-info/RECORD +145 -0
- {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.6.5b3.dist-info}/WHEEL +1 -1
- glaip_sdk/models.py +0 -240
- glaip_sdk-0.1.2.dist-info/RECORD +0 -82
- {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
|
-
"""
|
|
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
|
|
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
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
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"] =
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
if
|
|
256
|
+
pager_mode = (PAGER_MODE or "auto").lower()
|
|
257
|
+
if pager_mode in ("0", "off", "false"):
|
|
259
258
|
return False
|
|
260
|
-
if
|
|
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
|
glaip_sdk/cli/slash/__init__.py
CHANGED
|
@@ -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}[/]")
|