glaip-sdk 0.1.2__py3-none-any.whl → 0.7.17__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 (217) 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 +1413 -0
  5. glaip_sdk/branding.py +126 -2
  6. glaip_sdk/cli/account_store.py +555 -0
  7. glaip_sdk/cli/auth.py +260 -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/__init__.py +116 -0
  11. glaip_sdk/cli/commands/agents/_common.py +562 -0
  12. glaip_sdk/cli/commands/agents/create.py +155 -0
  13. glaip_sdk/cli/commands/agents/delete.py +64 -0
  14. glaip_sdk/cli/commands/agents/get.py +89 -0
  15. glaip_sdk/cli/commands/agents/list.py +129 -0
  16. glaip_sdk/cli/commands/agents/run.py +264 -0
  17. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  18. glaip_sdk/cli/commands/agents/update.py +112 -0
  19. glaip_sdk/cli/commands/common_config.py +104 -0
  20. glaip_sdk/cli/commands/configure.py +728 -113
  21. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  22. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  23. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  24. glaip_sdk/cli/commands/mcps/create.py +152 -0
  25. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  26. glaip_sdk/cli/commands/mcps/get.py +212 -0
  27. glaip_sdk/cli/commands/mcps/list.py +69 -0
  28. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  29. glaip_sdk/cli/commands/mcps/update.py +190 -0
  30. glaip_sdk/cli/commands/models.py +12 -8
  31. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  32. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  33. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  34. glaip_sdk/cli/commands/tools/_common.py +80 -0
  35. glaip_sdk/cli/commands/tools/create.py +228 -0
  36. glaip_sdk/cli/commands/tools/delete.py +61 -0
  37. glaip_sdk/cli/commands/tools/get.py +103 -0
  38. glaip_sdk/cli/commands/tools/list.py +69 -0
  39. glaip_sdk/cli/commands/tools/script.py +49 -0
  40. glaip_sdk/cli/commands/tools/update.py +102 -0
  41. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  42. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  43. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  44. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  45. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  46. glaip_sdk/cli/commands/update.py +163 -17
  47. glaip_sdk/cli/config.py +49 -4
  48. glaip_sdk/cli/constants.py +38 -0
  49. glaip_sdk/cli/context.py +8 -0
  50. glaip_sdk/cli/core/__init__.py +79 -0
  51. glaip_sdk/cli/core/context.py +124 -0
  52. glaip_sdk/cli/core/output.py +851 -0
  53. glaip_sdk/cli/core/prompting.py +649 -0
  54. glaip_sdk/cli/core/rendering.py +187 -0
  55. glaip_sdk/cli/display.py +41 -20
  56. glaip_sdk/cli/entrypoint.py +20 -0
  57. glaip_sdk/cli/hints.py +57 -0
  58. glaip_sdk/cli/io.py +6 -3
  59. glaip_sdk/cli/main.py +340 -143
  60. glaip_sdk/cli/masking.py +21 -33
  61. glaip_sdk/cli/pager.py +12 -13
  62. glaip_sdk/cli/parsers/__init__.py +1 -3
  63. glaip_sdk/cli/resolution.py +2 -1
  64. glaip_sdk/cli/slash/__init__.py +0 -9
  65. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  66. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  67. glaip_sdk/cli/slash/agent_session.py +62 -21
  68. glaip_sdk/cli/slash/prompt.py +21 -0
  69. glaip_sdk/cli/slash/remote_runs_controller.py +568 -0
  70. glaip_sdk/cli/slash/session.py +1105 -153
  71. glaip_sdk/cli/slash/tui/__init__.py +36 -0
  72. glaip_sdk/cli/slash/tui/accounts.tcss +177 -0
  73. glaip_sdk/cli/slash/tui/accounts_app.py +1853 -0
  74. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  75. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  76. glaip_sdk/cli/slash/tui/context.py +92 -0
  77. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  78. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  79. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  80. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  81. glaip_sdk/cli/slash/tui/loading.py +80 -0
  82. glaip_sdk/cli/slash/tui/remote_runs_app.py +760 -0
  83. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  84. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  85. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  86. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  87. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  88. glaip_sdk/cli/slash/tui/toast.py +388 -0
  89. glaip_sdk/cli/transcript/__init__.py +12 -52
  90. glaip_sdk/cli/transcript/cache.py +255 -44
  91. glaip_sdk/cli/transcript/capture.py +66 -1
  92. glaip_sdk/cli/transcript/history.py +815 -0
  93. glaip_sdk/cli/transcript/viewer.py +72 -463
  94. glaip_sdk/cli/tui_settings.py +125 -0
  95. glaip_sdk/cli/update_notifier.py +227 -10
  96. glaip_sdk/cli/validators.py +5 -6
  97. glaip_sdk/client/__init__.py +3 -1
  98. glaip_sdk/client/_schedule_payloads.py +89 -0
  99. glaip_sdk/client/agent_runs.py +147 -0
  100. glaip_sdk/client/agents.py +576 -44
  101. glaip_sdk/client/base.py +26 -0
  102. glaip_sdk/client/hitl.py +136 -0
  103. glaip_sdk/client/main.py +25 -14
  104. glaip_sdk/client/mcps.py +165 -24
  105. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  106. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +63 -47
  107. glaip_sdk/client/payloads/agent/responses.py +43 -0
  108. glaip_sdk/client/run_rendering.py +546 -92
  109. glaip_sdk/client/schedules.py +439 -0
  110. glaip_sdk/client/shared.py +21 -0
  111. glaip_sdk/client/tools.py +206 -32
  112. glaip_sdk/config/constants.py +33 -2
  113. glaip_sdk/guardrails/__init__.py +80 -0
  114. glaip_sdk/guardrails/serializer.py +89 -0
  115. glaip_sdk/hitl/__init__.py +48 -0
  116. glaip_sdk/hitl/base.py +64 -0
  117. glaip_sdk/hitl/callback.py +43 -0
  118. glaip_sdk/hitl/local.py +121 -0
  119. glaip_sdk/hitl/remote.py +523 -0
  120. glaip_sdk/mcps/__init__.py +21 -0
  121. glaip_sdk/mcps/base.py +345 -0
  122. glaip_sdk/models/__init__.py +136 -0
  123. glaip_sdk/models/_provider_mappings.py +101 -0
  124. glaip_sdk/models/_validation.py +97 -0
  125. glaip_sdk/models/agent.py +48 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/constants.py +141 -0
  129. glaip_sdk/models/mcp.py +33 -0
  130. glaip_sdk/models/model.py +170 -0
  131. glaip_sdk/models/schedule.py +224 -0
  132. glaip_sdk/models/tool.py +33 -0
  133. glaip_sdk/payload_schemas/__init__.py +1 -13
  134. glaip_sdk/payload_schemas/agent.py +1 -0
  135. glaip_sdk/payload_schemas/guardrails.py +34 -0
  136. glaip_sdk/registry/__init__.py +55 -0
  137. glaip_sdk/registry/agent.py +164 -0
  138. glaip_sdk/registry/base.py +139 -0
  139. glaip_sdk/registry/mcp.py +253 -0
  140. glaip_sdk/registry/tool.py +445 -0
  141. glaip_sdk/rich_components.py +58 -2
  142. glaip_sdk/runner/__init__.py +76 -0
  143. glaip_sdk/runner/base.py +84 -0
  144. glaip_sdk/runner/deps.py +115 -0
  145. glaip_sdk/runner/langgraph.py +1055 -0
  146. glaip_sdk/runner/logging_config.py +77 -0
  147. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  148. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  149. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  150. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +116 -0
  151. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  152. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  153. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  154. glaip_sdk/schedules/__init__.py +22 -0
  155. glaip_sdk/schedules/base.py +291 -0
  156. glaip_sdk/tools/__init__.py +22 -0
  157. glaip_sdk/tools/base.py +488 -0
  158. glaip_sdk/utils/__init__.py +59 -12
  159. glaip_sdk/utils/a2a/__init__.py +34 -0
  160. glaip_sdk/utils/a2a/event_processor.py +188 -0
  161. glaip_sdk/utils/agent_config.py +8 -2
  162. glaip_sdk/utils/bundler.py +403 -0
  163. glaip_sdk/utils/client.py +111 -0
  164. glaip_sdk/utils/client_utils.py +39 -7
  165. glaip_sdk/utils/datetime_helpers.py +58 -0
  166. glaip_sdk/utils/discovery.py +78 -0
  167. glaip_sdk/utils/display.py +23 -15
  168. glaip_sdk/utils/export.py +143 -0
  169. glaip_sdk/utils/general.py +0 -33
  170. glaip_sdk/utils/import_export.py +12 -7
  171. glaip_sdk/utils/import_resolver.py +524 -0
  172. glaip_sdk/utils/instructions.py +101 -0
  173. glaip_sdk/utils/rendering/__init__.py +115 -1
  174. glaip_sdk/utils/rendering/formatting.py +5 -30
  175. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  176. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  177. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  178. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  179. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  180. glaip_sdk/utils/rendering/models.py +1 -0
  181. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  182. glaip_sdk/utils/rendering/renderer/base.py +299 -1434
  183. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  184. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  185. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  186. glaip_sdk/utils/rendering/renderer/stream.py +4 -33
  187. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  188. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  189. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  190. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  191. glaip_sdk/utils/rendering/state.py +204 -0
  192. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  193. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  194. glaip_sdk/utils/rendering/steps/format.py +176 -0
  195. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  196. glaip_sdk/utils/rendering/timing.py +36 -0
  197. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  198. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  199. glaip_sdk/utils/resource_refs.py +25 -13
  200. glaip_sdk/utils/runtime_config.py +426 -0
  201. glaip_sdk/utils/serialization.py +18 -0
  202. glaip_sdk/utils/sync.py +162 -0
  203. glaip_sdk/utils/tool_detection.py +301 -0
  204. glaip_sdk/utils/tool_storage_provider.py +140 -0
  205. glaip_sdk/utils/validation.py +16 -24
  206. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/METADATA +69 -23
  207. glaip_sdk-0.7.17.dist-info/RECORD +224 -0
  208. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/WHEEL +2 -1
  209. glaip_sdk-0.7.17.dist-info/entry_points.txt +2 -0
  210. glaip_sdk-0.7.17.dist-info/top_level.txt +1 -0
  211. glaip_sdk/cli/commands/agents.py +0 -1369
  212. glaip_sdk/cli/commands/mcps.py +0 -1187
  213. glaip_sdk/cli/commands/tools.py +0 -584
  214. glaip_sdk/cli/utils.py +0 -1278
  215. glaip_sdk/models.py +0 -240
  216. glaip_sdk-0.1.2.dist-info/RECORD +0 -82
  217. glaip_sdk-0.1.2.dist-info/entry_points.txt +0 -3
