vibecore 0.2.0a1__py3-none-any.whl → 0.3.0__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 (38) hide show
  1. vibecore/agents/default.py +6 -11
  2. vibecore/agents/{task_agent.py → task.py} +2 -6
  3. vibecore/auth/__init__.py +15 -0
  4. vibecore/auth/config.py +38 -0
  5. vibecore/auth/interceptor.py +141 -0
  6. vibecore/auth/manager.py +173 -0
  7. vibecore/auth/models.py +54 -0
  8. vibecore/auth/oauth_flow.py +129 -0
  9. vibecore/auth/pkce.py +29 -0
  10. vibecore/auth/storage.py +111 -0
  11. vibecore/auth/token_manager.py +131 -0
  12. vibecore/cli.py +117 -9
  13. vibecore/flow.py +105 -0
  14. vibecore/handlers/stream_handler.py +11 -0
  15. vibecore/main.py +28 -6
  16. vibecore/models/anthropic_auth.py +226 -0
  17. vibecore/settings.py +61 -5
  18. vibecore/tools/task/executor.py +1 -1
  19. vibecore/tools/webfetch/__init__.py +7 -0
  20. vibecore/tools/webfetch/executor.py +127 -0
  21. vibecore/tools/webfetch/models.py +22 -0
  22. vibecore/tools/webfetch/tools.py +46 -0
  23. vibecore/tools/websearch/__init__.py +5 -0
  24. vibecore/tools/websearch/base.py +27 -0
  25. vibecore/tools/websearch/ddgs/__init__.py +5 -0
  26. vibecore/tools/websearch/ddgs/backend.py +64 -0
  27. vibecore/tools/websearch/executor.py +43 -0
  28. vibecore/tools/websearch/models.py +20 -0
  29. vibecore/tools/websearch/tools.py +49 -0
  30. vibecore/widgets/tool_message_factory.py +24 -0
  31. vibecore/widgets/tool_messages.py +219 -0
  32. vibecore/widgets/tool_messages.tcss +94 -0
  33. {vibecore-0.2.0a1.dist-info → vibecore-0.3.0.dist-info}/METADATA +107 -1
  34. {vibecore-0.2.0a1.dist-info → vibecore-0.3.0.dist-info}/RECORD +37 -15
  35. vibecore-0.3.0.dist-info/entry_points.txt +2 -0
  36. vibecore-0.2.0a1.dist-info/entry_points.txt +0 -2
  37. {vibecore-0.2.0a1.dist-info → vibecore-0.3.0.dist-info}/WHEEL +0 -0
  38. {vibecore-0.2.0a1.dist-info → vibecore-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,8 +1,7 @@
1
1
  from typing import TYPE_CHECKING
2
2
 
3
- from agents import Agent, ModelSettings
3
+ from agents import Agent
4
4
  from agents.extensions.handoff_prompt import prompt_with_handoff_instructions
5
- from openai.types import Reasoning
6
5
 
7
6
  from vibecore.context import VibecoreContext
8
7
  from vibecore.settings import settings
@@ -11,6 +10,8 @@ from vibecore.tools.python.tools import execute_python
11
10
  from vibecore.tools.shell.tools import bash, glob, grep, ls
12
11
  from vibecore.tools.task.tools import task
13
12
  from vibecore.tools.todo.tools import todo_read, todo_write
13
+ from vibecore.tools.webfetch.tools import webfetch
14
+ from vibecore.tools.websearch.tools import websearch
14
15
 
15
16
  from .prompts import COMMON_PROMPT
16
17
 
@@ -50,26 +51,20 @@ def create_default_agent(mcp_servers: list["MCPServer"] | None = None) -> Agent[
50
51
  grep,
51
52
  ls,
52
53
  task,
54
+ websearch,
55
+ webfetch,
53
56
  ]
54
57
  instructions = INSTRUCTIONS
55
58
 
56
59
  instructions = prompt_with_handoff_instructions(instructions)
57
60
 
58
- # Configure reasoning based on settings
59
- reasoning_config = Reasoning(summary="auto")
60
- if settings.reasoning_effort is not None:
61
- reasoning_config = Reasoning(effort=settings.reasoning_effort, summary="auto")
62
-
63
61
  return Agent[VibecoreContext](
64
62
  name="Vibecore Agent",
65
63
  handoff_description="A versatile general-purpose assistant",
66
64
  instructions=instructions,
67
65
  tools=tools,
68
66
  model=settings.model,
69
- model_settings=ModelSettings(
70
- include_usage=True, # Ensure token usage is tracked in streaming mode
71
- reasoning=reasoning_config,
72
- ),
67
+ model_settings=settings.default_model_settings,
73
68
  handoffs=[],
74
69
  mcp_servers=mcp_servers or [],
75
70
  )
@@ -1,8 +1,7 @@
1
1
  """Task-specific agent configuration for executing delegated tasks."""
2
2
 
3
- from agents import Agent, ModelSettings
3
+ from agents import Agent
4
4
  from agents.extensions.handoff_prompt import prompt_with_handoff_instructions
5
- from openai.types import Reasoning
6
5
 
7
6
  from vibecore.context import VibecoreContext
8
7
  from vibecore.settings import settings
@@ -58,9 +57,6 @@ def create_task_agent(prompt: str) -> Agent[VibecoreContext]:
58
57
  instructions=instructions,
59
58
  tools=tools,
60
59
  model=settings.model,
61
- model_settings=ModelSettings(
62
- include_usage=True, # Ensure token usage is tracked in streaming mode
63
- reasoning=Reasoning(summary="auto"),
64
- ),
60
+ model_settings=settings.default_model_settings,
65
61
  handoffs=[],
66
62
  )
