glaip-sdk 0.4.0__py3-none-any.whl → 0.5.1__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.
@@ -0,0 +1,414 @@
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
+
11
+ import click
12
+ from rich.console import Console
13
+ from rich.text import Text
14
+
15
+ from glaip_sdk.branding import (
16
+ ACCENT_STYLE,
17
+ ERROR_STYLE,
18
+ INFO,
19
+ NEUTRAL,
20
+ SUCCESS,
21
+ SUCCESS_STYLE,
22
+ WARNING_STYLE,
23
+ )
24
+ from glaip_sdk.cli.account_store import (
25
+ AccountNotFoundError,
26
+ AccountStore,
27
+ AccountStoreError,
28
+ InvalidAccountNameError,
29
+ get_account_store,
30
+ )
31
+ from glaip_sdk.cli.commands.common_config import check_connection, render_branding_header
32
+ from glaip_sdk.cli.hints import format_command_hint
33
+ from glaip_sdk.cli.masking import mask_api_key_display
34
+ from glaip_sdk.cli.utils import command_hint
35
+ from glaip_sdk.icons import ICON_TOOL
36
+ from glaip_sdk.rich_components import AIPPanel, AIPTable
37
+
38
+ console = Console()
39
+
40
+
41
+ @click.group()
42
+ def accounts_group() -> None:
43
+ """Manage multiple account profiles."""
44
+
45
+
46
+ _mask_api_key = mask_api_key_display
47
+
48
+
49
+ def _print_active_account_footer(store: AccountStore) -> None:
50
+ """Print footer showing active account."""
51
+ active = store.get_active_account()
52
+ if active:
53
+ account = store.get_account(active)
54
+ if account:
55
+ url = account.get("api_url", "")
56
+ masked_key = _mask_api_key(account.get("api_key"))
57
+ console.print(f"\n[{SUCCESS_STYLE}]Active account[/]: {active} · {url} · {masked_key}")
58
+
59
+
60
+ @accounts_group.command("list")
61
+ @click.option("--json", "output_json", is_flag=True, help="Output in JSON format")
62
+ def list_accounts(output_json: bool) -> None:
63
+ """List all account profiles."""
64
+ store = get_account_store()
65
+ accounts = store.list_accounts()
66
+ active_account = store.get_active_account()
67
+
68
+ if output_json:
69
+ accounts_list = []
70
+ for name, account in accounts.items():
71
+ accounts_list.append(
72
+ {
73
+ "name": name,
74
+ "api_url": account.get("api_url", ""),
75
+ "has_key": bool(account.get("api_key")),
76
+ "active": name == active_account,
77
+ },
78
+ )
79
+ click.echo(json.dumps(accounts_list, indent=2))
80
+ return
81
+
82
+ if not accounts:
83
+ console.print(f"[{WARNING_STYLE}]No accounts found.[/]")
84
+ hint = command_hint("accounts add", slash_command="login")
85
+ if hint:
86
+ console.print(f"Run {format_command_hint(hint) or hint} to add an account.")
87
+ return
88
+
89
+ # Render table
90
+ table = AIPTable(title=f"{ICON_TOOL} AIP Accounts")
91
+ table.add_column("Name", style=INFO, width=20)
92
+ table.add_column("API URL", style=SUCCESS, width=40)
93
+ table.add_column("Key (masked)", style=NEUTRAL, width=20)
94
+ table.add_column("Status", style=SUCCESS_STYLE, width=10)
95
+
96
+ for name, account in sorted(accounts.items()):
97
+ url = account.get("api_url", "")
98
+ masked_key = _mask_api_key(account.get("api_key"))
99
+ is_active = name == active_account
100
+ status = "[bold green]●[/bold green] active" if is_active else ""
101
+
102
+ table.add_row(name, url, masked_key, status)
103
+
104
+ console.print(table)
105
+
106
+ if active_account:
107
+ console.print(f"\n[{SUCCESS_STYLE}]Active account[/]: {active_account}")
108
+
109
+
110
+ def _check_account_overwrite(name: str, store: AccountStore, overwrite: bool) -> dict[str, str] | None:
111
+ """Check if account exists and handle overwrite logic.
112
+
113
+ Args:
114
+ name: Account name.
115
+ store: Account store instance.
116
+ overwrite: Whether to allow overwrite.
117
+
118
+ Returns:
119
+ Existing account dict or None.
120
+
121
+ Raises:
122
+ click.Abort: If account exists and overwrite is False.
123
+ """
124
+ existing = store.get_account(name)
125
+ if existing and not overwrite:
126
+ console.print(f"[{WARNING_STYLE}]Account '{name}' already exists.[/] Use --yes to overwrite.")
127
+ raise click.Abort()
128
+ return existing
129
+
130
+
131
+ def _get_credentials_non_interactive(url: str, read_key_from_stdin: bool, name: str) -> tuple[str, str]:
132
+ """Get credentials in non-interactive mode.
133
+
134
+ Args:
135
+ url: API URL from flag.
136
+ read_key_from_stdin: Whether to read key from stdin.
137
+ name: Account name (for error messages).
138
+
139
+ Returns:
140
+ Tuple of (api_url, api_key).
141
+
142
+ Raises:
143
+ click.Abort: If stdin is required but not available, or if --key used without --url.
144
+ """
145
+ if read_key_from_stdin:
146
+ if not sys.stdin.isatty():
147
+ return url, sys.stdin.read().strip()
148
+ console.print(
149
+ f"[{ERROR_STYLE}]Error: --key requires stdin input. "
150
+ f"Use: cat key.txt | aip accounts add {name} --url {url} --key[/]",
151
+ )
152
+ raise click.Abort()
153
+ # URL provided, prompt for key
154
+ console.print(f"\n[{ACCENT_STYLE}]AIP API Key[/]:")
155
+ return url, getpass.getpass("> ")
156
+
157
+
158
+ def _get_credentials_interactive(read_key_from_stdin: bool, existing: dict[str, str] | None) -> tuple[str, str]:
159
+ """Get credentials in interactive mode.
160
+
161
+ Args:
162
+ read_key_from_stdin: Whether --key flag was used.
163
+ existing: Existing account data.
164
+
165
+ Returns:
166
+ Tuple of (api_url, api_key).
167
+
168
+ Raises:
169
+ click.Abort: If --key used without --url.
170
+ """
171
+ if read_key_from_stdin:
172
+ console.print(
173
+ f"[{ERROR_STYLE}]Error: --key requires --url. For non-interactive mode, provide both: --url <url> --key[/]",
174
+ )
175
+ raise click.Abort()
176
+ # Fully interactive
177
+ _render_configuration_header()
178
+ return _prompt_account_inputs(existing)
179
+
180
+
181
+ def _collect_account_credentials(
182
+ url: str | None,
183
+ read_key_from_stdin: bool,
184
+ name: str,
185
+ existing: dict[str, str] | None,
186
+ ) -> tuple[str, str]:
187
+ """Collect account credentials from various input methods.
188
+
189
+ Args:
190
+ url: Optional URL from flag.
191
+ read_key_from_stdin: Whether to read key from stdin.
192
+ name: Account name (for error messages).
193
+ existing: Existing account data.
194
+
195
+ Returns:
196
+ Tuple of (api_url, api_key).
197
+
198
+ Raises:
199
+ click.Abort: If credentials cannot be collected or are invalid.
200
+ """
201
+ if url and read_key_from_stdin:
202
+ # Non-interactive: URL from flag, key from stdin
203
+ api_url, api_key = _get_credentials_non_interactive(url, True, name)
204
+ elif url:
205
+ # URL provided, prompt for key
206
+ api_url, api_key = _get_credentials_non_interactive(url, False, name)
207
+ else:
208
+ # Fully interactive or error case
209
+ api_url, api_key = _get_credentials_interactive(read_key_from_stdin, existing)
210
+
211
+ if not api_url or not api_key:
212
+ console.print(f"[{ERROR_STYLE}]Error: Both API URL and API key are required.[/]")
213
+ raise click.Abort()
214
+ return api_url, api_key
215
+
216
+
217
+ @accounts_group.command("add")
218
+ @click.argument("name")
219
+ @click.option("--url", help="API URL (required for non-interactive mode)")
220
+ @click.option(
221
+ "--key",
222
+ "read_key_from_stdin",
223
+ is_flag=True,
224
+ help="Read API key from stdin (secure, for scripts). Requires --url.",
225
+ )
226
+ @click.option(
227
+ "--yes",
228
+ "overwrite",
229
+ is_flag=True,
230
+ help="Overwrite existing account without prompting",
231
+ )
232
+ def add_account(
233
+ name: str,
234
+ url: str | None,
235
+ read_key_from_stdin: bool,
236
+ overwrite: bool,
237
+ ) -> None:
238
+ """Add or update an account profile.
239
+
240
+ NAME is the account name (1-32 chars, alphanumeric, dash, underscore).
241
+
242
+ By default, this command runs interactively, prompting for API URL and key.
243
+ For non-interactive use, both --url and --key (stdin) are required.
244
+ """
245
+ store = get_account_store()
246
+
247
+ # Check account overwrite
248
+ existing = _check_account_overwrite(name, store, overwrite)
249
+
250
+ # Collect credentials
251
+ api_url, api_key = _collect_account_credentials(url, read_key_from_stdin, name, existing)
252
+
253
+ # Save account
254
+ try:
255
+ store.add_account(name, api_url, api_key, overwrite=True)
256
+ console.print(Text(f"✅ Account '{name}' saved successfully", style=SUCCESS_STYLE))
257
+ _print_active_account_footer(store)
258
+ except InvalidAccountNameError as e:
259
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
260
+ raise click.Abort() from e
261
+ except AccountStoreError as e:
262
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
263
+ raise click.Abort() from e
264
+
265
+
266
+ @accounts_group.command("use")
267
+ @click.argument("name")
268
+ def use_account(name: str) -> None:
269
+ """Switch to a different account profile."""
270
+ store = get_account_store()
271
+
272
+ try:
273
+ account = store.get_account(name)
274
+ if not account:
275
+ console.print(f"[{ERROR_STYLE}]Error: Account '{name}' not found.[/]")
276
+ raise click.Abort()
277
+
278
+ url = account.get("api_url", "")
279
+ masked_key = _mask_api_key(account.get("api_key"))
280
+ api_key = account.get("api_key", "")
281
+
282
+ if not url or not api_key:
283
+ console.print(
284
+ f"[{ERROR_STYLE}]Error: Account '{name}' is missing credentials. Re-run 'aip accounts add {name}'.[/]"
285
+ )
286
+ raise click.Abort()
287
+
288
+ # Always validate before switching
289
+ check_connection(url, api_key, console, abort_on_error=True)
290
+
291
+ store.set_active_account(name)
292
+
293
+ console.print(
294
+ AIPPanel(
295
+ f"[{SUCCESS_STYLE}]Active account ➜ {name}[/]\nAPI URL: {url}\nKey: {masked_key}",
296
+ title="✅ Account Switched",
297
+ border_style=SUCCESS,
298
+ ),
299
+ )
300
+ except click.Abort:
301
+ # check_connection already printed the failure context; just propagate
302
+ raise
303
+ except AccountNotFoundError as e:
304
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
305
+ raise click.Abort() from e
306
+ except Exception as e:
307
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
308
+ raise click.Abort() from e
309
+
310
+
311
+ @accounts_group.command("rename")
312
+ @click.argument("current_name")
313
+ @click.argument("new_name")
314
+ @click.option(
315
+ "--yes",
316
+ "overwrite",
317
+ is_flag=True,
318
+ help="Overwrite target account if it already exists",
319
+ )
320
+ def rename_account(current_name: str, new_name: str, overwrite: bool) -> None:
321
+ """Rename an account profile."""
322
+ store = get_account_store()
323
+
324
+ if current_name == new_name:
325
+ console.print(f"[{WARNING_STYLE}]Source and target names are the same; nothing to rename.[/]")
326
+ return
327
+
328
+ try:
329
+ if not store.get_account(current_name):
330
+ console.print(f"[{ERROR_STYLE}]Error: Account '{current_name}' not found.[/]")
331
+ raise click.Abort()
332
+
333
+ # Guard before calling store.rename_account to keep consistent messaging with add --yes
334
+ if store.get_account(new_name) and not overwrite:
335
+ console.print(f"[{WARNING_STYLE}]Account '{new_name}' already exists.[/] Use --yes to overwrite.")
336
+ raise click.Abort()
337
+
338
+ store.rename_account(current_name, new_name, overwrite=overwrite)
339
+ console.print(Text(f"✅ Account '{current_name}' renamed to '{new_name}'", style=SUCCESS_STYLE))
340
+ _print_active_account_footer(store)
341
+ except AccountStoreError as e:
342
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
343
+ raise click.Abort() from e
344
+ except Exception as e: # pragma: no cover - defensive catch-all
345
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
346
+ raise click.Abort() from e
347
+
348
+
349
+ @accounts_group.command("remove")
350
+ @click.argument("name")
351
+ @click.option("--yes", "force", is_flag=True, help="Skip confirmation prompt")
352
+ def remove_account(name: str, force: bool) -> None:
353
+ """Remove an account profile."""
354
+ store = get_account_store()
355
+
356
+ account = store.get_account(name)
357
+ if not account:
358
+ console.print(f"[{WARNING_STYLE}]Account '{name}' not found.[/]")
359
+ return
360
+
361
+ if not force:
362
+ console.print(f"[{WARNING_STYLE}]This will remove account '{name}'.[/]")
363
+ confirm = input("Are you sure? (y/N): ").strip().lower()
364
+ if confirm not in ["y", "yes"]:
365
+ console.print("Cancelled.")
366
+ return
367
+
368
+ try:
369
+ store.remove_account(name)
370
+ console.print(Text(f"✅ Account '{name}' removed", style=SUCCESS_STYLE))
371
+
372
+ # Show new active account if it changed
373
+ active = store.get_active_account()
374
+ if active:
375
+ console.print(f"[{SUCCESS_STYLE}]Active account is now: {active}[/]")
376
+ except AccountStoreError as e:
377
+ console.print(f"[{ERROR_STYLE}]Error: {e}[/]")
378
+ raise click.Abort() from e
379
+
380
+
381
+ def _render_configuration_header() -> None:
382
+ """Display the interactive configuration heading/banner."""
383
+ render_branding_header(console, "[bold]AIP Account Configuration[/bold]")
384
+
385
+
386
+ def _prompt_account_inputs(existing: dict[str, str] | None) -> tuple[str, str]:
387
+ """Interactively prompt for account credentials."""
388
+ console.print("\n[bold]Enter your AIP configuration:[/bold]")
389
+ if existing:
390
+ console.print("(Leave blank to keep current values)")
391
+ console.print("─" * 50)
392
+
393
+ # Prompt for URL
394
+ current_url = existing.get("api_url", "") if existing else ""
395
+ suffix = f"(current: {current_url})" if current_url else ""
396
+ console.print(f"\n[{ACCENT_STYLE}]AIP API URL[/] {suffix}:")
397
+ new_url = input("> ").strip()
398
+ api_url = new_url if new_url else current_url
399
+ if not api_url:
400
+ api_url = "https://your-aip-instance.com"
401
+
402
+ # Prompt for key
403
+ current_key_masked = _mask_api_key(existing.get("api_key")) if existing else ""
404
+ suffix = f"(current: {current_key_masked})" if current_key_masked else ""
405
+ console.print(f"\n[{ACCENT_STYLE}]AIP API Key[/] {suffix}:")
406
+ new_key = getpass.getpass("> ")
407
+ if new_key:
408
+ api_key = new_key
409
+ elif existing:
410
+ api_key = existing.get("api_key", "")
411
+ else:
412
+ api_key = ""
413
+
414
+ return api_url, api_key
@@ -34,8 +34,8 @@ from glaip_sdk.cli.agent_config import (
34
34
  from glaip_sdk.cli.agent_config import (
35
35
  sanitize_agent_config_for_cli as sanitize_agent_config,
36
36
  )
37
- from glaip_sdk.cli.context import get_ctx_value, output_flags
38
37
  from glaip_sdk.cli.constants import DEFAULT_AGENT_INSTRUCTION_PREVIEW_LIMIT
38
+ from glaip_sdk.cli.context import get_ctx_value, output_flags
39
39
  from glaip_sdk.cli.display import (
40
40
  build_resource_result_data,
41
41
  display_agent_run_suggestions,
@@ -47,6 +47,7 @@ from glaip_sdk.cli.display import (
47
47
  handle_rich_output,
48
48
  print_api_error,
49
49
  )
50
+ from glaip_sdk.cli.hints import in_slash_mode
50
51
  from glaip_sdk.cli.io import (
51
52
  fetch_raw_resource_details,
52
53
  )
@@ -59,7 +60,6 @@ from glaip_sdk.cli.transcript import (
59
60
  maybe_launch_post_run_viewer,
60
61
  store_transcript_for_session,
61
62
  )
62
- from glaip_sdk.cli.hints import in_slash_mode
63
63
  from glaip_sdk.cli.utils import (
64
64
  _fuzzy_pick_for_resources,
65
65
  build_renderer,
@@ -576,7 +576,10 @@ def list_agents(
576
576
  and len(agents) > 0
577
577
  )
578
578
 
579
+ # Track picker attempt so the fallback table doesn't re-open the palette
580
+ picker_attempted = False
579
581
  if interactive_enabled:
582
+ picker_attempted = True
580
583
  picked_agent = _fuzzy_pick_for_resources(agents, "agent", "")
581
584
  if picked_agent:
582
585
  _display_agent_details(ctx, client, picked_agent)
@@ -591,7 +594,9 @@ def list_agents(
591
594
  f"{ICON_AGENT} Available Agents",
592
595
  columns,
593
596
  transform_agent,
594
- skip_picker=simple or any(param is not None for param in (agent_type, framework, name, version)),
597
+ skip_picker=picker_attempted
598
+ or simple
599
+ or any(param is not None for param in (agent_type, framework, name, version)),
595
600
  use_pager=False,
596
601
  )
597
602
 
@@ -0,0 +1,65 @@
1
+ """Shared helpers for configuration/account flows."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.text import Text
6
+
7
+ from glaip_sdk import Client
8
+ from glaip_sdk.branding import PRIMARY, SUCCESS_STYLE, WARNING_STYLE, AIPBranding
9
+ from glaip_sdk.cli.utils import sdk_version
10
+
11
+
12
+ def render_branding_header(console: Console, rule_text: str) -> None:
13
+ """Render the standard CLI branding header with a custom rule text."""
14
+ branding = AIPBranding.create_from_sdk(sdk_version=sdk_version(), package_name="glaip-sdk")
15
+ heading = "[bold]>_ GDP Labs AI Agents Package (AIP CLI)[/bold]"
16
+ console.print(heading)
17
+ console.print()
18
+ console.print(branding.get_welcome_banner())
19
+ console.rule(rule_text, style=PRIMARY)
20
+
21
+
22
+ def check_connection(
23
+ api_url: str,
24
+ api_key: str,
25
+ console: Console,
26
+ *,
27
+ abort_on_error: bool = False,
28
+ extra_hint: str | None = None,
29
+ ) -> bool:
30
+ """Test connectivity and report results.
31
+
32
+ Returns True on success, False on handled failures. Raises click.Abort when
33
+ abort_on_error is True and a fatal error occurs.
34
+ """
35
+ console.print("\n🔌 Testing connection...")
36
+ client: Client | None = None
37
+ try:
38
+ # Import lazily so test patches targeting glaip_sdk.Client are honored
39
+ from importlib import import_module # noqa: PLC0415
40
+
41
+ client_module = import_module("glaip_sdk")
42
+ client = client_module.Client(api_url=api_url, api_key=api_key)
43
+ try:
44
+ agents = client.list_agents()
45
+ console.print(Text(f"✅ Connection successful! Found {len(agents)} agents", style=SUCCESS_STYLE))
46
+ return True
47
+ except Exception as exc: # pragma: no cover - API failures depend on network
48
+ console.print(Text(f"⚠️ Connection established but API call failed: {exc}", style=WARNING_STYLE))
49
+ console.print(" You may need to check your API permissions or network access")
50
+ if extra_hint:
51
+ console.print(extra_hint)
52
+ if abort_on_error:
53
+ raise click.Abort() from exc
54
+ return False
55
+ except Exception as exc:
56
+ console.print(Text(f"❌ Connection failed: {exc}"))
57
+ console.print(" Please check your API URL and key")
58
+ if extra_hint:
59
+ console.print(extra_hint)
60
+ if abort_on_error:
61
+ raise click.Abort() from exc
62
+ return False
63
+ finally:
64
+ if client is not None:
65
+ client.close()