@@ -5,83 +5,293 @@ Authors:
5
5
  """
6
6
 
7
7
  import getpass
8
+ import json
9
+ import os
10
+ import re
11
+ import sys
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import Any
8
15
 
9
16
  import click
10
17
  from rich.console import Console
11
18
  from rich.text import Text
12
19
 
13
- from glaip_sdk import Client
14
- from glaip_sdk._version import __version__ as _SDK_VERSION
15
- from glaip_sdk.branding import (
16
- ACCENT_STYLE,
17
- ERROR_STYLE,
18
- INFO,
19
- PRIMARY,
20
- SUCCESS,
21
- SUCCESS_STYLE,
22
- WARNING_STYLE,
23
- AIPBranding,
24
- )
20
+ from glaip_sdk.branding import ACCENT_STYLE, ERROR_STYLE, INFO, NEUTRAL, SUCCESS_STYLE, WARNING_STYLE
21
+
22
+ # Optional import for gitignore support; warn when missing to avoid silent expansion
23
+ try:
24
+ import pathspec # type: ignore[import-untyped] # noqa: PLC0415
25
+ except ImportError: # pragma: no cover - optional dependency
26
+ pathspec = None # type: ignore[assignment]
27
+ from glaip_sdk.cli.account_store import get_account_store
28
+ from glaip_sdk.cli.commands.common_config import check_connection, render_branding_header
25
29
  from glaip_sdk.cli.config import CONFIG_FILE, load_config, save_config
30
+ from glaip_sdk.cli.hints import command_hint, format_command_hint
31
+ from glaip_sdk.cli.masking import mask_api_key_display
26
32
  from glaip_sdk.cli.rich_helpers import markup_text
27
- from glaip_sdk.cli.utils import command_hint, format_command_hint
28
- from glaip_sdk.icons import ICON_TOOL
29
33
  from glaip_sdk.rich_components import AIPTable
30
34
 
31
35
  console = Console()
36
+ stderr_console = Console(file=sys.stderr)
37
+ _PATHSPEC_WARNED = False
38
+ _PATHSPEC_WARNED_LOCK = threading.Lock()
39
+
40
+ # Hard deprecation banner for legacy config commands (v0.6.x)
41
+ CONFIG_HARD_DEPRECATION_MSG = (
42
+ f"[{WARNING_STYLE}]āš ļø DEPRECATED: 'aip config ...' commands will be removed in v0.7.0. "
43
+ "Use 'aip accounts ...' (list/add/use/remove/edit) or 'aip configure' for the wizard. "
44
+ "Set AIP_ENABLE_LEGACY_CONFIG=1 to temporarily re-enable these commands.[/]"
45
+ )
46
+
47
+ # Soft deprecation banner (for when env flag is set)
48
+ CONFIG_SOFT_DEPRECATION_MSG = (
49
+ f"[{WARNING_STYLE}]Deprecated: 'aip config ...' will be removed in v0.7.0. "
50
+ "Use 'aip accounts ...' (list/add/use/remove/edit) or 'aip configure' for the wizard.[/]"
51
+ )
52
+
53
+ # Target removal version
54
+ TARGET_REMOVAL_VERSION = "v0.7.0"
55
+
56
+ # Command hint constant
57
+ CONFIG_CONFIGURE_HINT = "config configure"
58
+ _DEFAULT_EXCLUDE_DIRS = {
59
+ ".git",
60
+ "node_modules",
61
+ ".venv",
62
+ "venv",
63
+ ".tox",
64
+ "build",
65
+ "dist",
66
+ "__pycache__",
67
+ ".mypy_cache",
68
+ ".pytest_cache",
69
+ }
70
+ _MAX_SCAN_FILE_SIZE = 2 * 1024 * 1024 # 2MB cap for default scans
71
+
72
+
73
+ def _is_legacy_config_enabled() -> bool:
74
+ """Check if legacy config commands are enabled via environment variable."""
75
+ env_value = os.environ.get("AIP_ENABLE_LEGACY_CONFIG", "").strip().lower()
76
+ return env_value in ("1", "true", "yes", "on")
77
+
78
+
79
+ def _print_config_deprecation() -> None:
80
+ """Print a standardized deprecation warning for legacy config commands."""
81
+ if _is_legacy_config_enabled():
82
+ # Soft deprecation when env flag is set
83
+ stderr_console.print(CONFIG_SOFT_DEPRECATION_MSG)
84
+ else:
85
+ # Hard deprecation when env flag is not set
86
+ stderr_console.print(CONFIG_HARD_DEPRECATION_MSG)
87
+
88
+
89
+ def _check_legacy_config_gate() -> bool:
90
+ """Return True if legacy config commands are allowed; print banner otherwise."""
91
+ if not _is_legacy_config_enabled():
92
+ stderr_console.print(CONFIG_HARD_DEPRECATION_MSG)
93
+ return False
94
+ return True
95
+
96
+
97
+ def _enforce_legacy_config_gate() -> None:
98
+ """CLI-only gate: exit with code 0 when legacy commands are disabled."""
99
+ if not _check_legacy_config_gate():
100
+ # Spec requires non-breaking exit after banner
101
+ sys.exit(0)
102
+
103
+
104
+ def _emit_telemetry_event(_event_name: str, properties: dict[str, Any] | None = None) -> None:
105
+ """Emit telemetry event for legacy command usage tracking.
106
+
107
+ This is a stub implementation that can be connected to a real telemetry system.
108
+ For now, it's a no-op but structured to allow easy integration.
109
+
110
+ Args:
111
+ _event_name: Name of the telemetry event (prefixed with _ to indicate unused for now).
112
+ properties: Optional event properties dictionary.
113
+
114
+ Note:
115
+ TODO: Connect to actual telemetry system when available.
116
+ """
117
+ if properties is None:
118
+ properties = {}
119
+ # Mark as intentionally unused until telemetry system is integrated
120
+ del _event_name, properties
32
121
 
33
122
 
34
123
  @click.group()
35
124
  def config_group() -> None:
36
- """Configuration management operations."""
37
- pass
125
+ """Configuration management operations (deprecated).
126
+
127
+ These commands are deprecated and will be removed in v0.7.0.
128
+ Use 'aip accounts ...' commands instead.
129
+ Set AIP_ENABLE_LEGACY_CONFIG=1 to temporarily re-enable.
130
+ """
131
+ _enforce_legacy_config_gate()
132
+ _print_config_deprecation()
133
+ # Emit telemetry for legacy command invocation
134
+ _emit_telemetry_event(
135
+ "config.command",
136
+ {
137
+ "phase": "hard_deprecation",
138
+ "gated_by_env": _is_legacy_config_enabled(),
139
+ },
140
+ )
38
141
 
39
142
 
40
143
  @config_group.command("list")
41
- def list_config() -> None:
42
- """List current configuration."""
43
- config = load_config()
144
+ @click.option("--json", "output_json", is_flag=True, help="Output in JSON format")
145
+ @click.pass_context
146
+ def list_config(ctx: click.Context, output_json: bool) -> None:
147
+ """List current configuration.
44
148
 
