clue2app-cli 0.2.7__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.
- clue2app_cli/__init__.py +10 -0
- clue2app_cli/agents/__init__.py +9 -0
- clue2app_cli/agents/ecr_healer.py +475 -0
- clue2app_cli/auth.py +343 -0
- clue2app_cli/config.py +316 -0
- clue2app_cli/core_client.py +46 -0
- clue2app_cli/creds_module.py +253 -0
- clue2app_cli/curses_wizard.py +839 -0
- clue2app_cli/entrypoint_check.py +215 -0
- clue2app_cli/main.py +6711 -0
- clue2app_cli/sdk.py +2993 -0
- clue2app_cli/tui/__init__.py +33 -0
- clue2app_cli/tui/curses_app.py +733 -0
- clue2app_cli/tui/data.py +316 -0
- clue2app_cli/tui/screens/__init__.py +1 -0
- clue2app_cli/tui/textual_app.py +1021 -0
- clue2app_cli/tui/widgets/__init__.py +1 -0
- clue2app_cli/tui_wizard.py +987 -0
- clue2app_cli/version.py +3 -0
- clue2app_cli/wizard.py +1079 -0
- clue2app_cli-0.2.7.dist-info/METADATA +202 -0
- clue2app_cli-0.2.7.dist-info/RECORD +26 -0
- clue2app_cli-0.2.7.dist-info/WHEEL +5 -0
- clue2app_cli-0.2.7.dist-info/entry_points.txt +3 -0
- clue2app_cli-0.2.7.dist-info/licenses/LICENSE +21 -0
- clue2app_cli-0.2.7.dist-info/top_level.txt +1 -0
clue2app_cli/auth.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication module for Clue2App CLI.
|
|
3
|
+
|
|
4
|
+
Handles user authentication, token management, and login/logout functionality.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
import secrets
|
|
10
|
+
import platform
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Optional, Dict, Any
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
import requests
|
|
18
|
+
import keyring
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.prompt import Prompt, Confirm
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from rich.spinner import Spinner
|
|
23
|
+
|
|
24
|
+
from . import config
|
|
25
|
+
from .version import __version__
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
# Constants
|
|
30
|
+
SERVICE_NAME = "clue2app-cli"
|
|
31
|
+
KEYRING_USERNAME = "default"
|
|
32
|
+
POLLING_INTERVAL = 5 # seconds
|
|
33
|
+
MAX_POLLING_ATTEMPTS = 60 # 5 minutes total
|
|
34
|
+
|
|
35
|
+
# User-Agent string
|
|
36
|
+
USER_AGENT = f"Clue2App-CLI/{__version__} ({platform.system()} {platform.release()})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuthenticationError(Exception):
|
|
40
|
+
"""Custom exception for authentication errors."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_jwt_token(token: str) -> Dict[str, Any]:
|
|
45
|
+
"""Parse JWT token and extract payload."""
|
|
46
|
+
try:
|
|
47
|
+
# Split token into parts
|
|
48
|
+
parts = token.split('.')
|
|
49
|
+
if len(parts) != 3:
|
|
50
|
+
raise ValueError("Invalid JWT token format")
|
|
51
|
+
|
|
52
|
+
# Decode payload (second part)
|
|
53
|
+
payload = parts[1]
|
|
54
|
+
# Add padding if needed
|
|
55
|
+
payload += '=' * (4 - len(payload) % 4)
|
|
56
|
+
# Decode base64
|
|
57
|
+
decoded = base64.b64decode(payload)
|
|
58
|
+
# Parse JSON
|
|
59
|
+
return json.loads(decoded)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise AuthenticationError(f"Failed to parse JWT token: {e}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_user_info(token_data: Dict[str, Any]) -> bool:
|
|
65
|
+
"""Save user information from token data."""
|
|
66
|
+
try:
|
|
67
|
+
creds = config.load_credentials()
|
|
68
|
+
creds.update({
|
|
69
|
+
"user_id": token_data.get("sub"),
|
|
70
|
+
"username": token_data.get("username"),
|
|
71
|
+
"email": token_data.get("email"),
|
|
72
|
+
"last_login": datetime.now().isoformat()
|
|
73
|
+
})
|
|
74
|
+
return config.save_credentials(creds)
|
|
75
|
+
except Exception:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class APIClient:
|
|
80
|
+
"""Simple API client for Clue2App authentication."""
|
|
81
|
+
|
|
82
|
+
def __init__(self):
|
|
83
|
+
self.auth_base_url = config.get_config_value("auth.base_url", "https://auth.control.apps.clue2.app")
|
|
84
|
+
self.auth_version = config.get_config_value("auth.version", "v1")
|
|
85
|
+
self.auth_timeout = config.get_config_value("auth.timeout", 30)
|
|
86
|
+
self.console_url = config.get_config_value("auth.console_url", "https://console.control.apps.clue2.app")
|
|
87
|
+
self.headers = {
|
|
88
|
+
"User-Agent": USER_AGENT,
|
|
89
|
+
"Accept": "application/json",
|
|
90
|
+
"Content-Type": "application/json"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def generate_auth_code(self) -> str:
|
|
94
|
+
"""Generate a random authentication code."""
|
|
95
|
+
return secrets.token_urlsafe(32)
|
|
96
|
+
|
|
97
|
+
def start_browser_auth(self, code: str) -> None:
|
|
98
|
+
"""Open browser for authentication."""
|
|
99
|
+
auth_url = f"{self.console_url}/cli-login?code={code}"
|
|
100
|
+
webbrowser.open(auth_url)
|
|
101
|
+
|
|
102
|
+
def validate_auth_code(self, code: str) -> Optional[str]:
|
|
103
|
+
"""Check if the authentication code has been validated."""
|
|
104
|
+
try:
|
|
105
|
+
response = requests.post(
|
|
106
|
+
f"{self.auth_base_url}/api/auth/validate-cli-code",
|
|
107
|
+
json={"authCode": code},
|
|
108
|
+
headers=self.headers,
|
|
109
|
+
timeout=self.auth_timeout
|
|
110
|
+
)
|
|
111
|
+
if response.status_code == 200:
|
|
112
|
+
return response.text
|
|
113
|
+
elif response.status_code == 404:
|
|
114
|
+
return None # Code not yet validated
|
|
115
|
+
else:
|
|
116
|
+
response.raise_for_status()
|
|
117
|
+
except requests.exceptions.RequestException as e:
|
|
118
|
+
raise AuthenticationError(f"Code validation failed: {e}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_access_token() -> Optional[str]:
|
|
122
|
+
"""Get the current access token from file storage."""
|
|
123
|
+
try:
|
|
124
|
+
# Use file-based storage (avoids keyring password prompts)
|
|
125
|
+
creds = config.load_credentials()
|
|
126
|
+
return creds.get("access_token")
|
|
127
|
+
except Exception:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def save_access_token(token: str) -> bool:
|
|
132
|
+
"""Save access token to file."""
|
|
133
|
+
try:
|
|
134
|
+
# Save to credentials file (avoids keyring password prompts)
|
|
135
|
+
creds = config.load_credentials()
|
|
136
|
+
creds["access_token"] = token
|
|
137
|
+
return config.save_credentials(creds)
|
|
138
|
+
except Exception:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def clear_access_token() -> bool:
|
|
143
|
+
"""Clear stored access token."""
|
|
144
|
+
try:
|
|
145
|
+
# Clear from credentials file
|
|
146
|
+
creds = config.load_credentials()
|
|
147
|
+
if "access_token" in creds:
|
|
148
|
+
del creds["access_token"]
|
|
149
|
+
config.save_credentials(creds)
|
|
150
|
+
return config.clear_credentials()
|
|
151
|
+
except Exception:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_current_user() -> Optional[str]:
|
|
156
|
+
"""Get current logged-in user."""
|
|
157
|
+
creds = config.load_credentials()
|
|
158
|
+
return creds.get("username") or creds.get("email")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_authenticated() -> bool:
|
|
162
|
+
"""Check if user is currently authenticated with a valid (non-expired) token."""
|
|
163
|
+
token = get_access_token()
|
|
164
|
+
if not token:
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
# Check if token is expired
|
|
168
|
+
try:
|
|
169
|
+
token_data = parse_jwt_token(token)
|
|
170
|
+
exp = token_data.get("exp")
|
|
171
|
+
if exp:
|
|
172
|
+
import time
|
|
173
|
+
# Token is expired if exp is in the past
|
|
174
|
+
if exp < time.time():
|
|
175
|
+
return False
|
|
176
|
+
except:
|
|
177
|
+
# If we can't parse the token, treat as not authenticated
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@click.group()
|
|
184
|
+
def auth_commands():
|
|
185
|
+
"""Authentication related commands."""
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@auth_commands.command(name="login")
|
|
190
|
+
@click.option('--remember/--no-remember', default=True, help='Remember login credentials')
|
|
191
|
+
def login(remember: bool):
|
|
192
|
+
"""
|
|
193
|
+
🔐 Login to your Clue2App account
|
|
194
|
+
|
|
195
|
+
Authenticate with the Clue2App platform using your browser.
|
|
196
|
+
Your credentials will be stored securely for future use.
|
|
197
|
+
"""
|
|
198
|
+
console.print("[bold blue]🔐 Clue2App Login[/bold blue]")
|
|
199
|
+
|
|
200
|
+
# Check if already logged in
|
|
201
|
+
if is_authenticated():
|
|
202
|
+
current_user = get_current_user()
|
|
203
|
+
if not Confirm.ask(f"Already logged in as [bold]{current_user}[/bold]. Login as different user?"):
|
|
204
|
+
console.print("✅ [green]Already authenticated[/green]")
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
# Create API client
|
|
208
|
+
client = APIClient()
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
# Generate auth code
|
|
212
|
+
auth_code = client.generate_auth_code()
|
|
213
|
+
|
|
214
|
+
# Open browser for authentication
|
|
215
|
+
console.print("\n[yellow]Opening your browser for authentication...[/yellow]")
|
|
216
|
+
client.start_browser_auth(auth_code)
|
|
217
|
+
|
|
218
|
+
# Poll for authentication completion
|
|
219
|
+
console.print("\n[bold green]Waiting for authentication...[/bold green]")
|
|
220
|
+
console.print("Please complete the authentication in your browser.")
|
|
221
|
+
|
|
222
|
+
auth_result = None
|
|
223
|
+
attempts = 0
|
|
224
|
+
|
|
225
|
+
with console.status("[bold blue]Checking authentication status...[/bold blue]", spinner="dots"):
|
|
226
|
+
while attempts < MAX_POLLING_ATTEMPTS:
|
|
227
|
+
auth_result = client.validate_auth_code(auth_code)
|
|
228
|
+
if auth_result:
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
time.sleep(POLLING_INTERVAL)
|
|
232
|
+
attempts += 1
|
|
233
|
+
|
|
234
|
+
if not auth_result:
|
|
235
|
+
raise AuthenticationError("Authentication timed out. Please try again.")
|
|
236
|
+
|
|
237
|
+
# Parse JWT token and save user info
|
|
238
|
+
token_data = parse_jwt_token(auth_result)
|
|
239
|
+
|
|
240
|
+
# Save authentication data
|
|
241
|
+
if remember:
|
|
242
|
+
save_access_token(auth_result)
|
|
243
|
+
save_user_info(token_data)
|
|
244
|
+
|
|
245
|
+
console.print()
|
|
246
|
+
console.print("✅ [bold green]Login successful![/bold green]")
|
|
247
|
+
|
|
248
|
+
# Display user info
|
|
249
|
+
username = token_data.get("username") or token_data.get("email")
|
|
250
|
+
if username:
|
|
251
|
+
console.print(f" Logged in as: [bold]{username}[/bold]")
|
|
252
|
+
|
|
253
|
+
if remember:
|
|
254
|
+
console.print(" Credentials saved for future use")
|
|
255
|
+
|
|
256
|
+
except AuthenticationError as e:
|
|
257
|
+
console.print()
|
|
258
|
+
console.print(f"[red]❌ Authentication failed:[/red] {e}")
|
|
259
|
+
raise click.Abort()
|
|
260
|
+
except Exception as e:
|
|
261
|
+
console.print()
|
|
262
|
+
console.print(f"[red]❌ Login error:[/red] {e}")
|
|
263
|
+
raise click.Abort()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@auth_commands.command(name="logout")
|
|
267
|
+
@click.option('--force', '-f', is_flag=True, help='Force logout without confirmation')
|
|
268
|
+
def logout(force: bool):
|
|
269
|
+
"""
|
|
270
|
+
🚪 Logout from your Clue2App account
|
|
271
|
+
|
|
272
|
+
Sign out and clear stored credentials.
|
|
273
|
+
"""
|
|
274
|
+
if not is_authenticated():
|
|
275
|
+
console.print("[yellow]⚠️ Not currently logged in[/yellow]")
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
current_user = get_current_user()
|
|
279
|
+
|
|
280
|
+
if not force:
|
|
281
|
+
if not Confirm.ask(f"Logout from account [bold]{current_user}[/bold]?"):
|
|
282
|
+
console.print("Logout cancelled")
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
console.print()
|
|
286
|
+
with console.status("[bold blue]Logging out...[/bold blue]", spinner="dots"):
|
|
287
|
+
try:
|
|
288
|
+
# Clear local credentials
|
|
289
|
+
clear_access_token()
|
|
290
|
+
|
|
291
|
+
console.print()
|
|
292
|
+
console.print("✅ [bold green]Logged out successfully[/bold green]")
|
|
293
|
+
|
|
294
|
+
except Exception as e:
|
|
295
|
+
console.print()
|
|
296
|
+
console.print(f"[yellow]⚠️ Logout completed (with warnings): {e}[/yellow]")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@auth_commands.command(name="status")
|
|
300
|
+
def auth_status():
|
|
301
|
+
"""
|
|
302
|
+
📊 Show authentication status
|
|
303
|
+
|
|
304
|
+
Display current login status and user information.
|
|
305
|
+
"""
|
|
306
|
+
console.print("[bold blue]🔐 Authentication Status[/bold blue]")
|
|
307
|
+
console.print()
|
|
308
|
+
|
|
309
|
+
if is_authenticated():
|
|
310
|
+
current_user = get_current_user()
|
|
311
|
+
creds = config.load_credentials()
|
|
312
|
+
|
|
313
|
+
console.print(f"✅ [green]Authenticated[/green] as [bold]{current_user}[/bold]")
|
|
314
|
+
|
|
315
|
+
last_login = creds.get("last_login")
|
|
316
|
+
if last_login:
|
|
317
|
+
try:
|
|
318
|
+
login_time = datetime.fromisoformat(last_login)
|
|
319
|
+
console.print(f" Last login: {login_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
320
|
+
except ValueError:
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
# Show token status
|
|
324
|
+
if get_access_token():
|
|
325
|
+
console.print(" 🔑 [green]Access token[/green]: Available")
|
|
326
|
+
else:
|
|
327
|
+
console.print("❌ [red]Not authenticated[/red]")
|
|
328
|
+
console.print(" Run [bold green]c2a login[/bold green] to authenticate")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# Register login and logout as top-level commands too
|
|
332
|
+
@click.command()
|
|
333
|
+
@click.option('--remember/--no-remember', default=True, help='Remember login credentials')
|
|
334
|
+
def login_command(remember: bool):
|
|
335
|
+
"""🔐 Login to your Clue2App account"""
|
|
336
|
+
login.callback(remember)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@click.command()
|
|
340
|
+
@click.option('--force', '-f', is_flag=True, help='Force logout without confirmation')
|
|
341
|
+
def logout_command(force: bool):
|
|
342
|
+
"""🚪 Logout from your Clue2App account"""
|
|
343
|
+
logout.callback(force)
|
clue2app_cli/config.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for Clue2App CLI.
|
|
3
|
+
|
|
4
|
+
Handles configuration file creation, reading, and updating.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import toml
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Any, Optional
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
# Configuration file names
|
|
19
|
+
CONFIG_FILE = "config.toml"
|
|
20
|
+
CREDENTIALS_FILE = "credentials.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_config_dir() -> Path:
|
|
24
|
+
"""Get the configuration directory for the CLI."""
|
|
25
|
+
if os.name == 'nt': # Windows
|
|
26
|
+
config_dir = Path(os.environ.get('APPDATA', '~')) / '.c2a'
|
|
27
|
+
else: # Unix-like systems
|
|
28
|
+
config_dir = Path.home() / '.c2a'
|
|
29
|
+
|
|
30
|
+
return config_dir
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ensure_config_dir() -> Path:
|
|
34
|
+
"""Ensure the configuration directory exists and return its path."""
|
|
35
|
+
config_dir = get_config_dir()
|
|
36
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
return config_dir
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_config_file() -> Path:
|
|
41
|
+
"""Get the path to the configuration file."""
|
|
42
|
+
return get_config_dir() / CONFIG_FILE
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_credentials_file() -> Path:
|
|
46
|
+
"""Get the path to the credentials file."""
|
|
47
|
+
return get_config_dir() / CREDENTIALS_FILE
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_config() -> Dict[str, Any]:
|
|
51
|
+
"""Load configuration from file."""
|
|
52
|
+
config_file = get_config_file()
|
|
53
|
+
|
|
54
|
+
if not config_file.exists():
|
|
55
|
+
return get_default_config()
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with open(config_file, 'r') as f:
|
|
59
|
+
config = toml.load(f)
|
|
60
|
+
return config
|
|
61
|
+
except Exception as e:
|
|
62
|
+
console.print(f"[red]Error loading config:[/red] {e}")
|
|
63
|
+
return get_default_config()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_config(config: Dict[str, Any]) -> bool:
|
|
67
|
+
"""Save configuration to file."""
|
|
68
|
+
try:
|
|
69
|
+
ensure_config_dir()
|
|
70
|
+
config_file = get_config_file()
|
|
71
|
+
|
|
72
|
+
with open(config_file, 'w') as f:
|
|
73
|
+
toml.dump(config, f)
|
|
74
|
+
|
|
75
|
+
return True
|
|
76
|
+
except Exception as e:
|
|
77
|
+
console.print(f"[red]Error saving config:[/red] {e}")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_default_config() -> Dict[str, Any]:
|
|
82
|
+
"""Get default configuration."""
|
|
83
|
+
return {
|
|
84
|
+
"api": {
|
|
85
|
+
"base_url": "https://coordinator.control.apps.clue2.app",
|
|
86
|
+
"version": "v1",
|
|
87
|
+
"timeout": 30
|
|
88
|
+
},
|
|
89
|
+
"auth": {
|
|
90
|
+
"base_url": "https://auth.control.apps.clue2.app",
|
|
91
|
+
"console_url": "https://console.control.apps.clue2.app",
|
|
92
|
+
"version": "v1",
|
|
93
|
+
"timeout": 30,
|
|
94
|
+
"auto_refresh": True,
|
|
95
|
+
"remember_me": True
|
|
96
|
+
},
|
|
97
|
+
"cli": {
|
|
98
|
+
"output_format": "table",
|
|
99
|
+
"color": True,
|
|
100
|
+
"paging": True
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_config_value(key: str, default: Any = None) -> Any:
|
|
106
|
+
"""Get a configuration value using dot notation (e.g., 'api.base_url')."""
|
|
107
|
+
config = load_config()
|
|
108
|
+
|
|
109
|
+
keys = key.split('.')
|
|
110
|
+
value = config
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
for k in keys:
|
|
114
|
+
value = value[k]
|
|
115
|
+
return value
|
|
116
|
+
except (KeyError, TypeError):
|
|
117
|
+
return default
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def set_config_value(key: str, value: Any) -> bool:
|
|
121
|
+
"""Set a configuration value using dot notation."""
|
|
122
|
+
config = load_config()
|
|
123
|
+
|
|
124
|
+
keys = key.split('.')
|
|
125
|
+
current = config
|
|
126
|
+
|
|
127
|
+
# Navigate to the parent of the key we want to set
|
|
128
|
+
for k in keys[:-1]:
|
|
129
|
+
if k not in current:
|
|
130
|
+
current[k] = {}
|
|
131
|
+
current = current[k]
|
|
132
|
+
|
|
133
|
+
# Set the value
|
|
134
|
+
current[keys[-1]] = value
|
|
135
|
+
|
|
136
|
+
return save_config(config)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_credentials() -> Dict[str, Any]:
|
|
140
|
+
"""Load stored credentials."""
|
|
141
|
+
creds_file = get_credentials_file()
|
|
142
|
+
|
|
143
|
+
if not creds_file.exists():
|
|
144
|
+
return {}
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
with open(creds_file, 'r') as f:
|
|
148
|
+
return json.load(f)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
console.print(f"[red]Error loading credentials:[/red] {e}")
|
|
151
|
+
return {}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def save_credentials(credentials: Dict[str, Any]) -> bool:
|
|
155
|
+
"""Save credentials to file."""
|
|
156
|
+
try:
|
|
157
|
+
ensure_config_dir()
|
|
158
|
+
creds_file = get_credentials_file()
|
|
159
|
+
|
|
160
|
+
with open(creds_file, 'w') as f:
|
|
161
|
+
json.dump(credentials, f, indent=2)
|
|
162
|
+
|
|
163
|
+
# Set restrictive permissions on credentials file
|
|
164
|
+
os.chmod(creds_file, 0o600)
|
|
165
|
+
|
|
166
|
+
return True
|
|
167
|
+
except Exception as e:
|
|
168
|
+
console.print(f"[red]Error saving credentials:[/red] {e}")
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def clear_credentials() -> bool:
|
|
173
|
+
"""Clear stored credentials."""
|
|
174
|
+
try:
|
|
175
|
+
creds_file = get_credentials_file()
|
|
176
|
+
if creds_file.exists():
|
|
177
|
+
creds_file.unlink()
|
|
178
|
+
return True
|
|
179
|
+
except Exception as e:
|
|
180
|
+
console.print(f"[red]Error clearing credentials:[/red] {e}")
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Initialize configuration on first import
|
|
185
|
+
def initialize_config():
|
|
186
|
+
"""Initialize configuration with defaults if it doesn't exist."""
|
|
187
|
+
config_file = get_config_file()
|
|
188
|
+
if not config_file.exists():
|
|
189
|
+
default_config = get_default_config()
|
|
190
|
+
save_config(default_config)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# Auto-initialize
|
|
194
|
+
try:
|
|
195
|
+
initialize_config()
|
|
196
|
+
except Exception:
|
|
197
|
+
# Silently fail if we can't initialize config
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# Context management for active project
|
|
202
|
+
def get_active_project() -> Optional[Dict[str, Any]]:
|
|
203
|
+
"""Get the currently active/selected project."""
|
|
204
|
+
return get_config_value("context.project", None)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def set_active_project(project_id: str, project_name: str, namespace: str) -> bool:
|
|
208
|
+
"""Set the active project context."""
|
|
209
|
+
config = load_config()
|
|
210
|
+
if "context" not in config:
|
|
211
|
+
config["context"] = {}
|
|
212
|
+
config["context"]["project"] = {
|
|
213
|
+
"id": project_id,
|
|
214
|
+
"name": project_name,
|
|
215
|
+
"namespace": namespace
|
|
216
|
+
}
|
|
217
|
+
return save_config(config)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def clear_active_project() -> bool:
|
|
221
|
+
"""Clear the active project context."""
|
|
222
|
+
config = load_config()
|
|
223
|
+
if "context" in config and "project" in config["context"]:
|
|
224
|
+
del config["context"]["project"]
|
|
225
|
+
return save_config(config)
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ConfigManager:
|
|
230
|
+
"""
|
|
231
|
+
Configuration manager class for CLI commands.
|
|
232
|
+
|
|
233
|
+
Provides a class-based interface for managing CLI configuration.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self):
|
|
237
|
+
"""Initialize the ConfigManager."""
|
|
238
|
+
ensure_config_dir()
|
|
239
|
+
|
|
240
|
+
def get_config(self, key: str) -> Any:
|
|
241
|
+
"""
|
|
242
|
+
Get a configuration value using dot notation.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
key: Configuration key in dot notation (e.g., 'api.base_url')
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
The configuration value, or None if not found
|
|
249
|
+
"""
|
|
250
|
+
return get_config_value(key)
|
|
251
|
+
|
|
252
|
+
def set_config(self, key: str, value: Any) -> bool:
|
|
253
|
+
"""
|
|
254
|
+
Set a configuration value using dot notation.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
key: Configuration key in dot notation (e.g., 'api.base_url')
|
|
258
|
+
value: Value to set
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
True if successful, False otherwise
|
|
262
|
+
"""
|
|
263
|
+
# Try to convert value to appropriate type
|
|
264
|
+
converted_value = self._convert_value(value)
|
|
265
|
+
return set_config_value(key, converted_value)
|
|
266
|
+
|
|
267
|
+
def reset_config(self) -> bool:
|
|
268
|
+
"""
|
|
269
|
+
Reset configuration to default values.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
True if successful, False otherwise
|
|
273
|
+
"""
|
|
274
|
+
default_config = get_default_config()
|
|
275
|
+
return save_config(default_config)
|
|
276
|
+
|
|
277
|
+
def get_all(self) -> Dict[str, Any]:
|
|
278
|
+
"""
|
|
279
|
+
Get all configuration values.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Dictionary containing all configuration
|
|
283
|
+
"""
|
|
284
|
+
return load_config()
|
|
285
|
+
|
|
286
|
+
def _convert_value(self, value: str) -> Any:
|
|
287
|
+
"""
|
|
288
|
+
Convert a string value to the appropriate type.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
value: String value to convert
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Converted value (int, float, bool, or string)
|
|
295
|
+
"""
|
|
296
|
+
if isinstance(value, str):
|
|
297
|
+
# Try boolean
|
|
298
|
+
if value.lower() in ('true', 'yes', '1'):
|
|
299
|
+
return True
|
|
300
|
+
if value.lower() in ('false', 'no', '0'):
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
# Try integer
|
|
304
|
+
try:
|
|
305
|
+
return int(value)
|
|
306
|
+
except ValueError:
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
# Try float
|
|
310
|
+
try:
|
|
311
|
+
return float(value)
|
|
312
|
+
except ValueError:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
# Return as-is
|
|
316
|
+
return value
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Thin HTTP client for talking to the platform's `core` service.
|
|
2
|
+
|
|
3
|
+
Lets the CLI call core's REST endpoints (creds/scan, services/health,
|
|
4
|
+
images/safety-drift, code/audit, etc.) without needing kubectl on the
|
|
5
|
+
caller's machine. Core does the privileged cluster I/O on the user's
|
|
6
|
+
behalf.
|
|
7
|
+
|
|
8
|
+
URL resolution:
|
|
9
|
+
1. `CORE_SERVICE_URL` env var (overrides everything; for staging/dev)
|
|
10
|
+
2. The default public ingress URL (production)
|
|
11
|
+
|
|
12
|
+
There's no auth header today because core's read endpoints are open on
|
|
13
|
+
the ingress — same as how the agents call them. Add bearer-token
|
|
14
|
+
support here when core grows authn.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
DEFAULT_CORE_URL = "https://core.control.apps.clue2.app"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def core_base_url():
|
|
28
|
+
return os.environ.get("CORE_SERVICE_URL", DEFAULT_CORE_URL).rstrip("/")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def core_post(path, body=None, timeout=60.0):
|
|
32
|
+
"""POST JSON to a core endpoint. Returns the decoded response.
|
|
33
|
+
Raises requests.HTTPError on transport failures or 5xx; 4xx
|
|
34
|
+
surfaces as a response with the error in the body."""
|
|
35
|
+
url = core_base_url() + path
|
|
36
|
+
resp = requests.post(url, json=body or {}, timeout=timeout)
|
|
37
|
+
resp.raise_for_status()
|
|
38
|
+
return resp.json()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def core_get(path, params=None, timeout=30.0):
|
|
42
|
+
"""GET from a core endpoint. Returns the decoded response."""
|
|
43
|
+
url = core_base_url() + path
|
|
44
|
+
resp = requests.get(url, params=params or {}, timeout=timeout)
|
|
45
|
+
resp.raise_for_status()
|
|
46
|
+
return resp.json()
|