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
glaip_sdk/branding.py CHANGED
@@ -90,6 +90,14 @@ GDP Labs AI Agents Package
90
90
  # ---- small helpers --------------------------------------------------------
91
91
  @staticmethod
92
92
  def _auto_version(package_name: str | None) -> str:
93
+ """Auto-detect version from environment, package metadata, or fallback.
94
+
95
+ Args:
96
+ package_name: Optional package name to read version from installed metadata.
97
+
98
+ Returns:
99
+ Version string from AIP_VERSION env var, package metadata, or SDK_VERSION fallback.
100
+ """
93
101
  # Priority: env → package metadata → fallback
94
102
  env_version = os.getenv("AIP_VERSION")
95
103
  if env_version:
@@ -103,6 +111,11 @@ GDP Labs AI Agents Package
103
111
 
104
112
  @staticmethod
105
113
  def _make_console() -> Console:
114
+ """Create a Rich Console instance respecting NO_COLOR environment variables.
115
+
116
+ Returns:
117
+ Console instance with color system configured based on environment.
118
+ """
106
119
  # Respect NO_COLOR/AIP_NO_COLOR environment variables
107
120
  no_color_env = os.getenv("NO_COLOR") is not None or os.getenv("AIP_NO_COLOR") is not None
108
121
  if no_color_env:
