cade-cli 0.3.3__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 (44) hide show
  1. cade_cli-0.3.3.dist-info/METADATA +151 -0
  2. cade_cli-0.3.3.dist-info/RECORD +44 -0
  3. cade_cli-0.3.3.dist-info/WHEEL +4 -0
  4. cade_cli-0.3.3.dist-info/entry_points.txt +2 -0
  5. cadecoder/__init__.py +1 -0
  6. cadecoder/ai/__init__.py +6 -0
  7. cadecoder/ai/prompts.py +572 -0
  8. cadecoder/cli/__init__.py +0 -0
  9. cadecoder/cli/app.py +147 -0
  10. cadecoder/cli/auth.py +483 -0
  11. cadecoder/cli/commands/__init__.py +5 -0
  12. cadecoder/cli/commands/auth.py +143 -0
  13. cadecoder/cli/commands/chat.py +264 -0
  14. cadecoder/cli/commands/mcp.py +477 -0
  15. cadecoder/cli/commands/tools.py +226 -0
  16. cadecoder/core/__init__.py +12 -0
  17. cadecoder/core/config.py +380 -0
  18. cadecoder/core/constants.py +281 -0
  19. cadecoder/core/errors.py +145 -0
  20. cadecoder/core/logging.py +148 -0
  21. cadecoder/core/types.py +235 -0
  22. cadecoder/core/utils.py +279 -0
  23. cadecoder/execution/__init__.py +46 -0
  24. cadecoder/execution/context_window.py +521 -0
  25. cadecoder/execution/orchestrator.py +562 -0
  26. cadecoder/execution/parallel.py +287 -0
  27. cadecoder/providers/__init__.py +60 -0
  28. cadecoder/providers/base.py +294 -0
  29. cadecoder/providers/openai.py +251 -0
  30. cadecoder/storage/__init__.py +0 -0
  31. cadecoder/storage/threads.py +489 -0
  32. cadecoder/templates/login_failed.html +21 -0
  33. cadecoder/templates/login_success.html +21 -0
  34. cadecoder/templates/styles.css +87 -0
  35. cadecoder/tools/__init__.py +19 -0
  36. cadecoder/tools/builtin.py +644 -0
  37. cadecoder/tools/filesystem.py +315 -0
  38. cadecoder/tools/git.py +221 -0
  39. cadecoder/tools/manager.py +1635 -0
  40. cadecoder/ui/__init__.py +7 -0
  41. cadecoder/ui/display.py +338 -0
  42. cadecoder/ui/input.py +145 -0
  43. cadecoder/ui/session.py +455 -0
  44. cadecoder/ui/state.py +20 -0