@@ -0,0 +1,15 @@
1
+ """Anthropic Pro/Max authentication module."""
2
+
3
+ from vibecore.auth.config import ANTHROPIC_CONFIG
4
+ from vibecore.auth.manager import AnthropicAuthManager
5
+ from vibecore.auth.models import AnthropicAuth, ApiKeyCredentials, OAuthCredentials
6
+ from vibecore.auth.storage import SecureAuthStorage
7
+
8
+ __all__ = [
9
+ "ANTHROPIC_CONFIG",
10
+ "AnthropicAuth",
11
+ "AnthropicAuthManager",
12
+ "ApiKeyCredentials",
13
+ "OAuthCredentials",
14
+ "SecureAuthStorage",
15
+ ]
@@ -0,0 +1,38 @@
1
+ """Configuration constants for Anthropic authentication."""
2
+
3
+ from typing import Final
4
+
5
+
6
+ class AnthropicConfig:
7
+ """Configuration for Anthropic OAuth and API."""
8
+
9
+ # OAuth Client Configuration
10
+ OAUTH_CLIENT_ID: Final[str] = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
11
+ OAUTH_SCOPES: Final[str] = "org:create_api_key user:profile user:inference"
12
+ OAUTH_REDIRECT_URI: Final[str] = "https://console.anthropic.com/oauth/code/callback"
13
+ OAUTH_RESPONSE_TYPE: Final[str] = "code"
14
+ OAUTH_CODE_CHALLENGE_METHOD: Final[str] = "S256"
15
+
16
+ # API Endpoints
17
+ CLAUDE_AI_AUTHORIZE: Final[str] = "https://claude.ai/oauth/authorize"
18
+ CONSOLE_AUTHORIZE: Final[str] = "https://console.anthropic.com/oauth/authorize"
19
+ TOKEN_EXCHANGE: Final[str] = "https://console.anthropic.com/v1/oauth/token"
20
+ API_BASE: Final[str] = "https://api.anthropic.com"
21
+ MESSAGES: Final[str] = "https://api.anthropic.com/v1/messages"
22
+
23
+ # Beta Headers (Critical for Claude Code spoofing)
24
+ BETA_OAUTH: Final[str] = "oauth-2025-04-20"
25
+ BETA_CLAUDE_CODE: Final[str] = "claude-code-20250219" # CRITICAL: Identifies as Claude Code
26
+ BETA_INTERLEAVED_THINKING: Final[str] = "interleaved-thinking-2025-05-14"
27
+ BETA_FINE_GRAINED_STREAMING: Final[str] = "fine-grained-tool-streaming-2025-05-14"
28
+
29
+ # Token Management
30
+ TOKEN_REFRESH_BUFFER_SECONDS: Final[int] = 300 # Refresh 5 minutes before expiry
31
+ TOKEN_MAX_RETRY_ATTEMPTS: Final[int] = 3
32
+ TOKEN_RETRY_DELAY_MS: Final[int] = 1000
33
+
34
+ # Claude Code Identity
35
+ CLAUDE_CODE_IDENTITY: Final[str] = "You are Claude Code, Anthropic's official CLI for Claude."
36
+
37
+
38
+ ANTHROPIC_CONFIG = AnthropicConfig()
@@ -0,0 +1,141 @@
1
+ """HTTP request interceptor for Claude Code spoofing."""
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+ from httpx import URL
7
+
8
+ from vibecore.auth.config import ANTHROPIC_CONFIG
9
+ from vibecore.auth.storage import SecureAuthStorage
10
+ from vibecore.auth.token_manager import TokenRefreshManager
11
+
12
+
13
+ class AnthropicRequestInterceptor:
14
+ """Intercepts and modifies Anthropic API requests."""
15
+
16
+ def __init__(self, storage: SecureAuthStorage):
17
+ """
18
+ Initialize request interceptor.
19
+
20
+ Args:
21
+ storage: Secure storage for credentials.
22
+ """
23
+ self.storage = storage
24
+ self.token_manager = TokenRefreshManager(storage)
25
+
26
+ async def intercept_request(self, url: str, headers: dict[str, str] | None = None, **kwargs: Any) -> dict[str, Any]:
27
+ """
28
+ Intercept and modify request for Anthropic API.
29
+
30
+ Args:
31
+ url: Request URL.
32
+ headers: Request headers.
33
+ **kwargs: Additional request parameters.
34
+
35
+ Returns:
36
+ Modified request parameters.
37
+ """
38
+ auth = await self.storage.load("anthropic")
39
+
40
+ if not auth:
41
+ raise ValueError("Not authenticated with Anthropic")
42
+
43
+ # Prepare headers
44
+ headers = {} if headers is None else headers.copy()
45
+
46
+ if auth.type == "oauth": # OAuth auth
47
+ await self._configure_oauth_headers(headers)
48
+ else: # API key auth
49
+ self._configure_api_key_headers(headers, auth.key) # type: ignore
50
+
51
+ # Apply Claude Code spoofing headers
52
+ self._apply_claude_code_headers(headers)
53
+
54
+ # Return modified request parameters
55
+ return {**kwargs, "headers": headers}
56
+
57
+ async def _configure_oauth_headers(self, headers: dict[str, str]) -> None:
58
+ """Configure headers for OAuth authentication."""
59
+ # Get valid access token (handles refresh automatically)
60
+ access_token = await self.token_manager.get_valid_token()
61
+
62
+ # Remove any API key headers (OAuth takes precedence)
63
+ headers.pop("x-api-key", None)
64
+ headers.pop("X-Api-Key", None)
65
+ headers.pop("anthropic-api-key", None)
66
+
67
+ # Set OAuth bearer token
68
+ headers["Authorization"] = f"Bearer {access_token}"
69
+
70
+ def _configure_api_key_headers(self, headers: dict[str, str], api_key: str) -> None:
71
+ """Configure headers for API key authentication."""
72
+ # Remove OAuth headers if present
73
+ headers.pop("Authorization", None)
74
+
75
+ # Set API key
76
+ headers["x-api-key"] = api_key
77
+
78
+ def _apply_claude_code_headers(self, headers: dict[str, str]) -> None:
79
+ """Apply Claude Code spoofing headers."""
80
+ # Build beta features header
81
+ beta_features = [
82
+ ANTHROPIC_CONFIG.BETA_OAUTH,
83
+ ANTHROPIC_CONFIG.BETA_CLAUDE_CODE, # Critical for spoofing
84
+ ANTHROPIC_CONFIG.BETA_INTERLEAVED_THINKING,
85
+ # ANTHROPIC_CONFIG.BETA_FINE_GRAINED_STREAMING,
86
+ ]
87
+
88
+ # Set the beta header (this is what makes Anthropic think we're Claude Code)
89
+ headers["anthropic-beta"] = ",".join(beta_features)
90
+
91
+ # Set additional headers that Claude Code uses
92
+ headers["anthropic-version"] = "2023-06-01"
93
+ headers.setdefault("accept", "application/json")
94
+
95
+ # Add Claude Code specific headers
96
+ headers["user-agent"] = "Claude-Code/1.0"
97
+ headers["x-client-id"] = ANTHROPIC_CONFIG.OAUTH_CLIENT_ID
98
+
99
+ # Ensure content-type is set for POST requests
100
+ headers.setdefault("content-type", "application/json")
101
+
102
+
103
+ class GlobalAnthropicInterceptor:
104
+ """Global interceptor for automatic Anthropic request modification."""
105
+
106
+ def __init__(self, storage: SecureAuthStorage):
107
+ """
108
+ Initialize global interceptor.
109
+
110
+ Args:
111
+ storage: Secure storage for credentials.
112
+ """
113
+ self.interceptor = AnthropicRequestInterceptor(storage)
114
+ self.original_client_class = httpx.AsyncClient
115
+
116
+ def install(self) -> None:
117
+ """Install global request interception."""
118
+ interceptor = self.interceptor
119
+
120
+ class InterceptedAsyncClient(httpx.AsyncClient):
121
+ """Custom AsyncClient that intercepts Anthropic requests."""
122
+
123
+ async def request(self, method: str, url: URL | str, **kwargs: Any) -> httpx.Response:
124
+ """Override request method to intercept Anthropic API calls."""
125
+ # Convert URL to string if needed
126
+ url_str = str(url)
127
+
128
+ # Check if this is an Anthropic API request
129
+ if "anthropic.com" in url_str or "claude.ai" in url_str:
130
+ # Intercept and modify request
131
+ kwargs = await interceptor.intercept_request(url_str, **kwargs)
132
+
133
+ # Call original request method
134
+ return await super().request(method, url, **kwargs)
135
+
136
+ # Replace httpx.AsyncClient globally
137
+ httpx.AsyncClient = InterceptedAsyncClient # type: ignore
138
+
139
+ def uninstall(self) -> None:
140
+ """Uninstall global request interception."""
141
+ httpx.AsyncClient = self.original_client_class # type: ignore
@@ -0,0 +1,173 @@
1
+ """Main authentication manager for Anthropic Pro/Max."""
2
+
3
+ from vibecore.auth.config import ANTHROPIC_CONFIG
4
+ from vibecore.auth.interceptor import GlobalAnthropicInterceptor
5
+ from vibecore.auth.models import ApiKeyCredentials
6
+ from vibecore.auth.oauth_flow import AnthropicOAuthFlow
7
+ from vibecore.auth.storage import SecureAuthStorage
8
+ from vibecore.auth.token_manager import TokenRefreshManager
9
+
10
+
11
+ class AnthropicAuthManager:
12
+ """Main manager for Anthropic authentication."""
13
+
14
+ def __init__(self, app_name: str = "vibecore"):
15
+ """
16
+ Initialize authentication manager.
17
+
18
+ Args:
19
+ app_name: Application name for storage.
20
+ """
21
+ self.storage = SecureAuthStorage(app_name)
22
+ self.oauth_flow = AnthropicOAuthFlow()
23
+ self.token_manager = TokenRefreshManager(self.storage)
24
+ self.interceptor = GlobalAnthropicInterceptor(self.storage)
25
+
26
+ async def authenticate_pro_max(self, mode: str = "max") -> bool:
27
+ """
28
+ Authenticate with Pro/Max subscription via OAuth.
29
+
30
+ Args:
31
+ mode: "max" for claude.ai, "console" for console.anthropic.com.
32
+
33
+ Returns:
34
+ True if authentication successful.
35
+ """
36
+ try:
37
+ # Step 1: Initiate OAuth flow
38
+ auth_request = await self.oauth_flow.initiate(mode)
39
+
40
+ # Step 2: Open browser for user authorization
41
+ print("\n🌐 Opening browser for authentication...")
42
+ print(f" If browser doesn't open, visit: {auth_request.url}\n")
43
+ self.oauth_flow.open_browser(auth_request.url)
44
+
45
+ # Step 3: Wait for user to provide authorization code
46
+ print("After authorizing, you'll be redirected to a page showing an authorization code.")
47
+ print("Copy the entire code (including the part after #) and paste it below.\n")
48
+ auth_code = input("Authorization code: ").strip()
49
+
50
+ if not auth_code:
51
+ print("❌ No authorization code provided")
52
+ return False
53
+
54
+ # Step 4: Exchange code for tokens
55
+ print("🔄 Exchanging authorization code for tokens...")
56
+ credentials = await self.oauth_flow.exchange(auth_code)
57
+
58
+ # Step 5: Save credentials securely
59
+ await self.storage.save("anthropic", credentials)
60
+
61
+ print("✅ Authentication successful! You're now using Claude Pro/Max.\n")
62
+ return True
63
+
64
+ except Exception as e:
65
+ print(f"❌ Authentication failed: {e}")
66
+ return False
67
+
68
+ async def authenticate_with_api_key(self, api_key: str) -> bool:
69
+ """
70
+ Authenticate with API key.
71
+
72
+ Args:
73
+ api_key: Anthropic API key.
74
+
75
+ Returns:
76
+ True if authentication successful.
77
+ """
78
+ try:
79
+ # Validate API key format
80
+ if not api_key.startswith("sk-ant-"):
81
+ print("❌ Invalid API key format. Anthropic keys start with 'sk-ant-'")
82
+ return False
83
+
84
+ credentials = ApiKeyCredentials(type="api", key=api_key)
85
+ await self.storage.save("anthropic", credentials)
86
+
87
+ print("✅ API key saved successfully!")
88
+ return True
89
+
90
+ except Exception as e:
91
+ print(f"❌ Failed to save API key: {e}")
92
+ return False
93
+
94
+ async def is_authenticated(self) -> bool:
95
+ """Check if user is authenticated."""
96
+ auth = await self.storage.load("anthropic")
97
+ return auth is not None
98
+
99
+ async def get_auth_type(self) -> str | None:
100
+ """
101
+ Get current authentication type.
102
+
103
+ Returns:
104
+ "oauth" for Pro/Max, "api" for API key, None if not authenticated.
105
+ """
106
+ auth = await self.storage.load("anthropic")
107
+ if auth:
108
+ return auth.type
109
+ return None
110
+
111
+ async def logout(self) -> None:
112
+ """Remove stored authentication."""
113
+ await self.storage.remove("anthropic")
114
+ print("✅ Logged out successfully")
115
+
116
+ def install_interceptor(self) -> None:
117
+ """Install global request interceptor for Claude Code spoofing."""
118
+ self.interceptor.install()
119
+
120
+ def uninstall_interceptor(self) -> None:
121
+ """Uninstall global request interceptor."""
122
+ self.interceptor.uninstall()
123
+
124
+ async def test_connection(self) -> bool:
125
+ """
126
+ Test the authentication by making a simple API call.
127
+
128
+ Returns:
129
+ True if connection successful.
130
+ """
131
+ try:
132
+ import httpx
133
+
134
+ # Get auth headers
135
+ auth = await self.storage.load("anthropic")
136
+ if not auth:
137
+ print("❌ Not authenticated")
138
+ return False
139
+
140
+ headers = {}
141
+ if auth.type == "oauth": # OAuth
142
+ token = await self.token_manager.get_valid_token()
143
+ headers["Authorization"] = f"Bearer {token}"
144
+ else: # API key
145
+ headers["x-api-key"] = auth.key # type: ignore
146
+
147
+ # Add Claude Code headers
148
+ headers["anthropic-beta"] = ",".join(
149
+ [
150
+ ANTHROPIC_CONFIG.BETA_OAUTH,
151
+ ANTHROPIC_CONFIG.BETA_CLAUDE_CODE,
152
+ ]
153
+ )
154
+ headers["anthropic-version"] = "2023-06-01"
155
+
156
+ # Make test request
157
+ async with httpx.AsyncClient() as client:
158
+ response = await client.get(
159
+ "https://api.anthropic.com/v1/models",
160
+ headers=headers,
161
+ timeout=10.0,
162
+ )
163
+
164
+ if response.status_code == 200:
165
+ print("✅ Connection test successful!")
166
+ return True
167
+ else:
168
+ print(f"❌ Connection test failed: {response.status_code}")
169
+ return False
170
+
171
+ except Exception as e:
172
+ print(f"❌ Connection test error: {e}")
173
+ return False
@@ -0,0 +1,54 @@
1
+ """Data models for Anthropic authentication."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+
7
+ @dataclass
8
+ class OAuthCredentials:
9
+ """OAuth credentials for Pro/Max users."""
10
+
11
+ type: Literal["oauth"] = "oauth"
12
+ refresh: str = "" # Refresh token (long-lived)
13
+ access: str = "" # Access token (short-lived)
14
+ expires: int = 0 # Unix timestamp in milliseconds
15
+
16
+
17
+ @dataclass
18
+ class ApiKeyCredentials:
19
+ """API key credentials for standard users."""
20
+
21
+ type: Literal["api"] = "api"
22
+ key: str = "" # API key
23
+
24
+
25
+ # Union type for both authentication methods
26
+ AnthropicAuth = OAuthCredentials | ApiKeyCredentials
27
+
28
+
29
+ @dataclass
30
+ class PKCEChallenge:
31
+ """PKCE challenge pair for OAuth flow."""
32
+
33
+ verifier: str
34
+ challenge: str
35
+
36
+
37
+ @dataclass
38
+ class AuthorizationRequest:
39
+ """OAuth authorization request details."""
40
+
41
+ url: str
42
+ verifier: str
43
+ state: str = ""
44
+
45
+
46
+ @dataclass
47
+ class TokenResponse:
48
+ """OAuth token response from server."""
49
+
50
+ access_token: str
51
+ refresh_token: str
52
+ expires_in: int
53
+ token_type: str
54
+ scope: str = ""
@@ -0,0 +1,129 @@
1
+ """OAuth flow implementation for Anthropic Pro/Max authentication."""
2
+
3
+ import time
4
+ import webbrowser
5
+ from urllib.parse import urlencode
6
+
7
+ import httpx
8
+
9
+ from vibecore.auth.config import ANTHROPIC_CONFIG
10
+ from vibecore.auth.models import AuthorizationRequest, OAuthCredentials, PKCEChallenge
11
+ from vibecore.auth.pkce import PKCEGenerator
12
+
13
+
14
+ class AnthropicOAuthFlow:
15
+ """Handles OAuth flow for Pro/Max authentication."""
16
+
17
+ def __init__(self):
18
+ """Initialize OAuth flow handler."""
19
+ self.pkce_challenge: PKCEChallenge | None = None
20
+
21
+ async def initiate(self, mode: str = "max") -> AuthorizationRequest:
22
+ """
23
+ Initiate OAuth flow for Pro/Max authentication.
24
+
25
+ Args:
26
+ mode: "max" for claude.ai, "console" for console.anthropic.com.
27
+
28
+ Returns:
29
+ Authorization request with URL and PKCE verifier.
30
+ """
31
+ # Generate PKCE challenge
32
+ self.pkce_challenge = PKCEGenerator.generate()
33
+
34
+ # Select appropriate authorization endpoint
35
+ base_url = ANTHROPIC_CONFIG.CLAUDE_AI_AUTHORIZE if mode == "max" else ANTHROPIC_CONFIG.CONSOLE_AUTHORIZE
36
+
37
+ # Build authorization URL with all required parameters
38
+ params = {
39
+ "code": "true",
40
+ "client_id": ANTHROPIC_CONFIG.OAUTH_CLIENT_ID,
41
+ "response_type": ANTHROPIC_CONFIG.OAUTH_RESPONSE_TYPE,
42
+ "redirect_uri": ANTHROPIC_CONFIG.OAUTH_REDIRECT_URI,
43
+ "scope": ANTHROPIC_CONFIG.OAUTH_SCOPES,
44
+ "code_challenge": self.pkce_challenge.challenge,
45
+ "code_challenge_method": ANTHROPIC_CONFIG.OAUTH_CODE_CHALLENGE_METHOD,
46
+ "state": self.pkce_challenge.verifier, # Using verifier as state
47
+ }
48
+
49
+ # Create full URL
50
+ auth_url = f"{base_url}?{urlencode(params)}"
51
+
52
+ return AuthorizationRequest(
53
+ url=auth_url,
54
+ verifier=self.pkce_challenge.verifier,
55
+ state=self.pkce_challenge.verifier,
56
+ )
57
+
58
+ async def exchange(self, auth_code: str) -> OAuthCredentials:
59
+ """
60
+ Exchange authorization code for tokens.
61
+
62
+ Args:
63
+ auth_code: Format: "code#state" from callback.
64
+
65
+ Returns:
66
+ OAuth credentials with access and refresh tokens.
67
+
68
+ Raises:
69
+ ValueError: If OAuth flow not initiated or invalid code format.
70
+ httpx.HTTPError: If token exchange fails.
71
+ """
72
+ if not self.pkce_challenge:
73
+ raise ValueError("OAuth flow not initiated")
74
+
75
+ # Parse the authorization code
76
+ parts = auth_code.split("#")
77
+ if len(parts) != 2:
78
+ raise ValueError("Invalid authorization code format. Expected: code#state")
79
+
80
+ code, state = parts
81
+
82
+ # Prepare token exchange request
83
+ request_body = {
84
+ "code": code,
85
+ "state": state,
86
+ "grant_type": "authorization_code",
87
+ "client_id": ANTHROPIC_CONFIG.OAUTH_CLIENT_ID,
88
+ "redirect_uri": ANTHROPIC_CONFIG.OAUTH_REDIRECT_URI,
89
+ "code_verifier": self.pkce_challenge.verifier,
90
+ }
91
+
92
+ # Exchange code for tokens
93
+ async with httpx.AsyncClient() as client:
94
+ response = await client.post(
95
+ ANTHROPIC_CONFIG.TOKEN_EXCHANGE,
96
+ headers={
97
+ "Content-Type": "application/json",
98
+ "Accept": "application/json",
99
+ },
100
+ json=request_body,
101
+ )
102
+
103
+ if response.status_code != 200:
104
+ error_text = response.text
105
+ raise httpx.HTTPError(f"Token exchange failed: {response.status_code} - {error_text}")
106
+
107
+ tokens_data = response.json()
108
+
109
+ # Create credentials object
110
+ credentials = OAuthCredentials(
111
+ type="oauth",
112
+ refresh=tokens_data["refresh_token"],
113
+ access=tokens_data["access_token"],
114
+ expires=int(time.time() * 1000) + tokens_data["expires_in"] * 1000,
115
+ )
116
+
117
+ # Clear PKCE challenge
118
+ self.pkce_challenge = None
119
+
120
+ return credentials
121
+
122
+ def open_browser(self, url: str) -> None:
123
+ """
124
+ Open browser for user authorization.
125
+
126
+ Args:
127
+ url: Authorization URL.
128
+ """
129
+ webbrowser.open(url)
vibecore/auth/pkce.py ADDED
@@ -0,0 +1,29 @@
1
+ """PKCE (Proof Key for Code Exchange) implementation for OAuth."""
2
+
3
+ import hashlib
4
+ import secrets
5
+ from base64 import urlsafe_b64encode
6
+
7
+ from vibecore.auth.models import PKCEChallenge
8
+
9
+
10
+ class PKCEGenerator:
11
+ """Generates cryptographically secure PKCE challenge pairs."""
12
+
13
+ @staticmethod
14
+ def generate() -> PKCEChallenge:
15
+ """
16
+ Generate a PKCE challenge pair following RFC 7636.
17
+
18
+ Returns:
19
+ PKCEChallenge with verifier and challenge.
20
+ """
21
+ # Generate 32 bytes of random data for verifier
22
+ verifier_bytes = secrets.token_bytes(32)
23
+ verifier = urlsafe_b64encode(verifier_bytes).decode("ascii").rstrip("=")
24
+
25
+ # Create SHA256 hash of verifier for challenge
26
+ challenge_bytes = hashlib.sha256(verifier.encode("ascii")).digest()
27
+ challenge = urlsafe_b64encode(challenge_bytes).decode("ascii").rstrip("=")
28
+
29
+ return PKCEChallenge(verifier=verifier, challenge=challenge)