45
- if not config:
46
- _print_missing_config_hint()
47
- return
149
+ Deprecated: run 'aip accounts list' for profile-aware output.
150
+ """
151
+ _enforce_legacy_config_gate()
152
+ console.print(f"[{WARNING_STYLE}]Deprecated: run 'aip accounts list' for profile-aware output.[/]")
153
+
154
+ # Delegate to accounts list by invoking the command
155
+ from glaip_sdk.cli.commands.accounts import accounts_group # noqa: PLC0415
156
+
157
+ list_cmd = accounts_group.get_command(ctx, "list")
158
+ if list_cmd:
159
+ ctx.invoke(list_cmd, output_json=output_json)
160
+
161
+
162
+ CONFIG_VALUE_TYPES: dict[str, str] = {
163
+ "api_url": "string",
164
+ "api_key": "string",
165
+ "timeout": "float",
166
+ "history_default_limit": "int",
167
+ }
168
+
169
+
170
+ def _parse_bool_config(value: str) -> bool:
171
+ """Parse boolean-like CLI input."""
172
+ normalized = value.strip().lower()
173
+ if normalized in {"1", "true", "yes", "on"}:
174
+ return True
175
+ if normalized in {"0", "false", "no", "off"}:
176
+ return False
177
+ raise click.ClickException("Invalid boolean value. Use one of: true, false, yes, no, 1, 0.")
48
178
 
49
- _render_config_table(config)
179
+
180
+ def _parse_int_config(value: str) -> int:
181
+ """Parse integer CLI input with non-negative enforcement."""
182
+ try:
183
+ parsed = int(value, 10)
184
+ except ValueError as exc:
185
+ raise click.ClickException("Invalid integer value.") from exc
186
+ if parsed < 0:
187
+ raise click.ClickException("Value must be greater than or equal to 0.")
188
+ return parsed
189
+
190
+
191
+ def _parse_float_config(value: str) -> float:
192
+ """Parse float CLI input with non-negative enforcement."""
193
+ try:
194
+ parsed = float(value)
195
+ except ValueError as exc:
196
+ raise click.ClickException("Invalid float value.") from exc
197
+ if parsed < 0:
198
+ raise click.ClickException("Value must be greater than or equal to 0.")
199
+ return parsed
200
+
201
+
202
+ def _coerce_config_value(key: str, raw_value: str) -> str | bool | int | float:
203
+ """Convert CLI string values to their target config types."""
204
+ kind = CONFIG_VALUE_TYPES.get(key, "string")
205
+ if kind == "bool":
206
+ return _parse_bool_config(raw_value)
207
+ if kind == "int":
208
+ return _parse_int_config(raw_value)
209
+ if kind == "float":
210
+ return _parse_float_config(raw_value)
211
+ return raw_value
50
212
 
51
213
 
52
214
  @config_group.command("set")
53
215
  @click.argument("key")
54
216
  @click.argument("value")
55
- def set_config(key: str, value: str) -> None:
56
- """Set a configuration value."""
57
- valid_keys = ["api_url", "api_key"]
217
+ @click.option(
218
+ "--account",
219
+ "account_name",
220
+ help="Account name to set value for (defaults to active account)",
221
+ )
222
+ def set_config(key: str, value: str, account_name: str | None) -> None:
223
+ """Set a configuration value.
224
+
225
+ For api_url and api_key, this operates on the specified account (or active account).
226
+ Other keys (timeout, history_default_limit) are global settings.
58
227
 