@@ -0,0 +1,540 @@
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 both credentials exist and are non-empty, empty dict otherwise.
221
+ """
222
+ accounts = {}
223
+ # Only create default account if both URL and key are present and non-empty
224
+ if api_url and api_key and api_url.strip() and api_key.strip():
225
+ accounts["default"] = {
226
+ "api_url": api_url.strip(),
227
+ "api_key": api_key.strip(),
228
+ }
229
+ return accounts
230
+
231
+ def _preserve_legacy_keys(self, config: dict[str, Any]) -> dict[str, Any]:
232
+ """Preserve legacy top-level keys for backward compatibility.
233
+
234
+ Args:
235
+ config: Legacy config dictionary.
236
+
237
+ Returns:
238
+ Dictionary with preserved keys.
239
+ """
240
+ preserved = {}
241
+ for key in ["timeout", "history_default_limit"]:
242
+ if key in config:
243
+ preserved[key] = config[key]
244
+ return preserved
245
+
246
+ def _migrate_legacy_config(self, config: dict[str, Any]) -> dict[str, Any]:
247
+ """Migrate legacy config to versioned structure.
248
+
249
+ Args:
250
+ config: Legacy config dictionary.
251
+
252
+ Returns:
253
+ Migrated config dictionary.
254
+ """
255
+ migrated = {
256
+ "version": CONFIG_VERSION,
257
+ "active_account": "default",
258
+ "accounts": {},
259
+ }
260
+
261
+ # Preserve existing accounts if they exist (shouldn't happen in true migration, but defensive)
262
+ existing_accounts = config.get("accounts", {})
263
+ if existing_accounts:
264
+ migrated["accounts"] = existing_accounts.copy()
265
+ existing_active = config.get("active_account")
266
+ if existing_active and existing_active in existing_accounts:
267
+ migrated["active_account"] = existing_active
268
+ elif "default" in existing_accounts:
269
+ migrated["active_account"] = "default"
270
+ else:
271
+ migrated["active_account"] = sorted(existing_accounts.keys())[0]
272
+ else:
273
+ # Extract legacy api_url and api_key only if no accounts exist
274
+ api_url = config.get("api_url")
275
+ api_key = config.get("api_key")
276
+
277
+ # Check for auth.json from secure login MVP (only during migration)
278
+ api_url, api_key = self._load_auth_json_credentials(api_url, api_key)
279
+
280
+ # Create default account if we have valid credentials
281
+ migrated["accounts"] = self._create_default_account(api_url, api_key)
282
+ # Only set active_account to default if we actually created a default account
283
+ if not migrated["accounts"]:
284
+ migrated.pop("active_account", None)
285
+
286
+ # Preserve other top-level keys for backward compatibility
287
+ migrated.update(self._preserve_legacy_keys(config))
288
+
289
+ return migrated
290
+
291
+ def _ensure_migrated(self) -> None:
292
+ """Ensure config is migrated to versioned structure.
293
+
294
+ This should be called before any account operations to ensure
295
+ the config file is in the correct format.
296
+ """
297
+ config = self._load_raw_config()
298
+
299
+ if self._needs_migration(config):
300
+ migrated = self._migrate_legacy_config(config)
301
+ try:
302
+ self._save_config(migrated)
303
+ except AccountStoreError:
304
+ # Gracefully skip migration when persistence is blocked (e.g., mocked I/O in tests)
305
+ return
306
+
307
+ def load_config(self) -> dict[str, Any]:
308
+ """Load config with automatic migration.
309
+
310
+ Returns:
311
+ Versioned config dictionary.
312
+ """
313
+ self._ensure_migrated()
314
+ return self._load_raw_config()
315
+
316
+ def validate_account_name(self, name: str) -> None:
317
+ """Validate an account name.
318
+
319
+ Args:
320
+ name: Account name to validate.
321
+
322
+ Raises:
323
+ InvalidAccountNameError: If name is invalid.
324
+ """
325
+ if not name:
326
+ raise InvalidAccountNameError("Account name cannot be empty")
327
+ if not ACCOUNT_NAME_PATTERN.match(name):
328
+ raise InvalidAccountNameError(
329
+ f"Invalid account name '{name}'. Must be 1-32 characters, alphanumeric, dash, or underscore."
330
+ )
331
+
332
+ def list_accounts(self) -> dict[str, dict[str, str]]:
333
+ """List all account profiles.
334
+
335
+ Returns:
336
+ Dictionary mapping account names to their profiles.
337
+ """
338
+ config = self.load_config()
339
+ return config.get("accounts", {}).copy()
340
+
341
+ def get_account(self, name: str) -> dict[str, str] | None:
342
+ """Get a specific account profile.
343
+
344
+ Args:
345
+ name: Account name.
346
+
347
+ Returns:
348
+ Account profile dictionary with api_url and api_key, or None if not found.
349
+ """
350
+ accounts = self.list_accounts()
351
+ return accounts.get(name)
352
+
353
+ def get_active_account(self) -> str | None:
354
+ """Get the name of the active account.
355
+
356
+ Returns:
357
+ Active account name, or None if not set.
358
+ """
359
+ config = self.load_config()
360
+ return config.get("active_account")
361
+
362
+ def set_active_account(self, name: str) -> None:
363
+ """Set the active account.
364
+
365
+ Args:
366
+ name: Account name to activate.
367
+
368
+ Raises:
369
+ AccountNotFoundError: If account doesn't exist.
370
+ """
371
+ self.validate_account_name(name)
372
+
373
+ config = self.load_config()
374
+ accounts = config.get("accounts", {})
375
+
376
+ if name not in accounts:
377
+ raise AccountNotFoundError(f"Account '{name}' not found")
378
+
379
+ config["active_account"] = name
380
+ self._save_config(config)
381
+
382
+ def add_account(
383
+ self,
384
+ name: str,
385
+ api_url: str,
386
+ api_key: str,
387
+ *,
388
+ overwrite: bool = False,
389
+ ) -> None:
390
+ """Add or update an account profile.
391
+
392
+ Args:
393
+ name: Account name.
394
+ api_url: API URL for this account.
395
+ api_key: API key for this account.
396
+ overwrite: If True, overwrite existing account without prompting.
397
+
398
+ Raises:
399
+ InvalidAccountNameError: If name is invalid.
400
+ AccountStoreError: If account exists and overwrite is False.
401
+ """
402
+ self.validate_account_name(name)
403
+
404
+ config = self.load_config()
405
+ accounts = config.setdefault("accounts", {})
406
+
407
+ if name in accounts and not overwrite:
408
+ raise AccountStoreError(f"Account '{name}' already exists. Use --yes to overwrite.")
409
+
410
+ accounts[name] = {
411
+ "api_url": api_url,
412
+ "api_key": api_key,
413
+ }
414
+
415
+ # If this is the first account, make it active
416
+ if not config.get("active_account") and len(accounts) == 1:
417
+ config["active_account"] = name
418
+
419
+ self._save_config(config)
420
+
421
+ def remove_account(self, name: str) -> None:
422
+ """Remove an account profile.
423
+
424
+ Args:
425
+ name: Account name to remove.
426
+
427
+ Raises:
428
+ AccountNotFoundError: If account doesn't exist.
429
+ AccountStoreError: If trying to remove the last account.
430
+ """
431
+ config = self.load_config()
432
+ accounts = config.get("accounts", {})
433
+
434
+ if name not in accounts:
435
+ raise AccountNotFoundError(f"Account '{name}' not found")
436
+
437
+ if len(accounts) <= 1:
438
+ raise AccountStoreError("Cannot remove the last remaining account")
439
+
440
+ del accounts[name]
441
+
442
+ # If we removed the active account, switch to another account
443
+ active_account = config.get("active_account")
444
+ if active_account == name:
445
+ # Prefer "default" if it exists, otherwise use first alphabetical account
446
+ if "default" in accounts:
447
+ config["active_account"] = "default"
448
+ elif accounts:
449
+ # Sort accounts alphabetically and pick the first one
450
+ sorted_names = sorted(accounts.keys())
451
+ config["active_account"] = sorted_names[0]
452
+ else:
453
+ # No accounts remaining (shouldn't happen due to check above)
454
+ config.pop("active_account", None)
455
+
456
+ self._save_config(config)
457
+
458
+ def get_credentials(
459
+ self,
460
+ account_name: str | None = None,
461
+ ) -> tuple[str | None, str | None]:
462
+ """Get credentials for an account.
463
+
464
+ Args:
465
+ account_name: Account name, or None to use active account.
466
+
467
+ Returns:
468
+ Tuple of (api_url, api_key), or (None, None) if not found.
469
+ """
470
+ config = self.load_config()
471
+
472
+ # Determine which account to use
473
+ if account_name:
474
+ target_account = account_name
475
+ else:
476
+ target_account = config.get("active_account")
477
+
478
+ if not target_account:
479
+ return None, None
480
+
481
+ accounts = config.get("accounts", {})
482
+ account = accounts.get(target_account)
483
+
484
+ if not account:
485
+ return None, None
486
+
487
+ return account.get("api_url"), account.get("api_key")
488
+
489
+ def rename_account(self, current_name: str, new_name: str, *, overwrite: bool = False) -> None:
490
+ """Rename an existing account profile.
491
+
492
+ Args:
493
+ current_name: The existing account name.
494
+ new_name: The desired new account name.
495
+ overwrite: Whether to overwrite an existing target account.
496
+
497
+ Raises:
498
+ InvalidAccountNameError: If either name is invalid.
499
+ AccountNotFoundError: If the source account does not exist.
500
+ AccountStoreError: If the target exists and overwrite is False.
501
+ """
502
+ self.validate_account_name(current_name)
503
+ self.validate_account_name(new_name)
504
+
505
+ if current_name == new_name:
506
+ # No-op rename; keep behavior predictable without mutating config
507
+ return
508
+
509
+ config = self.load_config()
510
+ accounts = config.get("accounts", {})
511
+
512
+ if current_name not in accounts:
513
+ raise AccountNotFoundError(f"Account '{current_name}' not found")
514
+
515
+ if new_name in accounts and not overwrite:
516
+ raise AccountStoreError(f"Account '{new_name}' already exists. Use --yes to overwrite.")
517
+
518
+ accounts[new_name] = accounts[current_name]
519
+ del accounts[current_name]
520
+
521
+ if config.get("active_account") == current_name:
522
+ config["active_account"] = new_name
523
+
524
+ self._save_config(config)
525
+
526
+
527
+ # Global instance for convenience
528
+ _account_store = AccountStore()
529
+
530
+
531
+ def get_account_store() -> AccountStore:
532
+ """Get the global account store instance."""
533
+ from glaip_sdk.cli import config as config_module # noqa: PLC0415
534
+
535
+ global _account_store
536
+
537
+ if _account_store is None or _account_store.config_file != config_module.CONFIG_FILE:
538
+ _account_store = AccountStore(config_module.CONFIG_FILE)
539
+
540
+ return _account_store