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