228
+ Deprecated: use 'aip accounts edit <name>' instead.
229
+ """
230
+ _enforce_legacy_config_gate()
231
+ # For other keys, use legacy config
232
+ valid_keys = tuple(CONFIG_VALUE_TYPES.keys())
59
233
  if key not in valid_keys:
60
234
  console.print(f"[{ERROR_STYLE}]Error: Invalid key '{key}'. Valid keys are: {', '.join(valid_keys)}[/]")
61
235
  raise click.ClickException(f"Invalid configuration key: {key}")
62
236
 
237
+ store = get_account_store()
238
+ # For api_url and api_key, update account profile but also mirror to legacy config
239
+ if key in ("api_url", "api_key"):
240
+ target_account = account_name or store.get_active_account() or "default"
241
+ try:
242
+ account = store.get_account(target_account) or {}
243
+ account[key] = value
244
+ store.add_account(
245
+ target_account,
246
+ account.get("api_url", ""),
247
+ account.get("api_key", ""),
248
+ overwrite=True,
249
+ )
250
+ except Exception:
251
+ # If account store persistence fails (e.g., mocked I/O), continue with legacy config
252
+ pass
253
+
254
+ # Always update legacy config for backward compatibility and test isolation
255
+ legacy_config = load_config()
256
+ legacy_config[key] = value
257
+ save_config(legacy_config)
258
+
259
+ display_value = _mask_api_key(value) if key == "api_key" else value
260
+ console.print(Text(f"āœ… Set {key} = {display_value} for account '{target_account}'", style=SUCCESS_STYLE))
261
+ return
262
+
263
+ coerced_value = _coerce_config_value(key, value)
63
264
  config = load_config()
64
- config[key] = value
265
+ config[key] = coerced_value
65
266
  save_config(config)
66
267
 
67
- if key == "api_key":
68
- console.print(Text(f"āœ… Set {key} = {_mask_api_key(value)}", style=SUCCESS_STYLE))
69
- else:
70
- console.print(Text(f"āœ… Set {key} = {value}", style=SUCCESS_STYLE))
268
+ display_value = _mask_api_key(coerced_value) if key == "api_key" else str(coerced_value)
269
+ console.print(Text(f"āœ… Set {key} = {display_value}", style=SUCCESS_STYLE))
71
270
 
72
271
 
73
272
  @config_group.command("get")
74
273
  @click.argument("key")
75
274
  def get_config(key: str) -> None:
76
- """Get a configuration value."""
275
+ """Get a configuration value.
276
+
277
+ Deprecated: use 'aip accounts show <name>' or read ~/.aip/config.yaml directly.
278
+ """
279
+ _enforce_legacy_config_gate()
77
280
  config = load_config()
78
281
 
79
- if key not in config:
282
+ value = config.get(key)
283
+
284
+ # Fallback to account store for api_url/api_key when legacy config lacks the key
285
+ if value is None and key in {"api_url", "api_key"}:
286
+ store = get_account_store()
287
+ active = store.get_active_account() or "default"
288
+ account = store.get_account(active) or {}
289
+ value = account.get(key)
290
+
291
+ if value is None:
80
292
  console.print(markup_text(f"[{WARNING_STYLE}]Configuration key '{key}' not found.[/]"))
81
293
  raise click.ClickException(f"Configuration key not found: {key}")
82
294
 
83
- value = config[key]
84
-
85
295
  if key == "api_key":
86
296
  console.print(_mask_api_key(value))
87
297
  else:
@@ -91,7 +301,11 @@ def get_config(key: str) -> None:
91
301
  @config_group.command("unset")
92
302
  @click.argument("key")
93
303
  def unset_config(key: str) -> None:
94
- """Remove a configuration value."""
304
+ """Remove a configuration value.
305
+
306
+ Deprecated: use 'aip accounts edit <name>' to clear specific fields.
307
+ """
308
+ _enforce_legacy_config_gate()
95
309
  config = load_config()
96
310
 
97
311
  if key not in config:
@@ -107,7 +321,11 @@ def unset_config(key: str) -> None:
107
321
  @config_group.command("reset")
108
322
  @click.option("--force", is_flag=True, help="Skip confirmation prompt")
109
323
  def reset_config(force: bool) -> None:
110
- """Reset all configuration to defaults."""
324
+ """Reset all configuration to defaults.
325
+
326
+ Deprecated: use 'aip accounts remove <name>' for each account or manually edit ~/.aip/config.yaml.
327
+ """
328
+ _enforce_legacy_config_gate()
111
329
  if not force:
112
330
  console.print(f"[{WARNING_STYLE}]This will remove all AIP configuration.[/]")
113
331
  confirm = input("Are you sure? (y/N): ").strip().lower()
@@ -132,88 +350,500 @@ def reset_config(force: bool) -> None:
132
350
  # In-memory configuration (e.g., tests) needs explicit clearing
133
351
  save_config({})
134
352
 
135
- hint = command_hint("config configure", slash_command="login")
353
+ hint = command_hint(CONFIG_CONFIGURE_HINT, slash_command="login")
136
354
  message = Text("āœ… Configuration reset.", style=SUCCESS_STYLE)
137
355
  if hint:
138
356
  message.append(f" Run '{hint}' to set up again.")
139
357
  console.print(message)
140
358
 
141
359
 
142
- def _configure_interactive() -> None:
360
+ def _configure_interactive(account_name: str | None = None) -> None:
143
361
  """Shared configuration logic for both configure commands."""
362
+ store = get_account_store()
363
+
364
+ # Determine account name (use provided, active, or default)
365
+ if not account_name:
366
+ account_name = store.get_active_account() or "default"
367
+
368
+ # Get existing account if it exists
369
+ existing = store.get_account(account_name)
370
+
144
371
  _render_configuration_header()
145
- config = load_config()
146
- _prompt_configuration_inputs(config)
147
- _save_configuration(config)
148
- _test_and_report_connection(config)
372
+ config = _prompt_configuration_inputs_for_account(existing)
373
+
374
+ # Save to account store
375
+ api_url = config.get("api_url", "")
376
+ api_key = config.get("api_key", "")
377
+ if api_url and api_key:
378
+ store.add_account(account_name, api_url, api_key, overwrite=True)
379
+ console.print(Text(f"\nāœ… Configuration saved to account '{account_name}'", style=SUCCESS_STYLE))
380
+
381
+ _test_and_report_connection_for_account(account_name)
149
382
  _print_post_configuration_hints()
383
+ # Show active account footer
384
+ from glaip_sdk.cli.commands.accounts import _print_active_account_footer # noqa: PLC0415
150
385
 
386
+ _print_active_account_footer(store)
151
387
 
152
- @config_group.command()
153
- def configure() -> None:
154
- """Configure AIP CLI credentials and settings interactively."""
155
- _configure_interactive()
156
388
 
389
+ @config_group.command("audit")
390
+ @click.option(
391
+ "--path",
392
+ "paths",
393
+ multiple=True,
394
+ help="Glob pattern(s) to search (repeatable). Defaults to current directory.",
395
+ )
396
+ @click.option(
397
+ "--stdin",
398
+ "read_from_stdin",
399
+ is_flag=True,
400
+ help="Read file list from stdin (one path per line).",
401
+ )
402
+ @click.option(
403
+ "--no-gitignore",
404
+ is_flag=True,
405
+ help="Disable .gitignore filtering (default: respects .gitignore).",
406
+ )
407
+ @click.option(
408
+ "--json",
409
+ "output_json",
410
+ is_flag=True,
411
+ help="Output results in JSON format.",
412
+ )
413
+ @click.option(
414
+ "--fail-on-hit/--no-fail-on-hit",
415
+ default=True,
416
+ help="Exit with code 1 if hits are found (default: fail on hit).",
417
+ )
418
+ @click.option(
419
+ "--silent",
420
+ is_flag=True,
421
+ help="Suppress Rich table output when --json is used.",
422
+ )
423
+ def audit_config(
424
+ paths: tuple[str, ...],
425
+ read_from_stdin: bool,
426
+ no_gitignore: bool,
427
+ output_json: bool,
428
+ fail_on_hit: bool,
429
+ silent: bool,
430
+ ) -> None:
431
+ """Scan scripts/configs for deprecated 'aip config' command usage.
432
+
433
+ Finds strings matching 'aip config' (including variations like 'aip-config',
434
+ 'python -m glaip_sdk.cli config') in scripts, CI manifests, and docs.
435
+
436
+ Examples:
437
+ aip config audit
438
+ aip config audit --path "**/*.sh" --path "**/*.yml"
439
+ aip config audit --stdin < file_list.txt
440
+ aip config audit --json --no-fail-on-hit
441
+ """
442
+ _enforce_legacy_config_gate()
443
+ # Collect files to scan
444
+ files_to_scan = _collect_files_to_scan(paths, read_from_stdin)
445
+
446
+ # Filter by gitignore if enabled
447
+ files_to_scan = _filter_by_gitignore(files_to_scan, no_gitignore)
448
+
449
+ # Scan files for matches
450
+ hits = _scan_files_for_matches(files_to_scan)
451
+
452
+ # Emit telemetry
453
+ _emit_telemetry_event(
454
+ "config.audit",
455
+ {
456
+ "audit_invoked": True,
457
+ "hits_found": len(hits),
458
+ "files_scanned": len(files_to_scan),
459
+ },
460
+ )
461
+
462
+ # Output results
463
+ _output_audit_results(hits, len(files_to_scan), output_json, silent)
464
+
465
+ # Exit with appropriate code
466
+ if hits and fail_on_hit:
467
+ sys.exit(1)
468
+ sys.exit(0)
469
+
470
+
471
+ # Patterns to match deprecated config command usage
472
+ _AUDIT_PATTERNS = [
473
+ r"aip\s+config",
474
+ r"aip-config",
475
+ r"python\s+-m\s+glaip_sdk\.cli\s+config",
476
+ r"python\s+-m\s+glaip_sdk\.cli\.main\s+config",
477
+ ]
478
+ _COMPILED_AUDIT_PATTERNS = [re.compile(pattern, re.IGNORECASE) for pattern in _AUDIT_PATTERNS]
479
+
480
+
481
+ def _collect_files_from_stdin() -> list[Path]:
482
+ """Collect files to scan from stdin input.
483
+
484
+ Returns:
485
+ List of file paths read from stdin.
486
+ """
487
+ files_to_scan: list[Path] = []
488
+ for line in sys.stdin:
489
+ line = line.strip()
490
+ if line:
491
+ try:
492
+ file_path = Path(line).expanduser().resolve()
493
+ except Exception:
494
+ continue
495
+ if file_path.exists() and file_path.is_file():
496
+ if _should_skip_file(file_path):
497
+ continue
498
+ files_to_scan.append(file_path)
499
+ return files_to_scan
500
+
501
+
502
+ def _collect_files_from_patterns(paths: tuple[str, ...]) -> list[Path]:
503
+ """Collect files to scan from glob patterns.
504
+
505
+ Args:
506
+ paths: Glob patterns to search.
507
+
508
+ Returns:
509
+ List of file paths matching the patterns.
510
+ """
511
+ files_to_scan: list[Path] = []
512
+ for pattern in paths:
513
+ for file_path in Path.cwd().rglob(pattern):
514
+ if file_path.is_file() and not _should_skip_file(file_path):
515
+ files_to_scan.append(file_path)
516
+ return files_to_scan
157
517
 
158
- # Alias command for backward compatibility
159
- @click.command()
160
- def configure_command() -> None:
161
- """Configure AIP CLI credentials and settings interactively.
162
518
 
