glaip-sdk 0.1.3__py3-none-any.whl → 0.6.19__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 (146) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1196 -0
  5. glaip_sdk/branding.py +13 -0
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/auth.py +254 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents.py +213 -73
  11. glaip_sdk/cli/commands/common_config.py +104 -0
  12. glaip_sdk/cli/commands/configure.py +729 -113
  13. glaip_sdk/cli/commands/mcps.py +241 -72
  14. glaip_sdk/cli/commands/models.py +11 -5
  15. glaip_sdk/cli/commands/tools.py +49 -57
  16. glaip_sdk/cli/commands/transcripts.py +755 -0
  17. glaip_sdk/cli/config.py +48 -4
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +8 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +35 -19
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +6 -3
  28. glaip_sdk/cli/main.py +241 -121
  29. glaip_sdk/cli/masking.py +21 -33
  30. glaip_sdk/cli/pager.py +9 -10
  31. glaip_sdk/cli/parsers/__init__.py +1 -3
  32. glaip_sdk/cli/slash/__init__.py +0 -9
  33. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  34. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  35. glaip_sdk/cli/slash/agent_session.py +62 -21
  36. glaip_sdk/cli/slash/prompt.py +21 -0
  37. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  38. glaip_sdk/cli/slash/session.py +771 -140
  39. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  40. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  41. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  42. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  43. glaip_sdk/cli/slash/tui/loading.py +58 -0
  44. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  45. glaip_sdk/cli/transcript/__init__.py +12 -52
  46. glaip_sdk/cli/transcript/cache.py +255 -44
  47. glaip_sdk/cli/transcript/capture.py +27 -1
  48. glaip_sdk/cli/transcript/history.py +815 -0
  49. glaip_sdk/cli/transcript/viewer.py +72 -499
  50. glaip_sdk/cli/update_notifier.py +14 -5
  51. glaip_sdk/cli/utils.py +243 -1252
  52. glaip_sdk/cli/validators.py +5 -6
  53. glaip_sdk/client/__init__.py +2 -1
  54. glaip_sdk/client/_agent_payloads.py +45 -9
  55. glaip_sdk/client/agent_runs.py +147 -0
  56. glaip_sdk/client/agents.py +291 -35
  57. glaip_sdk/client/base.py +1 -0
  58. glaip_sdk/client/main.py +19 -10
  59. glaip_sdk/client/mcps.py +122 -12
  60. glaip_sdk/client/run_rendering.py +466 -89
  61. glaip_sdk/client/shared.py +21 -0
  62. glaip_sdk/client/tools.py +155 -10
  63. glaip_sdk/config/constants.py +11 -0
  64. glaip_sdk/hitl/__init__.py +15 -0
  65. glaip_sdk/hitl/local.py +151 -0
  66. glaip_sdk/mcps/__init__.py +21 -0
  67. glaip_sdk/mcps/base.py +345 -0
  68. glaip_sdk/models/__init__.py +90 -0
  69. glaip_sdk/models/agent.py +47 -0
  70. glaip_sdk/models/agent_runs.py +116 -0
  71. glaip_sdk/models/common.py +42 -0
  72. glaip_sdk/models/mcp.py +33 -0
  73. glaip_sdk/models/tool.py +33 -0
  74. glaip_sdk/payload_schemas/__init__.py +1 -13
  75. glaip_sdk/registry/__init__.py +55 -0
  76. glaip_sdk/registry/agent.py +164 -0
  77. glaip_sdk/registry/base.py +139 -0
  78. glaip_sdk/registry/mcp.py +253 -0
  79. glaip_sdk/registry/tool.py +232 -0
  80. glaip_sdk/rich_components.py +58 -2
  81. glaip_sdk/runner/__init__.py +59 -0
  82. glaip_sdk/runner/base.py +84 -0
  83. glaip_sdk/runner/deps.py +112 -0
  84. glaip_sdk/runner/langgraph.py +870 -0
  85. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  86. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  87. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  88. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  89. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  90. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  91. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  92. glaip_sdk/tools/__init__.py +22 -0
  93. glaip_sdk/tools/base.py +435 -0
  94. glaip_sdk/utils/__init__.py +58 -12
  95. glaip_sdk/utils/a2a/__init__.py +34 -0
  96. glaip_sdk/utils/a2a/event_processor.py +188 -0
  97. glaip_sdk/utils/bundler.py +267 -0
  98. glaip_sdk/utils/client.py +111 -0
  99. glaip_sdk/utils/client_utils.py +39 -7
  100. glaip_sdk/utils/datetime_helpers.py +58 -0
  101. glaip_sdk/utils/discovery.py +78 -0
  102. glaip_sdk/utils/display.py +23 -15
  103. glaip_sdk/utils/export.py +143 -0
  104. glaip_sdk/utils/general.py +0 -33
  105. glaip_sdk/utils/import_export.py +12 -7
  106. glaip_sdk/utils/import_resolver.py +492 -0
  107. glaip_sdk/utils/instructions.py +101 -0
  108. glaip_sdk/utils/rendering/__init__.py +115 -1
  109. glaip_sdk/utils/rendering/formatting.py +5 -30
  110. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  111. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  112. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  113. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  114. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  115. glaip_sdk/utils/rendering/models.py +1 -0
  116. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  117. glaip_sdk/utils/rendering/renderer/base.py +275 -1476
  118. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  119. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  120. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  121. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  122. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  123. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  124. glaip_sdk/utils/rendering/state.py +204 -0
  125. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  126. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  127. glaip_sdk/utils/rendering/steps/format.py +176 -0
  128. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  129. glaip_sdk/utils/rendering/timing.py +36 -0
  130. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  131. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  132. glaip_sdk/utils/resource_refs.py +25 -13
  133. glaip_sdk/utils/runtime_config.py +425 -0
  134. glaip_sdk/utils/serialization.py +18 -0
  135. glaip_sdk/utils/sync.py +142 -0
  136. glaip_sdk/utils/tool_detection.py +33 -0
  137. glaip_sdk/utils/tool_storage_provider.py +140 -0
  138. glaip_sdk/utils/validation.py +16 -24
  139. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/METADATA +56 -21
  140. glaip_sdk-0.6.19.dist-info/RECORD +163 -0
  141. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/WHEEL +2 -1
  142. glaip_sdk-0.6.19.dist-info/entry_points.txt +2 -0
  143. glaip_sdk-0.6.19.dist-info/top_level.txt +1 -0
  144. glaip_sdk/models.py +0 -240
  145. glaip_sdk-0.1.3.dist-info/RECORD +0 -83
  146. glaip_sdk-0.1.3.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,746 @@
