glaip-sdk 0.3.0__py3-none-any.whl → 0.5.0__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 (58) hide show
  1. glaip_sdk/cli/account_store.py +522 -0
  2. glaip_sdk/cli/auth.py +224 -8
  3. glaip_sdk/cli/commands/accounts.py +414 -0
  4. glaip_sdk/cli/commands/agents.py +2 -2
  5. glaip_sdk/cli/commands/common_config.py +65 -0
  6. glaip_sdk/cli/commands/configure.py +153 -87
  7. glaip_sdk/cli/commands/mcps.py +191 -44
  8. glaip_sdk/cli/commands/transcripts.py +1 -1
  9. glaip_sdk/cli/config.py +31 -3
  10. glaip_sdk/cli/display.py +1 -1
  11. glaip_sdk/cli/hints.py +57 -0
  12. glaip_sdk/cli/io.py +6 -3
  13. glaip_sdk/cli/main.py +181 -79
  14. glaip_sdk/cli/masking.py +14 -1
  15. glaip_sdk/cli/slash/agent_session.py +2 -1
  16. glaip_sdk/cli/slash/remote_runs_controller.py +1 -1
  17. glaip_sdk/cli/slash/session.py +11 -9
  18. glaip_sdk/cli/slash/tui/remote_runs_app.py +2 -3
  19. glaip_sdk/cli/transcript/capture.py +12 -18
  20. glaip_sdk/cli/transcript/viewer.py +13 -646
  21. glaip_sdk/cli/update_notifier.py +2 -1
  22. glaip_sdk/cli/utils.py +95 -139
  23. glaip_sdk/client/agents.py +2 -4
  24. glaip_sdk/client/main.py +2 -18
  25. glaip_sdk/client/mcps.py +11 -1
  26. glaip_sdk/client/run_rendering.py +90 -111
  27. glaip_sdk/client/shared.py +21 -0
  28. glaip_sdk/models.py +8 -7
  29. glaip_sdk/utils/display.py +23 -15
  30. glaip_sdk/utils/rendering/__init__.py +6 -13
  31. glaip_sdk/utils/rendering/formatting.py +5 -30
  32. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  33. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  34. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  35. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  36. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  37. glaip_sdk/utils/rendering/models.py +1 -0
  38. glaip_sdk/utils/rendering/renderer/__init__.py +10 -28
  39. glaip_sdk/utils/rendering/renderer/base.py +214 -1469
  40. glaip_sdk/utils/rendering/renderer/debug.py +24 -0
  41. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  42. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  43. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  44. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  45. glaip_sdk/utils/rendering/state.py +204 -0
  46. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  47. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  48. glaip_sdk/utils/rendering/steps/format.py +176 -0
  49. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  50. glaip_sdk/utils/rendering/timing.py +36 -0
  51. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  52. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  53. glaip_sdk/utils/validation.py +13 -21
  54. {glaip_sdk-0.3.0.dist-info → glaip_sdk-0.5.0.dist-info}/METADATA +1 -1
  55. glaip_sdk-0.5.0.dist-info/RECORD +113 -0
  56. glaip_sdk-0.3.0.dist-info/RECORD +0 -94
  57. {glaip_sdk-0.3.0.dist-info → glaip_sdk-0.5.0.dist-info}/WHEEL +0 -0
  58. {glaip_sdk-0.3.0.dist-info → glaip_sdk-0.5.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,522 @@
1
+ """Account store for managing multiple credential profiles.
2
+
3
+ This module provides the AccountStore class for managing multiple account profiles
4
+ with API URL and API key pairs, supporting migration from legacy single-profile configs.
5
+
6
+ Authors:
7
+ Raymond Christopher (raymond.christopher@gdplabs.id)
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ try:
17
+ import fcntl # type: ignore
18
+ except ImportError: # pragma: no cover - platform-specific
19
+ fcntl = None # type: ignore[assignment]
20
+
21
+ import yaml
22
+
23
+ from glaip_sdk.cli.config import CONFIG_FILE
24
+
25
+ # POSIX-only locking; Windows falls back to no-op so CLI can still import/run.
26
+ LOCKING_SUPPORTED: bool = fcntl is not None
27
+
28
+ # Account name validation: alphanumeric plus "-" or "_", 1-32 chars
29
+ ACCOUNT_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
30
+ CONFIG_VERSION = 2
31
+ # Toggle to stop mirroring top-level api_url/api_key after deprecation window
32
+ MIRROR_TOP_LEVEL_CREDS = True
33
+
34
+
35
+ class AccountStoreError(Exception):
36
+ """Base exception for account store operations."""
37
+
38
+
39
+ class InvalidAccountNameError(AccountStoreError):
40
+ """Raised when an account name doesn't match validation rules."""
41
+
42
+
43
+ class AccountNotFoundError(AccountStoreError):
44
+ """Raised when a requested account doesn't exist."""
45
+
46
+
47
+ class AccountStore:
48
+ """Manages multiple account profiles in versioned config.yaml.
49
+
50
+ Supports migration from legacy single-profile configs and provides
51
+ thread-safe operations with file locking.
52
+ """
53
+
54
+ def __init__(self, config_file: Path | None = None):
55
+ """Initialize the account store.
56
+
57
+ Args:
58
+ config_file: Optional path to config file (for testing).
59
+ Defaults to ~/.aip/config.yaml.
60
+ """
61
+ self.config_file = config_file or CONFIG_FILE
62
+ self.config_dir = self.config_file.parent
63
+ self.lock_file = self.config_file.with_name(f"{self.config_file.name}.lock")
64
+
65
+ def _ensure_config_dir(self) -> None:
66
+ """Ensure the config directory exists."""
67
+ self.config_dir.mkdir(parents=True, exist_ok=True)
68
+
69
+ def _acquire_lock(self, file_handle: Any) -> None:
70
+ """Acquire an exclusive lock on the config file.
71
+
72
+ Args:
73
+ file_handle: File handle to lock.
74
+
75
+ Raises:
76
+ AccountStoreError: If lock cannot be acquired.
77
+ """
78
+ if not LOCKING_SUPPORTED:
79
+ return
80
+
81
+ try:
82
+ fcntl.flock(file_handle.fileno(), fcntl.LOCK_EX)
83
+ except (OSError, AttributeError) as e:
84
+ raise AccountStoreError(f"Failed to acquire lock on config file: {e}") from e
85
+
86
+ def _release_lock(self, file_handle: Any) -> None:
87
+ """Release the lock on the config file.
88
+
89
+ Args:
90
+ file_handle: File handle to unlock.
91
+ """
92
+ if not LOCKING_SUPPORTED:
93
+ return
94
+
95
+ try:
96
+ fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
97
+ except (OSError, AttributeError):
98
+ # Lock release failures are non-fatal
99
+ pass
100
+
101
+ def _load_raw_config(self) -> dict[str, Any]:
102
+ """Load raw config file without migration or validation."""
103
+ if not self.config_file.exists():
104
+ return {}
105
+
106
+ self._ensure_config_dir()
107
+ lock_handle = None
108
+ lock_acquired = False
109
+ try:
110
+ lock_handle = open(self.lock_file, "a+", encoding="utf-8")
111
+ self._acquire_lock(lock_handle)
112
+ lock_acquired = True
113
+
114
+ with open(self.config_file, encoding="utf-8") as f:
115
+ return yaml.safe_load(f) or {}
116
+ except yaml.YAMLError as e:
117
+ raise AccountStoreError(f"Failed to parse config file: {e}") from e
118
+ finally:
119
+ if lock_handle:
120
+ if lock_acquired:
121
+ self._release_lock(lock_handle)
122
+ lock_handle.close()
123
+
124
+ def _save_config(self, config: dict[str, Any]) -> None:
125
+ """Atomically save config file with proper permissions.
126
+
127
+ Also mirrors active profile credentials to top-level api_url/api_key
128
+ for backward compatibility with older CLIs during deprecation window.
129
+
130
+ Args:
131
+ config: Configuration dictionary to save.
132
+ """
133
+ self._ensure_config_dir()
134
+
135
+ # Mirror active profile to top-level for backward compatibility
136
+ if MIRROR_TOP_LEVEL_CREDS:
137
+ active_account = config.get("active_account")
138
+ accounts = config.get("accounts", {})
139
+ if active_account and active_account in accounts:
140
+ account = accounts[active_account]
141
+ config["api_url"] = account.get("api_url", "")
142
+ config["api_key"] = account.get("api_key", "")
143
+ else:
144
+ # Clear top-level creds if no active account
145
+ config.pop("api_url", None)
146
+ config.pop("api_key", None)
147
+
148
+ # Atomic write: write to temp file, then replace with lock held
149
+ tmp_path = self.config_file.with_name(f"{self.config_file.name}.tmp")
150
+ lock_handle = None
151
+ lock_acquired = False
152
+ try:
153
+ lock_handle = open(self.lock_file, "a+", encoding="utf-8")
154
+ self._acquire_lock(lock_handle)
155
+ lock_acquired = True
156
+
157
+ with open(tmp_path, "w", encoding="utf-8") as f:
158
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
159
+ tmp_path.replace(self.config_file)
160
+
161
+ # Set secure file permissions
162
+ try:
163
+ os.chmod(self.config_file, 0o600)
164
+ except OSError: # pragma: no cover - permission errors are expected in some environments
165
+ pass
166
+ except Exception as e:
167
+ # Clean up temp file on error
168
+ if tmp_path.exists():
169
+ tmp_path.unlink()
170
+ raise AccountStoreError(f"Failed to save config file: {e}") from e
171
+ finally:
172
+ if lock_handle:
173
+ if lock_acquired:
174
+ self._release_lock(lock_handle)
175
+ lock_handle.close()
176
+
177
+ def _needs_migration(self, config: dict[str, Any]) -> bool:
178
+ """Check if config needs migration from legacy format.
179
+
180
+ Args:
181
+ config: Raw config dictionary.
182
+
183
+ Returns:
184
+ True if migration is needed.
185
+ """
186
+ return "version" not in config
187
+
188
+ def _load_auth_json_credentials(self, api_url: str | None, api_key: str | None) -> tuple[str | None, str | None]:
189
+ """Load credentials from auth.json if missing.
190
+
191
+ Args:
192
+ api_url: Existing API URL or None.
193
+ api_key: Existing API key or None.
194
+
195
+ Returns:
196
+ Tuple of (api_url, api_key) with values from auth.json if missing.
197
+ """
198
+ auth_json_path = self.config_dir / "auth.json"
199
+ if (not api_url or not api_key) and auth_json_path.exists():
200
+ try:
201
+ with open(auth_json_path, encoding="utf-8") as f:
202
+ auth_data = json.load(f)
203
+ if not api_url:
204
+ api_url = auth_data.get("api_url") or api_url
205
+ if not api_key:
206
+ api_key = auth_data.get("api_key") or api_key
207
+ except (json.JSONDecodeError, OSError):
208
+ # Ignore errors reading auth.json
209
+ pass
210
+ return api_url, api_key
211
+
212
+ def _create_default_account(self, api_url: str | None, api_key: str | None) -> dict[str, dict[str, str]]:
213
+ """Create default account from legacy credentials.
214
+
215
+ Args:
216
+ api_url: API URL or None.
217
+ api_key: API key or None.
218
+
219
+ Returns:
220
+ Dictionary with "default" account if credentials exist, empty dict otherwise.
221
+ """
222
+ accounts = {}
223
+ if api_url or api_key:
224
+ accounts["default"] = {
225
+ "api_url": api_url or "",
226
+ "api_key": api_key or "",
227
+ }
228
+ return accounts
229
+
230
+ def _preserve_legacy_keys(self, config: dict[str, Any]) -> dict[str, Any]:
231
+ """Preserve legacy top-level keys for backward compatibility.
232
+
233
+ Args:
234
+ config: Legacy config dictionary.
235
+
236
+ Returns:
237
+ Dictionary with preserved keys.
238
+ """
239
+ preserved = {}
240
+ for key in ["timeout", "history_default_limit"]:
241
+ if key in config:
242
+ preserved[key] = config[key]
243
+ return preserved
244
+
245
+ def _migrate_legacy_config(self, config: dict[str, Any]) -> dict[str, Any]:
246
+ """Migrate legacy config to versioned structure.
247
+
248
+ Args:
249
+ config: Legacy config dictionary.
250
+
251
+ Returns:
252
+ Migrated config dictionary.
253
+ """
254
+ migrated = {
255
+ "version": CONFIG_VERSION,
256
+ "active_account": "default",
257
+ "accounts": {},
258
+ }
259
+
260
+ # Extract legacy api_url and api_key
261
+ api_url = config.get("api_url")
262
+ api_key = config.get("api_key")
263
+
264
+ # Check for auth.json from secure login MVP (only during migration)
265
+ api_url, api_key = self._load_auth_json_credentials(api_url, api_key)
266
+
267
+ # Create default account if we have credentials
268
+ migrated["accounts"] = self._create_default_account(api_url, api_key)
269
+
270
+ # Preserve other top-level keys for backward compatibility
271
+ migrated.update(self._preserve_legacy_keys(config))
272
+
273
+ return migrated
274
+
275
+ def _ensure_migrated(self) -> None:
276
+ """Ensure config is migrated to versioned structure.
277
+
278
+ This should be called before any account operations to ensure
279
+ the config file is in the correct format.
280
+ """
281
+ config = self._load_raw_config()
282
+
283
+ if self._needs_migration(config):
284
+ migrated = self._migrate_legacy_config(config)
285
+ try:
286
+ self._save_config(migrated)
287
+ except AccountStoreError:
288
+ # Gracefully skip migration when persistence is blocked (e.g., mocked I/O in tests)
289
+ return
290
+
291
+ def load_config(self) -> dict[str, Any]:
292
+ """Load config with automatic migration.
293
+
294
+ Returns:
295
+ Versioned config dictionary.
296
+ """
297
+ self._ensure_migrated()
298
+ return self._load_raw_config()
299
+
300
+ def validate_account_name(self, name: str) -> None:
301
+ """Validate an account name.
302
+
303
+ Args:
304
+ name: Account name to validate.
305
+
306
+ Raises:
307
+ InvalidAccountNameError: If name is invalid.
308
+ """
309
+ if not name:
310
+ raise InvalidAccountNameError("Account name cannot be empty")
311
+ if not ACCOUNT_NAME_PATTERN.match(name):
312
+ raise InvalidAccountNameError(
313
+ f"Invalid account name '{name}'. Must be 1-32 characters, alphanumeric, dash, or underscore."
314
+ )
315
+
316
+ def list_accounts(self) -> dict[str, dict[str, str]]:
317
+ """List all account profiles.
318
+
319
+ Returns:
320
+ Dictionary mapping account names to their profiles.
321
+ """
322
+ config = self.load_config()
323
+ return config.get("accounts", {}).copy()
324
+
325
+ def get_account(self, name: str) -> dict[str, str] | None:
326
+ """Get a specific account profile.
327
+
328
+ Args:
329
+ name: Account name.
330
+
331
+ Returns:
332
+ Account profile dictionary with api_url and api_key, or None if not found.
333
+ """
334
+ accounts = self.list_accounts()
335
+ return accounts.get(name)
336
+
337
+ def get_active_account(self) -> str | None:
338
+ """Get the name of the active account.
339
+
340
+ Returns:
341
+ Active account name, or None if not set.
342
+ """
343
+ config = self.load_config()
344
+ return config.get("active_account")
345
+
346
+ def set_active_account(self, name: str) -> None:
347
+ """Set the active account.
348
+
349
+ Args:
350
+ name: Account name to activate.
351
+
352
+ Raises:
353
+ AccountNotFoundError: If account doesn't exist.
354
+ """
355
+ self.validate_account_name(name)
356
+
357
+ config = self.load_config()
358
+ accounts = config.get("accounts", {})
359
+
360
+ if name not in accounts:
361
+ raise AccountNotFoundError(f"Account '{name}' not found")
362
+
363
+ config["active_account"] = name
364
+ self._save_config(config)
365
+
366
+ def add_account(
367
+ self,
368
+ name: str,
369
+ api_url: str,
370
+ api_key: str,
371
+ *,
372
+ overwrite: bool = False,
373
+ ) -> None:
374
+ """Add or update an account profile.
375
+
376
+ Args:
377
+ name: Account name.
378
+ api_url: API URL for this account.
379
+ api_key: API key for this account.
380
+ overwrite: If True, overwrite existing account without prompting.
381
+
382
+ Raises:
383
+ InvalidAccountNameError: If name is invalid.
384
+ AccountStoreError: If account exists and overwrite is False.
385
+ """
386
+ self.validate_account_name(name)
387
+
388
+ config = self.load_config()
389
+ accounts = config.setdefault("accounts", {})
390
+
391
+ if name in accounts and not overwrite:
392
+ raise AccountStoreError(f"Account '{name}' already exists. Use --yes to overwrite.")
393
+
394
+ accounts[name] = {
395
+ "api_url": api_url,
396
+ "api_key": api_key,
397
+ }
398
+
399
+ # If this is the first account, make it active
400
+ if not config.get("active_account") and len(accounts) == 1:
401
+ config["active_account"] = name
402
+
403
+ self._save_config(config)
404
+
405
+ def remove_account(self, name: str) -> None:
406
+ """Remove an account profile.
407
+
408
+ Args:
409
+ name: Account name to remove.
410
+
411
+ Raises:
412
+ AccountNotFoundError: If account doesn't exist.
413
+ AccountStoreError: If trying to remove the last account.
414
+ """
415
+ config = self.load_config()
416
+ accounts = config.get("accounts", {})
417
+
418
+ if name not in accounts:
419
+ raise AccountNotFoundError(f"Account '{name}' not found")
420
+
421
+ if len(accounts) <= 1:
422
+ raise AccountStoreError("Cannot remove the last remaining account")
423
+
424
+ del accounts[name]
425
+
426
+ # If we removed the active account, switch to another
427
+ active_account = config.get("active_account")
428
+ if active_account == name:
429
+ # Try to switch to 'default' if it exists, otherwise first alphabetical
430
+ remaining_names = sorted(accounts.keys())
431
+ if "default" in remaining_names:
432
+ config["active_account"] = "default"
433
+ elif remaining_names:
434
+ config["active_account"] = remaining_names[0]
435
+ else: # pragma: no cover - defensive code, unreachable due to len check above
436
+ config.pop("active_account", None)
437
+
438
+ self._save_config(config)
439
+
440
+ def get_credentials(
441
+ self,
442
+ account_name: str | None = None,
443
+ ) -> tuple[str | None, str | None]:
444
+ """Get credentials for an account.
445
+
446
+ Args:
447
+ account_name: Account name, or None to use active account.
448
+
449
+ Returns:
450
+ Tuple of (api_url, api_key), or (None, None) if not found.
451
+ """
452
+ config = self.load_config()
453
+
454
+ # Determine which account to use
455
+ if account_name:
456
+ target_account = account_name
457
+ else:
458
+ target_account = config.get("active_account")
459
+
460
+ if not target_account:
461
+ return None, None
462
+
463
+ accounts = config.get("accounts", {})
464
+ account = accounts.get(target_account)
465
+
466
+ if not account:
467
+ return None, None
468
+
469
+ return account.get("api_url"), account.get("api_key")
470
+
471
+ def rename_account(self, current_name: str, new_name: str, *, overwrite: bool = False) -> None:
472
+ """Rename an existing account profile.
473
+
474
+ Args:
475
+ current_name: The existing account name.
476
+ new_name: The desired new account name.
477
+ overwrite: Whether to overwrite an existing target account.
478
+
479
+ Raises:
480
+ InvalidAccountNameError: If either name is invalid.
481
+ AccountNotFoundError: If the source account does not exist.
482
+ AccountStoreError: If the target exists and overwrite is False.
483
+ """
484
+ self.validate_account_name(current_name)
485
+ self.validate_account_name(new_name)
486
+
487
+ if current_name == new_name:
488
+ # No-op rename; keep behavior predictable without mutating config
489
+ return
490
+
491
+ config = self.load_config()
492
+ accounts = config.get("accounts", {})
493
+
494
+ if current_name not in accounts:
495
+ raise AccountNotFoundError(f"Account '{current_name}' not found")
496
+
497
+ if new_name in accounts and not overwrite:
498
+ raise AccountStoreError(f"Account '{new_name}' already exists. Use --yes to overwrite.")
499
+
500
+ accounts[new_name] = accounts[current_name]
501
+ del accounts[current_name]
502
+
503
+ if config.get("active_account") == current_name:
504
+ config["active_account"] = new_name
505
+
506
+ self._save_config(config)
507
+
508
+
509
+ # Global instance for convenience
510
+ _account_store = AccountStore()
511
+
512
+
513
+ def get_account_store() -> AccountStore:
514
+ """Get the global account store instance."""
515
+ from glaip_sdk.cli import config as config_module # noqa: PLC0415
516
+
517
+ global _account_store
518
+
519
+ if _account_store is None or _account_store.config_file != config_module.CONFIG_FILE:
520
+ _account_store = AccountStore(config_module.CONFIG_FILE)
521
+
522
+ return _account_store