163
- This is an alias for 'aip config configure' for backward compatibility.
519
+ def _collect_files_default() -> list[Path]:
520
+ """Collect all files from current directory recursively.
521
+
522
+ Returns:
523
+ List of all file paths in current directory.
164
524
  """
165
- # Delegate to the shared function
166
- _configure_interactive()
525
+ files_to_scan: list[Path] = []
526
+ base = Path.cwd()
527
+ max_files = _resolve_audit_max_files()
528
+
529
+ for root, dirs, files in os.walk(base):
530
+ dirs[:] = [d for d in dirs if d not in _DEFAULT_EXCLUDE_DIRS]
531
+ for file in files:
532
+ file_path = Path(root) / file
533
+ if _should_skip_file(file_path):
534
+ continue
535
+ files_to_scan.append(file_path)
536
+ if max_files and len(files_to_scan) >= max_files:
537
+ _warn_scan_truncated(max_files)
538
+ return files_to_scan
539
+
540
+ return files_to_scan
541
+
542
+
543
+ def _resolve_audit_max_files() -> int | None:
544
+ """Resolve optional scan limit from env."""
545
+ max_files_env = os.getenv("AIP_CONFIG_AUDIT_MAX_FILES")
546
+ if not max_files_env:
547
+ return None
548
+ try:
549
+ parsed = int(max_files_env, 10)
550
+ except ValueError:
551
+ return None
552
+ return parsed if parsed > 0 else None
167
553
 