1
+ """Account management commands for multi-account profiles.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ import getpass
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import click
13
+ from rich.console import Console
14
+ from rich.text import Text
15
+
16
+ from glaip_sdk.branding import (
17
+ ACCENT_STYLE,
18
+ ERROR_STYLE,
19
+ INFO,
20
+ NEUTRAL,
21
+ SUCCESS,
22
+ SUCCESS_STYLE,
23
+ WARNING_STYLE,
24
+ )
25
+ from glaip_sdk.cli.account_store import (
26
+ AccountNotFoundError,
27
+ AccountStore,
28
+ AccountStoreError,
29
+ InvalidAccountNameError,
30
+ get_account_store,
31
+ )
32
+ from glaip_sdk.cli.commands.common_config import check_connection, render_branding_header
33
+ from glaip_sdk.cli.hints import format_command_hint
34
+ from glaip_sdk.cli.masking import mask_api_key_display
35
+ from glaip_sdk.cli.slash.accounts_shared import env_credentials_present
36
+ from glaip_sdk.cli.utils import command_hint
37
+ from glaip_sdk.icons import ICON_TOOL
38
+ from glaip_sdk.rich_components import AIPPanel, AIPTable
39
+
40
+ console = Console()
41
+
42
+
43
+ @click.group()
44
+ def accounts_group() -> None:
45
+ """Manage multiple account profiles."""
46
+
47
+
48
+ _mask_api_key = mask_api_key_display
49
+
50
+
51
+ def _print_active_account_footer(store: AccountStore) -> None:
52
+ """Print footer showing active account."""
53
+ active = store.get_active_account()
54
+ if active:
55
+ account = store.get_account(active)
56
+ if account:
57
+ url = account.get("api_url", "")
58
+ masked_key = _mask_api_key(account.get("api_key"))
59
+ console.print(f"\n[{SUCCESS_STYLE}]Active account[/]: {active} · {url} · {masked_key}")
60
+
61
+
62
+ @accounts_group.command("list")
63
+ @click.option("--json", "output_json", is_flag=True, help="Output in JSON format")
64
+ def list_accounts(output_json: bool) -> None:
65
+ """List all account profiles."""
66
+ store = get_account_store()
67
+ accounts = store.list_accounts()
68
+ active_account = store.get_active_account()
69
+
70
+ if output_json:
71
+ accounts_list = []
72
+ for name, account in accounts.items():
73
+ accounts_list.append(
74
+ {
75
+ "name": name,
76
+ "api_url": account.get("api_url", ""),
77
+ "has_key": bool(account.get("api_key")),
78
+ "active": name == active_account,
79
+ },
80
+ )
81
+ click.echo(json.dumps(accounts_list, indent=2))
82
+ return
83
+
84
+ if not accounts:
85
+ console.print(f"[{WARNING_STYLE}]No accounts found.[/]")
86
+ hint = command_hint("accounts add", slash_command="login")
87
+ if hint:
88
+ console.print(f"Run {format_command_hint(hint) or hint} to add an account.")
89
+ return
90
+
91
+ # Render table
92
+ table = AIPTable(title=f"{ICON_TOOL} AIP Accounts")
93
+ table.add_column("Name", style=INFO, width=20)
94
+ table.add_column("API URL", style=SUCCESS, width=40)
95
+ table.add_column("Key (masked)", style=NEUTRAL, width=20)
96
+ table.add_column("Status", style=SUCCESS_STYLE, width=10)
97
+
98
+ for name, account in sorted(accounts.items()):
99
+ url = account.get("api_url", "")
100
+ masked_key = _mask_api_key(account.get("api_key"))
101
+ is_active = name == active_account
102
+ status = "[bold green]●[/bold green] active" if is_active else ""
103
+
104
+ table.add_row(name, url, masked_key, status)
105
+
106
+ console.print(table)
107
+
108
+ if active_account:
109
+ console.print(f"\n[{SUCCESS_STYLE}]Active account[/]: {active_account}")
110
+
111
+ # Show hint for updating accounts
112
+ console.print(f"\n[{INFO}]💡 Tip[/]: To update an account's URL or key, use: [bold]aip accounts edit <name>[/bold]")
113
+
114
+
115
+ def _build_account_json_payload(
116
+ name: str,
117
+ api_url: str,
118
+ masked_key: str,
119
+ config_path: str,
120
+ is_active: bool,
121
+ env_lock: bool,
122
+ metadata: dict[str, str | None],
123
+ ) -> dict[str, str | bool | None]:
124
+ """Build JSON payload for account display.
125
+
126
+ Args:
127
+ name: Account name.
128
+ api_url: API URL.
129
+ masked_key: Masked API key.
130
+ config_path: Config file path.
131
+ is_active: Whether account is active.
132
+ env_lock: Whether env credentials are set.
133
+ metadata: Account metadata dict.
134
+
135
+ Returns:
136
+ JSON payload dict.
137
+ """
138
+ payload: dict[str, str | bool | None] = {
139
+ "name": name,
140
+ "api_url": api_url,
141
+ "api_key_masked": masked_key,
142
+ "config_path": config_path,
143
+ "active": is_active,
144
+ "env_lock": env_lock,
145
+ }
146
+ for key, value in metadata.items():
147
+ if value:
148
+ payload[key] = value
149
+ return payload
150
+
151
+
152
+ def _format_config_path(config_path: str) -> str:
153
+ """Format config path for display, shortening under home."""
154
+ path_obj = Path(config_path).expanduser()
155
+ try:
156
+ home = Path.home().expanduser()
157
+ resolved = path_obj.resolve(strict=False)
158
+ relative = resolved.relative_to(home).as_posix()
159
+ return f"~/{relative}"
160
+ except ValueError:
161
+ # Not under home; return expanded path
162
+ return str(path_obj)
163
+ except OSError:
164
+ # Fall back to original string on resolution errors
165
+ return config_path
166
+
167
+
168
+ def _build_account_display_lines(
169
+ name: str,
170
+ api_url: str,
171
+ masked_key: str,
172
+ config_path: str,
173
+ is_active: bool,
174
+ env_lock: bool,
175
+ metadata: dict[str, str | None],
176
+ ) -> list[str]:
177
+ """Build display lines for account information.
178
+
179
+ Args:
180
+ name: Account name.
181
+ api_url: API URL.
182
+ masked_key: Masked API key.
183
+ config_path: Config file path.
184
+ is_active: Whether account is active.
185
+ env_lock: Whether env credentials are set.
186
+ metadata: Account metadata dict.
187
+
188
+ Returns:
189
+ List of formatted display lines.
190
+ """
191
+ lines = [
192
+ f"[{SUCCESS_STYLE}]Name[/]: {name}{' (active)' if is_active else ''}",
193
+ f"[{SUCCESS_STYLE}]API URL[/]: {api_url or 'not set'}",
194
+ f"[{SUCCESS_STYLE}]Key[/]: {masked_key or 'not set'}",
195
+ f"[{SUCCESS_STYLE}]Config[/]: {config_path}",
196
+ ]
197
+
198
+ label_map = {
199
+ "notes": "Notes",
200
+ "last_used_at": "Last used",
201
+ "last_validated_at": "Last validated",
202
+ "created_with": "Created with",
203
+ }
204
+ for key, label in label_map.items():
205
+ value = metadata.get(key)
206
+ if value:
207
+ lines.append(f"[{SUCCESS_STYLE}]{label}[/]: {value}")
208
+
209
+ if env_lock:
210
+ lines.append(
211
+ f"[{WARNING_STYLE}]Env credentials detected (AIP_API_URL/AIP_API_KEY); stored profile may be ignored.[/]"
212
+ )
213
+
214
+ return lines
215
+
216
+
217
+ @accounts_group.command("show")
218
+ @click.argument("name")
219
+ @click.option("--json", "output_json", is_flag=True, help="Output in JSON format")
220
+ def show_account(name: str, output_json: bool) -> None:
221
+ """Show details for a single account profile."""
222
+ store = get_account_store()
223
+ account = store.get_account(name)
224
+
225
+ if not account:
226
+ console.print(f"[{ERROR_STYLE}]Error: Account '{name}' not found.[/]")
227
+ raise click.Abort()
228
+
229
+ api_url = account.get("api_url", "")
230
+ api_key = account.get("api_key")
231
+ masked_key = _mask_api_key(api_key or "")
232
+ active_account = store.get_active_account()
233
+ is_active = active_account == name
234
+ env_lock = env_credentials_present(partial=True)
235
+ config_path_raw = str(store.config_file)
236
+ config_path_display = _format_config_path(config_path_raw)
237
+
238
+ metadata = {
239
+ "notes": account.get("notes"),
240
+ "last_used_at": account.get("last_used_at"),
241
+ "last_validated_at": account.get("last_validated_at"),
242
+ "created_with": account.get("created_with"),
243
+ }
244
+
245
+ if output_json:
246
+ payload = _build_account_json_payload(name, api_url, masked_key, config_path_raw, is_active, env_lock, metadata)
247
+ click.echo(json.dumps(payload, indent=2))
248
+ return
249
+
250
+ lines = _build_account_display_lines(name, api_url, masked_key, config_path_display, is_active, env_lock, metadata)
251
+
252
+ lock_badge = " 🔒 Env lock" if env_lock else ""
253
+ console.print(
254
+ AIPPanel(
255
+ "\n".join(lines),
256
+ title=f"AIP Account{lock_badge}",
257
+ border_style=ACCENT_STYLE,
258
+ ),
259
+ )
260
+
261
+
262
+ def _check_account_overwrite(name: str, store: AccountStore, overwrite: bool) -> dict[str, str] | None:
263
+ """Check if account exists and handle overwrite logic.
264
+
265
+ Args:
266
+ name: Account name.
267
+ store: Account store instance.
268
+ overwrite: Whether to allow overwrite.
269
+
270
+ Returns:
271
+ Existing account dict or None.
272
+
273
+ Raises:
274
+ click.Abort: If account exists and overwrite is False.
275
+ """
276
+ existing = store.get_account(name)
277
+ if existing and not overwrite:
278
+ console.print(f"[{WARNING_STYLE}]Account '{name}' already exists.[/] Use --yes to overwrite.")
279
+ raise click.Abort()
280
+ return existing
281
+
282
+
283
+ def _get_credentials_non_interactive(
284
+ url: str,
285
+ read_key_from_stdin: bool,
286
+ name: str,
287
+ command_name: str = "aip accounts add",
288
+ ) -> tuple[str, str]:
289
+ """Get credentials in non-interactive mode.
290
+
291
+ Args:
292
+ url: API URL from flag.
293
+ read_key_from_stdin: Whether to read key from stdin.
294
+ name: Account name (for error messages).
295
+ command_name: Command name for guidance text.
296
+
297
+ Returns:
298
+ Tuple of (api_url, api_key).
299
+
300
+ Raises:
301
+ click.Abort: If stdin is required but not available, or if --key used without --url.
302
+ """
303
+ if read_key_from_stdin:
304
+ if not sys.stdin.isatty():
305
+ return url, sys.stdin.read().strip()
306
+ console.print(
307
+ f"[{ERROR_STYLE}]Error: --key expects stdin or an explicit value. "
308
+ f"Use '--key <value>' or pipe: cat key.txt | {command_name} {name} --url {url} --key[/]",
309
+ )
310
+ raise click.Abort()
311
+ # URL provided, prompt for key
312
+ console.print(f"\n[{ACCENT_STYLE}]AIP API Key[/]:")
313
+ return url, getpass.getpass("> ")
314
+
315
+
316
+ def _get_credentials_interactive(read_key_from_stdin: bool, existing: dict[str, str] | None) -> tuple[str, str]:
317
+ """Get credentials in interactive mode.
318
+
319
+ Args:
320
+ read_key_from_stdin: Whether --key flag was used.
321
+ existing: Existing account data.
322
+
323
+ Returns:
324
+ Tuple of (api_url, api_key).
325
+
326
+ Raises:
327
+ click.Abort: If --key used without --url.
328
+ """
329
+ if read_key_from_stdin:
330
+ console.print(
331
+ f"[{ERROR_STYLE}]Error: --key requires --url. "
332
+ f"Provide --url with --key <value|-> for non-interactive use or omit --key to be prompted.[/]",
333
+ )
334
+ raise click.Abort()
335
+ # Fully interactive
336
+ _render_configuration_header()
337
+ return _prompt_account_inputs(existing)
338
+
339
+
340
+ def _handle_key_rotation(
341
+ name: str,
342
+ existing_url: str,
343
+ command_name: str,
344
+ ) -> tuple[str, str]:
345
+ """Handle key rotation using stored URL.
346
+
347
+ Args:
348
+ name: Account name (for error messages).
349
+ existing_url: Existing account URL.
350
+ command_name: Command name for error messages.
351
+
352
+ Returns:
353
+ Tuple of (api_url, api_key).
354
+
355
+ Raises:
356
+ click.Abort: If existing URL is missing.
357
+ """
358
+ if not existing_url:
359
+ console.print(f"[{ERROR_STYLE}]Error: Account '{name}' is missing an API URL. Provide --url to set it.[/]")
360
+ raise click.Abort()
361
+ return _get_credentials_non_interactive(existing_url, True, name, command_name)
362
+
363
+
364
+ def _preserve_existing_values(
365
+ api_url: str,
366
+ api_key: str,
367
+ existing_url: str,
368
+ existing_key: str,
369
+ ) -> tuple[str, str]:
370
+ """Preserve stored values when blank input is provided during edit.
371
+
372
+ Args:
373
+ api_url: Collected API URL.
374
+ api_key: Collected API key.
375
+ existing_url: Existing account URL.
376
+ existing_key: Existing account key.
377
+
378
+ Returns:
379
+ Tuple of (api_url, api_key) with preserved values.
380
+ """
381
+ if not api_url and existing_url:
382
+ api_url = existing_url
383
+ if not api_key and existing_key:
384
+ api_key = existing_key
385
+ return api_url, api_key
386
+
387
+
388
+ def _collect_credentials_from_inputs(
389
+ url: str | None,
390
+ api_key_input: str | None,
391
+ name: str,
392
+ existing: dict[str, str] | None,
393
+ command_name: str,
394
+ existing_url: str,
395
+ ) -> tuple[str, str]:
396
+ """Collect credentials based on input flags and existing data.
397
+
398
+ Args:
399
+ url: Optional URL from flag.
400
+ api_key_input: API key value from flag (or "-" when stdin requested).
401
+ name: Account name (for error messages).
402
+ existing: Existing account data.
403
+ command_name: Command name for error messages.
404
+ existing_url: Existing account URL.
405
+
406
+ Returns:
407
+ Tuple of (api_url, api_key).
408
+ """
409
+ provided_key = api_key_input if api_key_input not in (None, "-") else None
410
+ read_key_from_stdin = api_key_input == "-"
411
+
412
+ if provided_key and url:
413
+ # Fully non-interactive: URL and key provided via flags
414
+ return url, provided_key
415
+
416
+ if provided_key:
417
+ # Reuse stored URL if present; otherwise require --url
418
+ if existing_url:
419
+ return existing_url, provided_key
420
+ if existing:
421
+ console.print(
422
+ f"[{ERROR_STYLE}]Error: Account '{name}' is missing an API URL. "
423
+ f"Provide --url to set it when rotating the key.[/]"
424
+ )
425
+ else:
426
+ console.print(
427
+ f"[{ERROR_STYLE}]Error: --key requires --url for new accounts. "
428
+ f"Run without --key for prompts or pass both flags for non-interactive setup.[/]",
429
+ )
430
+ raise click.Abort()
431
+
432
+ if url and read_key_from_stdin:
433
+ # Non-interactive: URL from flag, key from stdin
434
+ return _get_credentials_non_interactive(url, True, name, command_name)
435
+ if url:
436
+ # URL provided, prompt for key
437
+ return _get_credentials_non_interactive(url, False, name, command_name)
438
+ if read_key_from_stdin and existing:
439
+ # Key rotation using stored URL
440
+ return _handle_key_rotation(name, existing_url, command_name)
441
+ # Fully interactive or error case
442
+ return _get_credentials_interactive(read_key_from_stdin, existing)
443
+
444
+
445
+ def _collect_account_credentials(
446
+ url: str | None,
447
+ api_key_input: str | None,
448
+ name: str,
449
+ existing: dict[str, str] | None,
450
+ ) -> tuple[str, str]:
451
+ """Collect account credentials from various input methods.
452
+
453
+ Examples:
454
+ # Inline key
455
+ aip accounts add prod --url https://api.example.com --key sk-abc123
456
+
457
+ # Stdin (useful for scripts)
458
+ echo "sk-abc123" | aip accounts add prod --url https://api.example.com --key
459
+
460
+ # Fully interactive
461
+ aip accounts add prod
462
+
463
+ Args:
464
+ url: Optional URL from flag.
465
+ api_key_input: API key value from flag (or "-" when stdin requested).
466
+ name: Account name (for error messages).
467
+ existing: Existing account data.
468
+
469
+ Returns:
470
+ Tuple of (api_url, api_key).
471
+
472
+ Raises:
473
+ click.Abort: If credentials cannot be collected or are invalid.
474
+ """
475
+ command_name = "aip accounts edit" if existing else "aip accounts add"
476
+ existing_url = existing.get("api_url", "") if existing else ""
477
+ existing_key = existing.get("api_key", "") if existing else ""
478
+
479
+ api_url, api_key = _collect_credentials_from_inputs(url, api_key_input, name, existing, command_name, existing_url)
480
+
481
+ # Preserve stored values when blank input is provided during edit
482
+ api_url, api_key = _preserve_existing_values(api_url, api_key, existing_url, existing_key)
483
+
484
+ if not api_url or not api_key:
485
+ console.print(f"[{ERROR_STYLE}]Error: Both API URL and API key are required.[/]")
486
+ raise click.Abort()
487
+ return api_url, api_key
488
+
489
+
490
+ @accounts_group.command("add")
491
+ @click.argument("name")
492
+ @click.option("--url", help="API URL (required for non-interactive mode)")
493
+ @click.option(
494
+ "--key",
495
+ "api_key_input",
496
+ type=str,
497
+ is_flag=False,
498
+ flag_value="-",
499
+ default=None,
500
+ help="API key value. Pass without a value or '-' to read from stdin. Requires --url for non-interactive use.",
501
+ )
502
+ @click.option(
503
+ "--yes",
504
+ "overwrite",
505
+ is_flag=True,
506
+ help="Overwrite existing account without prompting",
507
+ )
508
+ def add_account(
509
+ name: str,
510
+ url: str | None,
511
+ api_key_input: str | None,
512
+ overwrite: bool,
513
+ ) -> None:
514
+ """Add a new account profile.
515
+
516
+ NAME is the account name (1-32 chars, alphanumeric, dash, underscore).
517
+
518
+ By default, this command runs interactively, prompting for API URL and key.
519
+ For non-interactive use, provide --url with --key <value> or --key - (stdin).
520
+
521
+ If the account already exists, use --yes to overwrite without prompting.
522
+ To update an existing account, use [bold]aip accounts edit <name>[/bold] instead.
523
+ """
524
+ store = get_account_store()
525
+
526
+ # Check account overwrite
527
+ existing = _check_account_overwrite(name, store, overwrite)
528
+
529
+ # Collect credentials
530
+ api_url, api_key = _collect_account_credentials(url, api_key_input, name, existing)
531
+
532
+ # Save account
533
+ try:
534
+ store.add_account(name, api_url, api_key, overwrite=True)
535
+ console.print(Text(f"✅ Account '{name}' saved successfully", style=SUCCESS_STYLE))
536
+ _print_active_account_footer(store)
537
+ except InvalidAccountNameError as e:
538
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
539
+ raise click.Abort() from e
540
+ except AccountStoreError as e:
541
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
542
+ raise click.Abort() from e
543
+
544
+
545
+ @accounts_group.command("edit")
546
+ @click.argument("name")
547
+ @click.option("--url", help="API URL (optional, leave blank to keep current)")
548
+ @click.option(
549
+ "--key",
550
+ "api_key_input",
551
+ type=str,
552
+ is_flag=False,
553
+ flag_value="-",
554
+ default=None,
555
+ help="API key value. Pass without a value or '-' to read from stdin. Uses stored URL unless --url is provided.",
556
+ )
557
+ def edit_account(
558
+ name: str,
559
+ url: str | None,
560
+ api_key_input: str | None,
561
+ ) -> None:
562
+ """Edit an existing account profile's URL or key.
563
+
564
+ NAME is the account name to edit.
565
+
566
+ By default, this command runs interactively, showing current values and
567
+ prompting for new ones. Leave fields blank to keep current values.
568
+
569
+ For non-interactive use, provide --url to change the URL, --key <value> to rotate the key,
570
+ or --key - (stdin) for scripts. Stored values are reused for any fields not provided.
571
+ """
572
+ store = get_account_store()
573
+
574
+ # Account must exist for edit
575
+ existing = store.get_account(name)
576
+ if not existing:
577
+ console.print(f"[{ERROR_STYLE}]Error: Account '{name}' not found.[/]")
578
+ console.print(f"Use [bold]aip accounts add {name}[/bold] to create a new account.")
579
+ raise click.Abort()
580
+
581
+ # Collect credentials (will pre-fill existing values in interactive mode)
582
+ api_url, api_key = _collect_account_credentials(url, api_key_input, name, existing)
583
+
584
+ # Save account
585
+ try:
586
+ store.add_account(name, api_url, api_key, overwrite=True)
587
+ console.print(Text(f"✅ Account '{name}' updated successfully", style=SUCCESS_STYLE))
588
+ _print_active_account_footer(store)
589
+ except InvalidAccountNameError as e:
590
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
591
+ raise click.Abort() from e
592
+ except AccountStoreError as e:
593
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
594
+ raise click.Abort() from e
595
+
596
+
597
+ @accounts_group.command("use")
598
+ @click.argument("name")
599
+ def use_account(name: str) -> None:
600
+ """Switch to a different account profile."""
601
+ store = get_account_store()
602
+
603
+ try:
604
+ account = store.get_account(name)
605
+ if not account:
606
+ console.print(f"[{ERROR_STYLE}]Error: Account '{name}' not found.[/]")
607
+ raise click.Abort()
608
+
609
+ url = account.get("api_url", "")
610
+ masked_key = _mask_api_key(account.get("api_key"))
611
+ api_key = account.get("api_key", "")
612
+
613
+ if not url or not api_key:
614
+ console.print(
615
+ f"[{ERROR_STYLE}]Error: Account '{name}' is missing credentials. "
616
+ f"Use [bold]aip accounts edit {name}[/bold] to update credentials.[/]"
617
+ )
618
+ raise click.Abort()
619
+
620
+ # Always validate before switching
621
+ check_connection(url, api_key, console, abort_on_error=True)
622
+
623
+ store.set_active_account(name)
624
+
625
+ console.print(
626
+ AIPPanel(
627
+ f"[{SUCCESS_STYLE}]Active account ➜ {name}[/]\nAPI URL: {url}\nKey: {masked_key}",
628
+ title="✅ Account Switched",
629
+ border_style=SUCCESS,
630
+ ),
631
+ )
632
+ except click.Abort:
633
+ # check_connection already printed the failure context; just propagate
634
+ raise
635
+ except AccountNotFoundError as e:
636
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
637
+ raise click.Abort() from e
638
+ except Exception as e:
639
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
640
+ raise click.Abort() from e
641
+
642
+
643
+ @accounts_group.command("rename")
644
+ @click.argument("current_name")
645
+ @click.argument("new_name")
646
+ @click.option(
647
+ "--yes",
648
+ "overwrite",
649
+ is_flag=True,
650
+ help="Overwrite target account if it already exists",
651
+ )
652
+ def rename_account(current_name: str, new_name: str, overwrite: bool) -> None:
653
+ """Rename an account profile."""
654
+ store = get_account_store()
655
+
656
+ if current_name == new_name:
657
+ console.print(f"[{WARNING_STYLE}]Source and target names are the same; nothing to rename.[/]")
658
+ return
659
+
660
+ try:
661
+ if not store.get_account(current_name):
662
+ console.print(f"[{ERROR_STYLE}]Error: Account '{current_name}' not found.[/]")
663
+ raise click.Abort()
664
+
665
+ # Guard before calling store.rename_account to keep consistent messaging with add --yes
666
+ if store.get_account(new_name) and not overwrite:
667
+ console.print(f"[{WARNING_STYLE}]Account '{new_name}' already exists.[/] Use --yes to overwrite.")
668
+ raise click.Abort()
669
+
670
+ store.rename_account(current_name, new_name, overwrite=overwrite)
671
+ console.print(Text(f"✅ Account '{current_name}' renamed to '{new_name}'", style=SUCCESS_STYLE))
672
+ _print_active_account_footer(store)
673
+ except AccountStoreError as e:
674
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
675
+ raise click.Abort() from e
676
+ except Exception as e: # pragma: no cover - defensive catch-all
677
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
678
+ raise click.Abort() from e
679
+
680
+
681
+ @accounts_group.command("remove")
682
+ @click.argument("name")
683
+ @click.option("--yes", "force", is_flag=True, help="Skip confirmation prompt")
684
+ def remove_account(name: str, force: bool) -> None:
685
+ """Remove an account profile."""
686
+ store = get_account_store()
687
+
688
+ account = store.get_account(name)
689
+ if not account:
690
+ console.print(f"[{WARNING_STYLE}]Account '{name}' not found.[/]")
691
+ return
692
+
693
+ if not force:
694
+ console.print(f"[{WARNING_STYLE}]This will remove account '{name}'.[/]")
695
+ confirm = input("Are you sure? (y/N): ").strip().lower()
696
+ if confirm not in ["y", "yes"]:
697
+ console.print("Cancelled.")
698
+ return
699
+
700
+ try:
701
+ store.remove_account(name)
702
+ console.print(Text(f"✅ Account '{name}' removed", style=SUCCESS_STYLE))
703
+
704
+ # Show new active account if it changed
705
+ active = store.get_active_account()
706
+ if active:
707
+ console.print(f"[{SUCCESS_STYLE}]Active account is now: {active}[/]")
708
+ except AccountStoreError as e:
709
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
710
+ raise click.Abort() from e
711
+
712
+
713
+ def _render_configuration_header() -> None:
714
+ """Display the interactive configuration heading/banner."""
715
+ render_branding_header(console, "[bold]AIP Account Configuration[/bold]")
716
+
717
+
718
+ def _prompt_account_inputs(existing: dict[str, str] | None) -> tuple[str, str]:
719
+ """Interactively prompt for account credentials."""
720
+ console.print("\n[bold]Enter your AIP configuration:[/bold]")
721
+ if existing:
722
+ console.print("(Leave blank to keep current values)")
723
+ console.print("─" * 50)
724
+
725
+ # Prompt for URL
726
+ current_url = existing.get("api_url", "") if existing else ""
727
+ suffix = f"(current: {current_url})" if current_url else ""
728
+ console.print(f"\n[{ACCENT_STYLE}]AIP API URL[/] {suffix}:")
729
+ new_url = input("> ").strip()
730
+ api_url = new_url if new_url else current_url
731
+ if not api_url:
732
+ api_url = "https://your-aip-instance.com"
733
+
734
+ # Prompt for key
735
+ current_key_masked = _mask_api_key(existing.get("api_key")) if existing else ""
736
+ suffix = f"(current: {current_key_masked})" if current_key_masked else ""
737
+ console.print(f"\n[{ACCENT_STYLE}]AIP API Key[/] {suffix}:")
738
+ new_key = getpass.getpass("> ")
739
+ if new_key:
740
+ api_key = new_key
741
+ elif existing:
742
+ api_key = existing.get("api_key", "")
743
+ else:
744
+ api_key = ""
745
+
746
+ return api_url, api_key