cadecoder/cli/app.py ADDED
@@ -0,0 +1,147 @@
1
+ """Main CLI application setup and entry point for CadeCoder."""
2
+
3
+ import os
4
+
5
+ # Set environment variable to disable tokenizers parallelism warning
6
+ # This must be set before any imports that use tokenizers
7
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
8
+
9
+ from typing import Annotated
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from cadecoder import __version__
15
+ from cadecoder.cli.commands import auth, chat
16
+ from cadecoder.cli.commands.mcp import mcp_app
17
+ from cadecoder.cli.commands.tools import tool_app
18
+ from cadecoder.core.logging import setup_logging
19
+
20
+ # Create the main app
21
+ app = typer.Typer(
22
+ name="cade",
23
+ help="Cade - The CLI Agent from Arcade.dev",
24
+ add_completion=True,
25
+ no_args_is_help=False, # Changed to False to allow default command
26
+ context_settings={"help_option_names": ["-h", "--help"]},
27
+ rich_markup_mode="markdown",
28
+ invoke_without_command=True, # Allow callback to run when no command given
29
+ )
30
+
31
+ # Register individual commands with explicit names and help panels
32
+ app.command(name="login", help="Log in to Arcade Cloud", rich_help_panel="User")(auth.login)
33
+ app.command(name="logout", help="Log out of Arcade Cloud", rich_help_panel="User")(auth.logout)
34
+ app.command(name="whoami", help="Show current login status", rich_help_panel="User")(auth.whoami)
35
+ app.command(name="chat")(chat.chat)
36
+ app.command(name="resume", help="Resume the most recent thread or a specific thread by name")(
37
+ chat.resume
38
+ )
39
+
40
+ # Register sub-command groups
41
+ app.add_typer(mcp_app, name="mcp", help="Manage MCP servers", rich_help_panel="Tools")
42
+ app.add_typer(tool_app, name="tool", help="View available tools", rich_help_panel="Tools")
43
+
44
+ # Use stderr for messages, logs, prompts to avoid interfering with stdout piping
45
+ console = Console(stderr=True)
46
+
47
+
48
+ def version_callback(value: bool) -> None:
49
+ """Prints the version of the package."""
50
+ if value:
51
+ # Use stdout for version
52
+ print(f"cade Version: {__version__}")
53
+ raise typer.Exit()
54
+
55
+
56
+ @app.callback()
57
+ def main_callback(
58
+ ctx: typer.Context,
59
+ verbose: Annotated[
60
+ bool,
61
+ typer.Option("--verbose", "-v", help="Enable verbose debug logging.", is_eager=True),
62
+ ] = False,
63
+ resume_flag: Annotated[
64
+ bool,
65
+ typer.Option(
66
+ "--resume",
67
+ "-r",
68
+ help="Resume the most recent thread (shortcut for 'cade resume').",
69
+ is_eager=True,
70
+ ),
71
+ ] = False,
72
+ message: Annotated[
73
+ str | None,
74
+ typer.Option(
75
+ "--message",
76
+ "-m",
77
+ help="Single message mode: process one message and exit. "
78
+ "Reads from stdin if no argument given.",
79
+ ),
80
+ ] = None,
81
+ version: Annotated[
82
+ bool | None,
83
+ typer.Option(
84
+ "--version",
85
+ callback=version_callback,
86
+ is_eager=True,
87
+ help="Show version and exit.",
88
+ ),
89
+ ] = None,
90
+ ) -> None:
91
+ """
92
+ Main callback to initialize things if needed.
93
+ Ensure configuration is loaded/available here or before storage access.
94
+
95
+ Args:
96
+ ctx: Typer context object
97
+ verbose: Enable verbose logging if True
98
+ resume_flag: Resume most recent thread if True
99
+ message: Single message mode - process one message and exit
100
+ version: Show version and exit if True
101
+ """
102
+ # Setup logging level first
103
+ setup_logging(verbose)
104
+
105
+ # Load config early - may be needed by commands
106
+ # Store config in context for potential use by commands
107
+ ctx.ensure_object(dict)
108
+ ctx.obj["verbose"] = verbose
109
+
110
+ # Handle single message mode
111
+ if message is not None and ctx.invoked_subcommand is None:
112
+ import sys
113
+
114
+ from cadecoder.ui.session import run_single_message_mode
115
+
116
+ # If message is empty string, read from stdin
117
+ if message == "":
118
+ # Check if stdin has data
119
+ if not sys.stdin.isatty():
120
+ message = sys.stdin.read().strip()
121
+ else:
122
+ console.print(
123
+ "[red]Error: No message provided. Use -m 'message' or pipe input.[/red]"
124
+ )
125
+ raise typer.Exit(1)
126
+
127
+ if not message:
128
+ console.print("[red]Error: Empty message provided.[/red]")
129
+ raise typer.Exit(1)
130
+
131
+ exit_code = run_single_message_mode(message)
132
+ raise typer.Exit(exit_code)
133
+
134
+ # Handle eager resume flag before default chat
135
+ if resume_flag and ctx.invoked_subcommand is None:
136
+ # Invoke resume command directly
137
+ chat.resume()
138
+ raise typer.Exit()
139
+
140
+ # If no command was invoked (just "cade"), launch chat
141
+ if ctx.invoked_subcommand is None:
142
+ # Import and run chat command directly
143
+ chat.chat()
144
+
145
+
146
+ if __name__ == "__main__":
147
+ app()
cadecoder/cli/auth.py ADDED
@@ -0,0 +1,483 @@
1
+ """
2
+ OAuth authentication module for CadeCoder CLI.
3
+
4
+ Uses arcade-core for OAuth 2.0 Authorization Code flow with PKCE.
5
+ Credentials are shared with arcade-cli at ~/.arcade/credentials.yaml.
6
+ """
7
+
8
+ import secrets
9
+ import threading
10
+ import uuid
11
+ import webbrowser
12
+ from http.server import BaseHTTPRequestHandler, HTTPServer
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import parse_qs
16
+
17
+ from arcade_core.auth_tokens import (
18
+ CLIConfig,
19
+ TokenResponse,
20
+ fetch_cli_config,
21
+ get_valid_access_token,
22
+ )
23
+ from arcade_core.config_model import Config
24
+ from authlib.integrations.httpx_client import OAuth2Client
25
+ from rich.console import Console
26
+
27
+ from cadecoder.core.constants import load_template
28
+
29
+ console = Console()
30
+
31
+ # OAuth constants
32
+ DEFAULT_SCOPES = "openid offline_access"
33
+ LOCAL_CALLBACK_PORT = 9905
34
+
35
+
36
+ class WhoAmIResponse:
37
+ """Response from Coordinator /whoami endpoint."""
38
+
39
+ def __init__(
40
+ self,
41
+ account_id: str,
42
+ email: str,
43
+ organizations: list[dict[str, Any]] | None = None,
44
+ projects: list[dict[str, Any]] | None = None,
45
+ ) -> None:
46
+ self.account_id = account_id
47
+ self.email = email
48
+ self.organizations = organizations or []
49
+ self.projects = projects or []
50
+
51
+ @classmethod
52
+ def from_dict(cls, data: dict[str, Any]) -> "WhoAmIResponse":
53
+ """Create from API response dict."""
54
+ return cls(
55
+ account_id=data.get("account_id", ""),
56
+ email=data.get("email", ""),
57
+ organizations=data.get("organizations", []),
58
+ projects=data.get("projects", []),
59
+ )
60
+
61
+ def get_selected_org(self) -> dict[str, Any] | None:
62
+ """Get the default org or first available."""
63
+ if not self.organizations:
64
+ return None
65
+ for org in self.organizations:
66
+ if org.get("is_default"):
67
+ return org
68
+ return self.organizations[0]
69
+
70
+ def get_selected_project(self) -> dict[str, Any] | None:
71
+ """Get the default project or first available."""
72
+ if not self.projects:
73
+ return None
74
+ for proj in self.projects:
75
+ if proj.get("is_default"):
76
+ return proj
77
+ return self.projects[0]
78
+
79
+
80
+ def create_oauth_client(cli_config: CLIConfig) -> OAuth2Client:
81
+ """Create an authlib OAuth2Client configured for the CLI."""
82
+ return OAuth2Client(
83
+ client_id=cli_config.client_id,
84
+ token_endpoint=cli_config.token_endpoint,
85
+ code_challenge_method="S256",
86
+ )
87
+
88
+
89
+ def generate_authorization_url(
90
+ client: OAuth2Client,
91
+ cli_config: CLIConfig,
92
+ redirect_uri: str,
93
+ state: str,
94
+ ) -> tuple[str, str]:
95
+ """Generate OAuth authorization URL with PKCE."""
96
+ code_verifier = secrets.token_urlsafe(64)
97
+ url, _ = client.create_authorization_url(
98
+ cli_config.authorization_endpoint,
99
+ redirect_uri=redirect_uri,
100
+ scope=DEFAULT_SCOPES,
101
+ state=state,
102
+ code_verifier=code_verifier,
103
+ )
104
+ return url, code_verifier
105
+
106
+
107
+ def exchange_code_for_tokens(
108
+ client: OAuth2Client,
109
+ code: str,
110
+ redirect_uri: str,
111
+ code_verifier: str,
112
+ ) -> TokenResponse:
113
+ """Exchange authorization code for tokens using authlib."""
114
+ token = client.fetch_token(
115
+ client.session.metadata["token_endpoint"],
116
+ grant_type="authorization_code",
117
+ code=code,
118
+ redirect_uri=redirect_uri,
119
+ code_verifier=code_verifier,
120
+ )
121
+ return TokenResponse(
122
+ access_token=token["access_token"],
123
+ refresh_token=token["refresh_token"],
124
+ expires_in=token["expires_in"],
125
+ token_type=token["token_type"],
126
+ )
127
+
128
+
129
+ def fetch_whoami(coordinator_url: str, access_token: str) -> WhoAmIResponse:
130
+ """Fetch user info from the Coordinator."""
131
+ import httpx
132
+
133
+ url = f"{coordinator_url}/api/v1/auth/whoami"
134
+ response = httpx.get(
135
+ url,
136
+ headers={"Authorization": f"Bearer {access_token}"},
137
+ timeout=30,
138
+ )
139
+ response.raise_for_status()
140
+ data = response.json().get("data", {})
141
+ return WhoAmIResponse.from_dict(data)
142
+
143
+
144
+ def save_credentials_from_whoami(
145
+ tokens: TokenResponse,
146
+ whoami: WhoAmIResponse,
147
+ coordinator_url: str,
148
+ ) -> None:
149
+ """Save OAuth credentials using arcade-core Config model."""
150
+ from datetime import datetime, timedelta
151
+
152
+ from arcade_core.config_model import AuthConfig, ContextConfig, UserConfig
153
+
154
+ expires_at = datetime.now() + timedelta(seconds=tokens.expires_in)
155
+
156
+ context = None
157
+ selected_org = whoami.get_selected_org()
158
+ selected_project = whoami.get_selected_project()
159
+
160
+ if selected_org and selected_project:
161
+ org_id = selected_org.get("org_id") or selected_org.get("organization_id", "")
162
+ context = ContextConfig(
163
+ org_id=org_id,
164
+ org_name=selected_org.get("name", ""),
165
+ project_id=selected_project.get("project_id", ""),
166
+ project_name=selected_project.get("name", ""),
167
+ )
168
+
169
+ config = Config(
170
+ coordinator_url=coordinator_url,
171
+ auth=AuthConfig(
172
+ access_token=tokens.access_token,
173
+ refresh_token=tokens.refresh_token,
174
+ expires_at=expires_at,
175
+ ),
176
+ user=UserConfig(email=whoami.email),
177
+ context=context,
178
+ )
179
+ config.save_to_file()
180
+
181
+
182
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
183
+ """HTTP request handler for OAuth callback."""
184
+
185
+ def __init__(
186
+ self,
187
+ *args: Any,
188
+ state: str,
189
+ result_holder: dict[str, Any],
190
+ **kwargs: Any,
191
+ ) -> None:
192
+ self.state = state
193
+ self.result_holder = result_holder
194
+ super().__init__(*args, **kwargs)
195
+
196
+ def log_message(self, format: str, *args: Any) -> None:
197
+ """Suppress logging to stdout."""
198
+ pass
199
+
200
+ def do_GET(self) -> None:
201
+ """Handle GET request (OAuth callback)."""
202
+ query_string = self.path.split("?", 1)[-1] if "?" in self.path else ""
203
+ params = parse_qs(query_string)
204
+
205
+ returned_state = params.get("state", [None])[0]
206
+ code = params.get("code", [None])[0]
207
+ error = params.get("error", [None])[0]
208
+ error_description = params.get("error_description", [None])[0]
209
+
210
+ if returned_state != self.state:
211
+ self.result_holder["error"] = "Invalid state parameter. Possible CSRF attack."
212
+ self._send_error_response()
213
+ return
214
+
215
+ if error:
216
+ self.result_holder["error"] = error_description or error
217
+ self._send_error_response()
218
+ return
219
+
220
+ if not code:
221
+ self.result_holder["error"] = "No authorization code received."
222
+ self._send_error_response()
223
+ return
224
+
225
+ self.result_holder["code"] = code
226
+ self._send_success_response()
227
+
228
+ def _send_success_response(self) -> None:
229
+ """Send success HTML response."""
230
+ self.send_response(200)
231
+ self.send_header("Content-Type", "text/html; charset=utf-8")
232
+ self.end_headers()
233
+ self.wfile.write(load_template("login_success.html"))
234
+ threading.Thread(target=self.server.shutdown).start()
235
+
236
+ def _send_error_response(self) -> None:
237
+ """Send error HTML response."""
238
+ self.send_response(400)
239
+ self.send_header("Content-Type", "text/html; charset=utf-8")
240
+ self.end_headers()
241
+ self.wfile.write(load_template("login_failed.html"))
242
+ threading.Thread(target=self.server.shutdown).start()
243
+
244
+
245
+ class OAuthCallbackServer:
246
+ """Local HTTP server for OAuth callback."""
247
+
248
+ def __init__(self, state: str, port: int = LOCAL_CALLBACK_PORT) -> None:
249
+ self.state = state
250
+ self.port = port
251
+ self.httpd: HTTPServer | None = None
252
+ self.result: dict[str, Any] = {}
253
+
254
+ def _make_handler(self) -> type[OAuthCallbackHandler]:
255
+ """Create handler factory with state and result holder."""
256
+ state = self.state
257
+ result = self.result
258
+
259
+ class HandlerWithState(OAuthCallbackHandler):
260
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
261
+ super().__init__(*args, state=state, result_holder=result, **kwargs)
262
+
263
+ return HandlerWithState
264
+
265
+ def run_server(self) -> None:
266
+ """Start the callback server."""
267
+ server_address = ("", self.port)
268
+ self.httpd = HTTPServer(server_address, self._make_handler())
269
+ self.httpd.serve_forever()
270
+
271
+ def start(self) -> int | None:
272
+ """Start the HTTP server in the background.
273
+
274
+ Returns the actual bound port or None on failure.
275
+ """
276
+ try:
277
+ if self.port == 0:
278
+ temp = HTTPServer(("", 0), self._make_handler())
279
+ self.port = temp.server_port
280
+ temp.server_close()
281
+
282
+ self._server_thread = threading.Thread(target=self.run_server, daemon=True)
283
+ self._server_thread.start()
284
+ return self.port
285
+ except Exception:
286
+ return None
287
+
288
+ def wait_for_callback(self, timeout: float | None = None) -> bool:
289
+ """Wait for callback thread to complete."""
290
+ if hasattr(self, "_server_thread"):
291
+ self._server_thread.join(timeout=timeout)
292
+ return "code" in self.result
293
+ return False
294
+
295
+ def shutdown_server(self) -> None:
296
+ """Shut down the callback server."""
297
+ if self.httpd:
298
+ self.httpd.shutdown()
299
+
300
+ def get_redirect_uri(self) -> str:
301
+ """Get the redirect URI for this server."""
302
+ return f"http://localhost:{self.port}/callback"
303
+
304
+
305
+ class OAuthLoginError(Exception):
306
+ """Error during OAuth login flow."""
307
+
308
+ pass
309
+
310
+
311
+ def build_coordinator_url(host: str, port: int | None = None) -> str:
312
+ """Build the Coordinator URL from host and optional port."""
313
+ if port:
314
+ scheme = "http" if host == "localhost" else "https"
315
+ return f"{scheme}://{host}:{port}"
316
+ scheme = "http" if host == "localhost" else "https"
317
+ default_port = ":8000" if host == "localhost" else ""
318
+ return f"{scheme}://{host}{default_port}"
319
+
320
+
321
+ def perform_oauth_login(
322
+ coordinator_url: str,
323
+ on_status: Any | None = None,
324
+ ) -> tuple[TokenResponse, WhoAmIResponse]:
325
+ """Perform the complete OAuth login flow.
326
+
327
+ Args:
328
+ coordinator_url: Base URL of the Coordinator
329
+ on_status: Optional callback for status messages
330
+
331
+ Returns:
332
+ Tuple of (TokenResponse, WhoAmIResponse)
333
+
334
+ Raises:
335
+ OAuthLoginError: If any step of the login flow fails
336
+ """
337
+
338
+ def status(msg: str) -> None:
339
+ if on_status:
340
+ on_status(msg)
341
+
342
+ # Step 1: Fetch OAuth config from Coordinator
343
+ try:
344
+ cli_config = fetch_cli_config(coordinator_url)
345
+ except Exception as e:
346
+ raise OAuthLoginError(f"Could not connect to Arcade at {coordinator_url}") from e
347
+
348
+ # Step 2: Create OAuth client and prepare PKCE
349
+ oauth_client = create_oauth_client(cli_config)
350
+ state = str(uuid.uuid4())
351
+
352
+ # Step 3: Start local callback server
353
+ server = OAuthCallbackServer(state)
354
+ local_port = server.start()
355
+ if local_port is None:
356
+ raise OAuthLoginError("Failed to start callback server.")
357
+
358
+ redirect_uri = server.get_redirect_uri()
359
+
360
+ # Step 4: Generate authorization URL and open browser
361
+ auth_url, code_verifier = generate_authorization_url(
362
+ oauth_client, cli_config, redirect_uri, state
363
+ )
364
+
365
+ status(f"Opening browser to: {auth_url}")
366
+ if not webbrowser.open(auth_url):
367
+ status(f"Copy this URL into your browser:\n{auth_url}")
368
+
369
+ # Step 5: Wait for callback
370
+ server.wait_for_callback(timeout=300)
371
+
372
+ # Check for errors from callback
373
+ if "error" in server.result:
374
+ raise OAuthLoginError(f"Login failed: {server.result['error']}")
375
+
376
+ if "code" not in server.result:
377
+ raise OAuthLoginError("No authorization code received (timed out).")
378
+
379
+ # Step 6: Exchange code for tokens
380
+ code = server.result["code"]
381
+ tokens = exchange_code_for_tokens(oauth_client, code, redirect_uri, code_verifier)
382
+
383
+ # Step 7: Fetch user info
384
+ whoami = fetch_whoami(coordinator_url, tokens.access_token)
385
+
386
+ # Validate org/project exist
387
+ if not whoami.get_selected_org():
388
+ raise OAuthLoginError(
389
+ "No organizations found for your account. "
390
+ "Please contact support@arcade.dev for assistance."
391
+ )
392
+
393
+ if not whoami.get_selected_project():
394
+ org = whoami.get_selected_org()
395
+ org_name = org.get("name", "unknown") if org else "unknown"
396
+ raise OAuthLoginError(
397
+ f"No projects found in organization '{org_name}'. "
398
+ "Please contact support@arcade.dev for assistance."
399
+ )
400
+
401
+ return tokens, whoami
402
+
403
+
404
+ def check_existing_login(suppress_message: bool = False) -> bool:
405
+ """Check if the user is already logged in.
406
+
407
+ Args:
408
+ suppress_message: If True, suppress the logged in message.
409
+
410
+ Returns:
411
+ True if the user is already logged in, False otherwise.
412
+ """
413
+ try:
414
+ config = Config.load_from_file()
415
+ if config.is_authenticated() and config.auth:
416
+ email = config.user.email if config.user else "unknown"
417
+ context = config.context
418
+ if context:
419
+ org_name = context.org_name
420
+ project_name = context.project_name
421
+ else:
422
+ org_name = "unknown"
423
+ project_name = "unknown"
424
+
425
+ if not suppress_message:
426
+ console.print(
427
+ f"You're already logged in as {email}.",
428
+ style="bold green",
429
+ )
430
+ console.print(f"Active: {org_name} / {project_name}", style="dim")
431
+ return True
432
+ except FileNotFoundError:
433
+ pass
434
+ except ValueError as e:
435
+ console.print(f"Error reading config: {e}", style="bold red")
436
+
437
+ return False
438
+
439
+
440
+ def get_access_token(coordinator_url: str | None = None) -> str:
441
+ """Get a valid access token, refreshing if necessary.
442
+
443
+ This is the main function to use when making authenticated API calls.
444
+
445
+ Args:
446
+ coordinator_url: Optional coordinator URL override
447
+
448
+ Returns:
449
+ Valid access token
450
+
451
+ Raises:
452
+ ValueError: If not logged in or token refresh fails
453
+ """
454
+ return get_valid_access_token(coordinator_url)
455
+
456
+
457
+ # For backward compatibility with legacy API key format
458
+ def _check_legacy_credentials() -> tuple[str, str] | None:
459
+ """Check for legacy API key credentials.
460
+
461
+ Returns:
462
+ Tuple of (api_key, email) if found, None otherwise.
463
+ """
464
+ import yaml
465
+
466
+ creds_path = Path.home() / ".arcade" / "credentials.yaml"
467
+ if not creds_path.exists():
468
+ return None
469
+
470
+ try:
471
+ with creds_path.open() as f:
472
+ data = yaml.safe_load(f) or {}
473
+
474
+ cloud = data.get("cloud", {})
475
+ if isinstance(cloud, dict) and "api" in cloud:
476
+ api_key = cloud.get("api", {}).get("key")
477
+ email = cloud.get("user", {}).get("email")
478
+ if api_key and email:
479
+ return api_key, email
480
+ except Exception:
481
+ pass
482
+
483
+ return None
@@ -0,0 +1,5 @@
1
+ """CLI command modules for CadeCoder."""
2
+
3
+ from cadecoder.cli.commands import auth, chat
4
+
5
+ __all__ = ["auth", "chat"]