168
554
 
169
- # Note: The config command group should be registered in main.py
555
+ def _warn_scan_truncated(max_files: int) -> None:
556
+ """Warn when scanning is truncated to avoid surprises on huge repos."""
557
+ console.print(
558
+ f"[{WARNING_STYLE}]Scanning limited to the first {max_files} files. "
559
+ "Use --path to narrow the search or increase AIP_CONFIG_AUDIT_MAX_FILES to scan more.[/]"
560
+ )
170
561
 
171
562
 
172
- def _mask_api_key(value: str | None) -> str:
173
- if not value:
174
- return ""
175
- return "***" + value[-4:] if len(value) > 4 else "***"
563
+ def _collect_files_to_scan(paths: tuple[str, ...], read_from_stdin: bool) -> list[Path]:
564
+ """Collect files to scan based on input method.
176
565
 
566
+ Args:
567
+ paths: Glob patterns to search (if not reading from stdin).
568
+ read_from_stdin: Whether to read file list from stdin.
177
569
 
178
- def _print_missing_config_hint() -> None:
179
- hint = command_hint("config configure", slash_command="login")
180
- if hint:
181
- console.print(f"[{WARNING_STYLE}]No configuration found.[/] Run {format_command_hint(hint) or hint} to set up.")
570
+ Returns:
571
+ List of file paths to scan.
572
+ """
573
+ if read_from_stdin:
574
+ return _collect_files_from_stdin()
575
+ if paths:
576
+ return _collect_files_from_patterns(paths)
577
+ return _collect_files_default()
578
+
579
+
580
+ def _filter_by_gitignore(files_to_scan: list[Path], no_gitignore: bool) -> list[Path]:
581
+ """Filter files by .gitignore patterns if enabled.
582
+
583
+ Args:
584
+ files_to_scan: List of file paths to filter.
585
+ no_gitignore: If True, skip gitignore filtering.
586
+
587
+ Returns:
588
+ Filtered list of file paths.
589
+ """
590
+ global _PATHSPEC_WARNED
591
+ if no_gitignore or pathspec is None:
592
+ if not no_gitignore and pathspec is None and not _PATHSPEC_WARNED:
593
+ msg = (
594
+ f"[{WARNING_STYLE}]Warning:[/] pathspec is not installed; "
595
+ "gitignore filtering for 'aip config audit' will be skipped."
596
+ )
597
+ with _PATHSPEC_WARNED_LOCK:
598
+ if not _PATHSPEC_WARNED:
599
+ stderr_console.print(msg)
600
+ _PATHSPEC_WARNED = True
601
+ return files_to_scan
602
+
603
+ # Load .gitignore patterns
604
+ gitignore_path = Path.cwd() / ".gitignore"
605
+ if not gitignore_path.exists():
606
+ return files_to_scan
607
+
608
+ try:
609
+ with gitignore_path.open(encoding="utf-8", errors="ignore") as f:
610
+ spec = pathspec.PathSpec.from_lines("gitwildmatch", f)
611
+
612
+ # Guard against files outside CWD; fallback to absolute path in that case
613
+ def _to_git_path(path: Path) -> str:
614
+ try:
615
+ return str(path.relative_to(Path.cwd()))
616
+ except ValueError:
617
+ return str(path)
618
+
619
+ return [path for path in files_to_scan if not spec.match_file(_to_git_path(path))]
620
+ except Exception:
621
+ # If gitignore parsing fails, return all files
622
+ return files_to_scan
623
+
624
+
625
+ def _should_skip_file(file_path: Path) -> bool:
626
+ """Check whether a file should be skipped based on size."""
627
+ try:
628
+ return file_path.stat().st_size > _MAX_SCAN_FILE_SIZE
629
+ except OSError:
630
+ return False
631
+
632
+
633
+ def _extract_match_snippet(line: str, match_obj: re.Match[str]) -> str:
634
+ """Extract a snippet around a match for display.
635
+
636
+ Args:
637
+ line: The full line containing the match.
638
+ match_obj: The regex match object.
639
+
640
+ Returns:
641
+ A snippet of text around the match.
642
+ """
643
+ start = max(0, match_obj.start() - 20)
644
+ end = min(len(line), match_obj.end() + 20)
645
+ return line[start:end].strip()
646
+
647
+
648
+ def _process_file_for_matches(file_path: Path, compiled_patterns: list[re.Pattern[str]]) -> list[dict[str, Any]]:
649
+ """Process a single file for deprecated config command matches.
650
+
651
+ Args:
652
+ file_path: Path to the file to scan.
653
+ compiled_patterns: List of compiled regex patterns to match.
654
+
655
+ Returns:
656
+ List of hit dictionaries found in this file.
657
+ """
658
+ hits: list[dict[str, Any]] = []
659
+ if _should_skip_file(file_path):
660
+ return hits
661
+ try:
662
+ with file_path.open(encoding="utf-8", errors="ignore") as f:
663
+ for line_num, line in enumerate(f, start=1):
664
+ for pattern in compiled_patterns:
665
+ match_obj = pattern.search(line)
666
+ if match_obj:
667
+ snippet = _extract_match_snippet(line, match_obj)
668
+ replacement = _suggest_replacement(line.strip())
669
+
670
+ try:
671
+ file_str = str(file_path.relative_to(Path.cwd()))
672
+ except ValueError:
673
+ file_str = str(file_path)
674
+
675
+ hits.append(
676
+ {
677
+ "file": file_str,
678
+ "line": line_num,
679
+ "match": snippet,
680
+ "replacement": replacement,
681
+ }
682
+ )
683
+ break # Only count once per line
684
+ except (UnicodeDecodeError, PermissionError):
685
+ # Skip binary files or files we can't read
686
+ pass
687
+ except OSError:
688
+ # Skip files with permission errors
689
+ pass
690
+
691
+ return hits
692
+
693
+
694
+ def _scan_files_for_matches(files_to_scan: list[Path]) -> list[dict[str, Any]]:
695
+ """Scan files for deprecated config command usage.
696
+
697
+ Args:
698
+ files_to_scan: List of file paths to scan.
699
+
700
+ Returns:
701
+ List of hit dictionaries with file, line, match, and replacement info.
702
+ """
703
+ hits: list[dict[str, Any]] = []
704
+
705
+ for file_path in files_to_scan:
706
+ file_hits = _process_file_for_matches(file_path, _COMPILED_AUDIT_PATTERNS)
707
+ hits.extend(file_hits)
708
+
709
+ return hits
710
+
711
+
712
+ def _output_audit_results(hits: list[dict[str, Any]], files_scanned: int, output_json: bool, silent: bool) -> None:
713
+ """Output audit results in the requested format.
714
+
715
+ Args:
716
+ hits: List of hit dictionaries.
717
+ files_scanned: Number of files scanned.
718
+ output_json: If True, output JSON format.
719
+ silent: If True, suppress Rich output when using JSON.
720
+ """
721
+ if output_json:
722
+ result = {
723
+ "hits": hits,
724
+ "total_hits": len(hits),
725
+ "files_scanned": files_scanned,
726
+ }
727
+ click.echo(json.dumps(result, indent=2))
728
+ return
729
+
730
+ if silent:
731
+ return
732
+
733
+ if hits:
734
+ table = AIPTable(title="āš ļø Deprecated 'aip config' Usage Found")
735
+ table.add_column("File", style=INFO, width=30)
736
+ table.add_column("Line", style=NEUTRAL, width=8)
737
+ table.add_column("Match", style=WARNING_STYLE, width=40)
738
+ table.add_column("Suggested Replacement", style=SUCCESS_STYLE, width=40)
739
+
740
+ for hit in hits:
741
+ table.add_row(
742
+ hit["file"],
743
+ str(hit["line"]),
744
+ hit["match"],
745
+ hit["replacement"],
746
+ )
747
+
748
+ console.print(table)
749
+ console.print(f"\n[{WARNING_STYLE}]Found {len(hits)} deprecated usage(s).[/]")
182
750
  else:
