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.
- cade_cli-0.3.3.dist-info/METADATA +151 -0
- cade_cli-0.3.3.dist-info/RECORD +44 -0
- cade_cli-0.3.3.dist-info/WHEEL +4 -0
- cade_cli-0.3.3.dist-info/entry_points.txt +2 -0
- cadecoder/__init__.py +1 -0
- cadecoder/ai/__init__.py +6 -0
- cadecoder/ai/prompts.py +572 -0
- cadecoder/cli/__init__.py +0 -0
- cadecoder/cli/app.py +147 -0
- cadecoder/cli/auth.py +483 -0
- cadecoder/cli/commands/__init__.py +5 -0
- cadecoder/cli/commands/auth.py +143 -0
- cadecoder/cli/commands/chat.py +264 -0
- cadecoder/cli/commands/mcp.py +477 -0
- cadecoder/cli/commands/tools.py +226 -0
- cadecoder/core/__init__.py +12 -0
- cadecoder/core/config.py +380 -0
- cadecoder/core/constants.py +281 -0
- cadecoder/core/errors.py +145 -0
- cadecoder/core/logging.py +148 -0
- cadecoder/core/types.py +235 -0
- cadecoder/core/utils.py +279 -0
- cadecoder/execution/__init__.py +46 -0
- cadecoder/execution/context_window.py +521 -0
- cadecoder/execution/orchestrator.py +562 -0
- cadecoder/execution/parallel.py +287 -0
- cadecoder/providers/__init__.py +60 -0
- cadecoder/providers/base.py +294 -0
- cadecoder/providers/openai.py +251 -0
- cadecoder/storage/__init__.py +0 -0
- cadecoder/storage/threads.py +489 -0
- cadecoder/templates/login_failed.html +21 -0
- cadecoder/templates/login_success.html +21 -0
- cadecoder/templates/styles.css +87 -0
- cadecoder/tools/__init__.py +19 -0
- cadecoder/tools/builtin.py +644 -0
- cadecoder/tools/filesystem.py +315 -0
- cadecoder/tools/git.py +221 -0
- cadecoder/tools/manager.py +1635 -0
- cadecoder/ui/__init__.py +7 -0
- cadecoder/ui/display.py +338 -0
- cadecoder/ui/input.py +145 -0
- cadecoder/ui/session.py +455 -0
- 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
|