183
- console.print(f"[{WARNING_STYLE}]No configuration found.[/]")
751
+ console.print(f"[{SUCCESS_STYLE}]āœ… No deprecated 'aip config' usage found.[/]")
752
+
753
+
754
+ def _suggest_replacement(line: str) -> str:
755
+ """Suggest a replacement command for deprecated config usage."""
756
+ line_lower = line.lower()
757
+
758
+ # Map common patterns to replacements
759
+ if "config list" in line_lower:
760
+ return "aip accounts list"
761
+ elif "config set" in line_lower:
762
+ if "api_url" in line_lower or "api_key" in line_lower:
763
+ return "aip accounts edit <name> [--url URL] [--key]"
764
+ return "aip accounts edit <name>"
765
+ elif "config get" in line_lower:
766
+ return "aip accounts show <name> (or read ~/.aip/config.yaml)"
767
+ elif "config unset" in line_lower:
768
+ return "aip accounts edit <name> (to clear specific fields)"
769
+ elif "config reset" in line_lower:
770
+ return "aip accounts remove <name> (for each account)"
771
+ # Generic "config" usage (command-like), but avoid matching any arbitrary
772
+ # mention of the word "config" in unrelated text.
773
+ elif "aip config" in line_lower or " config " in f" {line_lower} " or CONFIG_CONFIGURE_HINT in line_lower:
774
+ return "aip configure or aip accounts add <name>"
775
+ else:
776
+ return "Use 'aip accounts ...' or 'aip configure'"
184
777
 
185
778
 
186
- def _render_config_table(config: dict[str, str]) -> None:
187
- table = AIPTable(title=f"{ICON_TOOL} AIP Configuration")
188
- table.add_column("Setting", style=INFO, width=20)
189
- table.add_column("Value", style=SUCCESS)
779
+ @config_group.command()
780
+ @click.option(
781
+ "--account",
782
+ "account_name",
783
+ help="Account name to configure (defaults to active account)",
784
+ )
785
+ def configure(account_name: str | None) -> None:
786
+ """Configure AIP CLI credentials and settings interactively.
190
787
 
191
- for key, value in config.items():
192
- table.add_row(key, _mask_api_key(value) if key == "api_key" else str(value))
788
+ This command is an alias for 'aip accounts add <name>' and will
789
+ configure the specified account (or active account if not specified).
790
+ """
791
+ _enforce_legacy_config_gate()
792
+ _configure_interactive(account_name)
193
793
 
194
- console.print(table)
195
- console.print(Text(f"\nšŸ“ Config file: {CONFIG_FILE}"))
794
+
795
+ # Alias command for backward compatibility
796
+ @click.command()
797
+ @click.option(
798
+ "--account",
799
+ "account_name",
800
+ help="Account name to configure (defaults to active account)",
801
+ )
802
+ def configure_command(account_name: str | None) -> None:
803
+ """Configure AIP CLI credentials and settings interactively.
804
+
805
+ This is an alias for 'aip config configure' for backward compatibility.
806
+ For multi-account support, use 'aip accounts add <name>' instead.
807
+ """
808
+ _enforce_legacy_config_gate()
809
+ suppress_tip = os.environ.get("AIP_SUPPRESS_CONFIGURE_TIP", "").strip().lower() in {"1", "true", "yes", "on"}
810
+ if not suppress_tip:
811
+ tip_prefix = f"[{WARNING_STYLE}]Setup tip:[/] "
812
+ tip_body = (
813
+ "Prefer 'aip accounts add <name>' or 'aip configure' from your terminal for multi-account setup. "
814
+ "Launching the interactive wizard now..."
815
+ )
816
+ console.print(f"{tip_prefix}{tip_body}")
817
+ # Delegate to the shared function
818
+ _configure_interactive(account_name)
819
+
820
+
821
+ # Note: The config command group should be registered in main.py
822
+ _mask_api_key = mask_api_key_display
196
823
 
197
824
 
198
825
  def _render_configuration_header() -> None:
199
- branding = AIPBranding.create_from_sdk(sdk_version=_SDK_VERSION, package_name="glaip-sdk")
200
- heading = "[bold]>_ GDP Labs AI Agents Package (AIP CLI)[/bold]"
201
- console.print(heading)
202
- console.print()
203
- console.print(branding.get_welcome_banner())
204
- console.rule("[bold]AIP Configuration[/bold]", style=PRIMARY)
826
+ """Display the interactive configuration heading/banner."""
827
+ render_branding_header(console, "[bold]AIP Configuration[/bold]")
205
828
 
206
829
 
207
- def _prompt_configuration_inputs(config: dict[str, str]) -> None:
830
+ def _prompt_configuration_inputs_for_account(existing: dict[str, str] | None) -> dict[str, str]:
831
+ """Interactively prompt for account configuration values."""
208
832
  console.print("\n[bold]Enter your AIP configuration:[/bold]")
209
- console.print("(Leave blank to keep current values)")
833
+ if existing:
834
+ console.print("(Leave blank to keep current values)")
210
835
  console.print("─" * 50)
211
836
 
837
+ config = existing.copy() if existing else {}
838
+
212
839
  _prompt_api_url(config)
213
840
  _prompt_api_key(config)
214
841
 
842
+ return config
843
+
215
844
 
216
845
  def _prompt_api_url(config: dict[str, str]) -> None:
846
+ """Ask the user for the API URL, preserving existing values by default."""
217
847
  current_url = config.get("api_url", "")
218
848
  suffix = f"(current: {current_url})" if current_url else ""
219
849
  console.print(f"\n[{ACCENT_STYLE}]AIP API URL[/] {suffix}:")
@@ -225,6 +855,7 @@ def _prompt_api_url(config: dict[str, str]) -> None:
225
855
 
226
856
 
227
857
  def _prompt_api_key(config: dict[str, str]) -> None:
858
+ """Prompt the user for the API key while masking previous input."""
228
859
  current_key_masked = _mask_api_key(config.get("api_key"))
229
860
  suffix = f"(current: {current_key_masked})" if current_key_masked else ""
230
861
  console.print(f"\n[{ACCENT_STYLE}]AIP API Key[/] {suffix}:")
@@ -233,44 +864,28 @@ def _prompt_api_key(config: dict[str, str]) -> None:
233
864
  config["api_key"] = new_key
234
865
 
235
866
 
236
- def _save_configuration(config: dict[str, str]) -> None:
237
- save_config(config)
238
- console.print(Text(f"\nāœ… Configuration saved to: {CONFIG_FILE}", style=SUCCESS_STYLE))
867
+ def _test_and_report_connection_for_account(account_name: str) -> None:
868
+ """Sanity-check the provided credentials against the backend."""
869
+ store = get_account_store()
870
+ account = store.get_account(account_name)
871
+ if not account:
872
+ return
239
873
 
874
+ api_url = account.get("api_url", "")
875
+ api_key = account.get("api_key", "")
876
+ if not api_url or not api_key:
877
+ return
240
878
 
241
- def _test_and_report_connection(config: dict[str, str]) -> None:
242
- console.print("\nšŸ”Œ Testing connection...")
243
- client: Client | None = None
244
- try:
245
- client = Client(api_url=config["api_url"], api_key=config["api_key"])
246
- try:
247
- agents = client.list_agents()
248
- console.print(
249
- Text(
250
- f"āœ… Connection successful! Found {len(agents)} agents",
251
- style=SUCCESS_STYLE,
252
- )
253
- )
254
- except Exception as exc: # pragma: no cover - API failures depend on network
255
- console.print(
256
- Text(
257
- f"āš ļø Connection established but API call failed: {exc}",
258
- style=WARNING_STYLE,
259
- )
260
- )
261
- console.print(" You may need to check your API permissions or network access")
262
- except Exception as exc:
263
- console.print(Text(f"āŒ Connection failed: {exc}"))
264
- console.print(" Please check your API URL and key")
265
- hint_status = command_hint("status", slash_command="status")
266
- if hint_status:
267
- console.print(f" You can run {format_command_hint(hint_status) or hint_status} later to test again")
268
- finally:
269
- if client is not None:
270
- client.close()
879
+ hint_status = command_hint("status", slash_command="status")
880
+ extra_hint = None
881
+ if hint_status:
882
+ extra_hint = f" You can run {format_command_hint(hint_status) or hint_status} later to test again"
883
+
884
+ check_connection(api_url, api_key, console, abort_on_error=False, extra_hint=extra_hint)
271
885
 
272
886
 
273
887
  def _print_post_configuration_hints() -> None:
888
+ """Offer next-step guidance after configuration completes."""
274
889
  console.print("\nšŸ’” You can now use AIP CLI commands!")
275
890
  hint_status = command_hint("status", slash_command="status")
276
891
  if hint_status: