mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
febdc530e1
* feat(auth): add Copilot login Add experimental GitHub Copilot device login and copilot/* provider support for users with Copilot access but no OpenAI account. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(copilot): use responses for GPT-5 Route Copilot GPT-5 models through the Responses API because gpt-5.5 is not available on /chat/completions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(copilot): honor device flow timing * docs(copilot): add auth helper docstrings * fix(auth): address copilot review feedback --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
791 lines
23 KiB
Go
791 lines
23 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/huh/v2"
|
|
"github.com/mark3labs/kit/internal/auth"
|
|
"github.com/mark3labs/kit/internal/ui"
|
|
kit "github.com/mark3labs/kit/pkg/kit"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// authCmd represents the auth command for managing AI provider authentication.
|
|
// This command provides subcommands for login, logout, and status checking
|
|
// of authentication credentials for various AI providers, with OAuth support
|
|
// for providers like Anthropic and OpenAI.
|
|
var authCmd = &cobra.Command{
|
|
Use: "auth",
|
|
Short: "Manage authentication credentials for AI providers",
|
|
Long: `Manage authentication credentials for AI providers.
|
|
|
|
This command allows you to securely authenticate and manage credentials for various AI providers
|
|
using OAuth flows. Stored credentials take precedence over environment variables.
|
|
|
|
Available providers:
|
|
- anthropic: Anthropic Claude API (OAuth)
|
|
- openai: OpenAI API (OAuth and API key)
|
|
- copilot: GitHub Copilot (GitHub device login)
|
|
|
|
Examples:
|
|
kit auth login anthropic
|
|
kit auth login openai
|
|
kit auth login copilot
|
|
kit auth logout anthropic
|
|
kit auth status`,
|
|
}
|
|
|
|
// authLoginCmd represents the login subcommand for authenticating with AI providers.
|
|
// It handles OAuth flow for supported providers, opening a browser for authentication
|
|
// and securely storing the resulting credentials for future use.
|
|
var authLoginCmd = &cobra.Command{
|
|
Use: "login [provider]",
|
|
Short: "Authenticate with an AI provider using OAuth",
|
|
Long: `Authenticate with an AI provider using OAuth flow.
|
|
|
|
This will open your browser to complete the OAuth authentication process.
|
|
Your credentials will be securely stored and will take precedence over
|
|
environment variables when making API calls.
|
|
|
|
Available providers:
|
|
- anthropic: Anthropic Claude API (OAuth)
|
|
- openai: OpenAI ChatGPT Plus/Pro (Codex OAuth)
|
|
- copilot: GitHub Copilot (GitHub device login, experimental)
|
|
|
|
Flags:
|
|
--set-default Set this provider's default model as the system default
|
|
|
|
Examples:
|
|
kit auth login anthropic
|
|
kit auth login openai
|
|
kit auth login copilot
|
|
kit auth login copilot --set-default`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runAuthLogin,
|
|
}
|
|
|
|
// authLogoutCmd represents the logout subcommand for removing stored authentication credentials.
|
|
// This command removes stored API keys or OAuth tokens for specified providers,
|
|
// requiring the user to authenticate again or use environment variables.
|
|
var authLogoutCmd = &cobra.Command{
|
|
Use: "logout [provider]",
|
|
Short: "Remove stored authentication credentials for a provider",
|
|
Long: `Remove stored authentication credentials for an AI provider.
|
|
|
|
This will delete the stored API key or OAuth credentials for the specified provider.
|
|
You will need to use environment variables or command-line flags for authentication after logout.
|
|
|
|
Available providers:
|
|
- anthropic: Anthropic Claude API
|
|
- openai: OpenAI API
|
|
- copilot: GitHub Copilot
|
|
|
|
Example:
|
|
kit auth logout anthropic
|
|
kit auth logout openai
|
|
kit auth logout copilot`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runAuthLogout,
|
|
}
|
|
|
|
// authStatusCmd represents the status subcommand for checking authentication status.
|
|
// It displays which providers have stored credentials, their types (OAuth vs API key),
|
|
// creation dates, and expiration status without revealing the actual credentials.
|
|
var authStatusCmd = &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show authentication status for all providers",
|
|
Long: `Show the current authentication status for all supported AI providers.
|
|
|
|
This command displays which providers have stored credentials and when they were created.
|
|
It does not display the actual API keys for security reasons.
|
|
|
|
Example:
|
|
kit auth status`,
|
|
RunE: runAuthStatus,
|
|
}
|
|
|
|
var (
|
|
loginSetDefault bool
|
|
)
|
|
|
|
// defaultModels maps providers to their recommended default models.
|
|
// These are used when --set-default flag is passed to auth login.
|
|
var defaultModels = map[string]string{
|
|
"anthropic": "anthropic/claude-sonnet-4-5-20250929",
|
|
"openai": "openai/gpt-5.4",
|
|
"copilot": "copilot/gpt-5.5",
|
|
}
|
|
|
|
// setDefaultModelIfRequested sets the default model for the given provider
|
|
// if the --set-default flag was provided.
|
|
func setDefaultModelIfRequested(provider string) error {
|
|
if !loginSetDefault {
|
|
return nil
|
|
}
|
|
|
|
model, ok := defaultModels[provider]
|
|
if !ok {
|
|
return fmt.Errorf("no default model configured for provider: %s", provider)
|
|
}
|
|
|
|
if err := ui.SaveModelPreference(model); err != nil {
|
|
return fmt.Errorf("failed to save model preference: %w", err)
|
|
}
|
|
|
|
fmt.Printf("\n✓ Set default model to: %s\n", model)
|
|
return nil
|
|
}
|
|
|
|
func init() {
|
|
authCmd.AddCommand(authLoginCmd)
|
|
authCmd.AddCommand(authLogoutCmd)
|
|
authCmd.AddCommand(authStatusCmd)
|
|
|
|
authLoginCmd.Flags().BoolVar(&loginSetDefault, "set-default", false, "Set this provider's default model as the system default after login")
|
|
}
|
|
|
|
// runAuthLogin dispatches OAuth login to the selected provider.
|
|
func runAuthLogin(cmd *cobra.Command, args []string) error {
|
|
provider := strings.ToLower(args[0])
|
|
|
|
switch provider {
|
|
case "anthropic":
|
|
return loginAnthropic()
|
|
case "openai":
|
|
return loginOpenAI()
|
|
case "copilot":
|
|
return loginCopilot(cmd.Context())
|
|
default:
|
|
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai, copilot", provider)
|
|
}
|
|
}
|
|
|
|
func runAuthLogout(cmd *cobra.Command, args []string) error {
|
|
provider := strings.ToLower(args[0])
|
|
|
|
switch provider {
|
|
case "anthropic":
|
|
return logoutAnthropic()
|
|
case "openai":
|
|
return logoutOpenAI()
|
|
case "copilot":
|
|
return logoutCopilot()
|
|
default:
|
|
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai, copilot", provider)
|
|
}
|
|
}
|
|
|
|
func runAuthStatus(cmd *cobra.Command, args []string) error {
|
|
cm, err := kit.NewCredentialManager()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize credential manager: %w", err)
|
|
}
|
|
|
|
fmt.Println("Authentication Status")
|
|
fmt.Println("====================")
|
|
fmt.Printf("Credentials file: %s\n\n", cm.GetCredentialsPath())
|
|
|
|
// Check Anthropic credentials
|
|
fmt.Print("Anthropic Claude: ")
|
|
if hasAnthropicCreds, err := cm.HasAnthropicCredentials(); err != nil {
|
|
fmt.Printf("Error checking credentials: %v\n", err)
|
|
} else if hasAnthropicCreds {
|
|
if creds, err := cm.GetAnthropicCredentials(); err != nil {
|
|
fmt.Printf("Error reading credentials: %v\n", err)
|
|
} else {
|
|
authType := "API Key"
|
|
status := "✓ Authenticated"
|
|
|
|
if creds.Type == "oauth" {
|
|
authType = "OAuth"
|
|
if creds.IsExpired() {
|
|
status = "⚠️ Token expired (will refresh automatically)"
|
|
} else if creds.NeedsRefresh() {
|
|
status = "⚠️ Token expires soon (will refresh automatically)"
|
|
}
|
|
}
|
|
|
|
fmt.Printf("%s (%s, stored %s)\n", status, authType, creds.CreatedAt.Format("2006-01-02 15:04:05"))
|
|
}
|
|
} else {
|
|
fmt.Println("✗ Not authenticated")
|
|
// Check if environment variable is set
|
|
if os.Getenv("ANTHROPIC_API_KEY") != "" {
|
|
fmt.Println(" (ANTHROPIC_API_KEY environment variable is set)")
|
|
}
|
|
}
|
|
|
|
// Check OpenAI credentials
|
|
fmt.Print("\nOpenAI: ")
|
|
if hasOpenAICreds, err := cm.HasOpenAICredentials(); err != nil {
|
|
fmt.Printf("Error checking credentials: %v\n", err)
|
|
} else if hasOpenAICreds {
|
|
if creds, err := cm.GetOpenAICredentials(); err != nil {
|
|
fmt.Printf("Error reading credentials: %v\n", err)
|
|
} else {
|
|
authType := "API Key"
|
|
status := "✓ Authenticated"
|
|
|
|
if creds.Type == "oauth" {
|
|
authType = "OAuth (ChatGPT/Codex)"
|
|
if creds.IsExpired() {
|
|
status = "⚠️ Token expired (will refresh automatically)"
|
|
} else if creds.NeedsRefresh() {
|
|
status = "⚠️ Token expires soon (will refresh automatically)"
|
|
}
|
|
}
|
|
|
|
accountInfo := ""
|
|
if creds.Type == "oauth" && creds.AccountID != "" {
|
|
accountInfo = fmt.Sprintf(" [%s]", creds.AccountID)
|
|
}
|
|
|
|
fmt.Printf("%s (%s%s, stored %s)\n", status, authType, accountInfo, creds.CreatedAt.Format("2006-01-02 15:04:05"))
|
|
}
|
|
} else {
|
|
fmt.Println("✗ Not authenticated")
|
|
// Check if environment variable is set
|
|
if os.Getenv("OPENAI_API_KEY") != "" {
|
|
fmt.Println(" (OPENAI_API_KEY environment variable is set)")
|
|
}
|
|
}
|
|
|
|
// Check GitHub Copilot credentials
|
|
fmt.Print("\nGitHub Copilot: ")
|
|
if hasCopilotCreds, err := cm.HasCopilotCredentials(); err != nil {
|
|
fmt.Printf("Error checking credentials: %v\n", err)
|
|
} else if hasCopilotCreds {
|
|
if creds, err := cm.GetCopilotCredentials(); err != nil {
|
|
fmt.Printf("Error reading credentials: %v\n", err)
|
|
} else {
|
|
status := "✓ Authenticated"
|
|
if creds.IsExpired() {
|
|
status = "⚠️ Token expired (will refresh automatically)"
|
|
} else if creds.NeedsRefresh() {
|
|
status = "⚠️ Token expires soon (will refresh automatically)"
|
|
}
|
|
|
|
fmt.Printf("%s (GitHub OAuth, stored %s)\n", status, creds.CreatedAt.Format("2006-01-02 15:04:05"))
|
|
}
|
|
} else {
|
|
fmt.Println("✗ Not authenticated")
|
|
}
|
|
|
|
fmt.Println("\nTo authenticate with a provider:")
|
|
fmt.Println(" kit auth login anthropic")
|
|
fmt.Println(" kit auth login openai")
|
|
fmt.Println(" kit auth login copilot")
|
|
|
|
return nil
|
|
}
|
|
|
|
func loginAnthropic() error {
|
|
cm, err := kit.NewCredentialManager()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize credential manager: %w", err)
|
|
}
|
|
|
|
// Check if already authenticated
|
|
if hasAuth, err := cm.HasAnthropicCredentials(); err == nil && hasAuth {
|
|
var reauth bool
|
|
err := huh.NewConfirm().
|
|
Title("You are already authenticated with Anthropic").
|
|
Description("Do you want to re-authenticate?").
|
|
Affirmative("Yes").
|
|
Negative("No").
|
|
Value(&reauth).
|
|
Run()
|
|
if err != nil || !reauth {
|
|
fmt.Println("Authentication cancelled.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Create OAuth client
|
|
client := auth.NewOAuthClient()
|
|
|
|
// Generate authorization URL
|
|
fmt.Println("🔐 Starting OAuth authentication with Anthropic...")
|
|
authData, err := client.GetAuthorizationURL()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate authorization URL: %w", err)
|
|
}
|
|
|
|
// Display URL and try to open browser
|
|
fmt.Println("\n📱 Opening your browser for authentication...")
|
|
fmt.Println("If the browser doesn't open automatically, please visit this URL:")
|
|
fmt.Printf("\n%s\n\n", authData.URL)
|
|
|
|
// Try to open browser
|
|
auth.TryOpenBrowser(authData.URL)
|
|
|
|
// Wait for user to complete OAuth flow
|
|
fmt.Println("After authorizing the application, you'll receive an authorization code.")
|
|
|
|
var code string
|
|
err = huh.NewInput().
|
|
Title("Authorization code").
|
|
Description("Paste the code from your browser").
|
|
Value(&code).
|
|
Run()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read authorization code: %w", err)
|
|
}
|
|
code = strings.TrimSpace(code)
|
|
|
|
if code == "" {
|
|
return fmt.Errorf("authorization code cannot be empty")
|
|
}
|
|
|
|
// Exchange code for tokens
|
|
fmt.Println("\n🔄 Exchanging authorization code for access token...")
|
|
creds, err := client.ExchangeCode(code, authData.Verifier)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to exchange authorization code: %w", err)
|
|
}
|
|
|
|
// Store the credentials
|
|
if err := cm.SetOAuthCredentials(creds); err != nil {
|
|
return fmt.Errorf("failed to store credentials: %w", err)
|
|
}
|
|
|
|
fmt.Println("✅ Successfully authenticated with Anthropic!")
|
|
fmt.Printf("📁 Credentials stored in: %s\n", cm.GetCredentialsPath())
|
|
fmt.Println("\n🎉 Your OAuth credentials will now be used for Anthropic API calls.")
|
|
fmt.Println("💡 You can check your authentication status with: kit auth status")
|
|
|
|
// Set default model if requested
|
|
if err := setDefaultModelIfRequested("anthropic"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remind users how to set this as default if they didn't use --set-default
|
|
if !loginSetDefault {
|
|
fmt.Println("\n💡 To set Anthropic as your default model, run:")
|
|
fmt.Println(" kit auth login anthropic --set-default")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func logoutAnthropic() error {
|
|
cm, err := kit.NewCredentialManager()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize credential manager: %w", err)
|
|
}
|
|
|
|
// Check if authenticated
|
|
hasAuth, err := cm.HasAnthropicCredentials()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check authentication status: %w", err)
|
|
}
|
|
|
|
if !hasAuth {
|
|
fmt.Println("You are not currently authenticated with Anthropic.")
|
|
return nil
|
|
}
|
|
|
|
// Confirm logout
|
|
var confirm bool
|
|
err = huh.NewConfirm().
|
|
Title("Remove Anthropic credentials").
|
|
Description("Are you sure you want to remove your stored credentials?").
|
|
Affirmative("Yes").
|
|
Negative("No").
|
|
Value(&confirm).
|
|
Run()
|
|
if err != nil || !confirm {
|
|
fmt.Println("Logout cancelled.")
|
|
return nil
|
|
}
|
|
|
|
// Remove credentials
|
|
if err := cm.RemoveAnthropicCredentials(); err != nil {
|
|
return fmt.Errorf("failed to remove credentials: %w", err)
|
|
}
|
|
|
|
fmt.Println("✓ Successfully logged out from Anthropic!")
|
|
fmt.Println("You will need to use environment variables or command-line flags for authentication.")
|
|
|
|
return nil
|
|
}
|
|
|
|
func loginOpenAI() error {
|
|
cm, err := kit.NewCredentialManager()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize credential manager: %w", err)
|
|
}
|
|
|
|
// Check if already authenticated
|
|
if hasAuth, err := cm.HasOpenAICredentials(); err == nil && hasAuth {
|
|
var reauth bool
|
|
err := huh.NewConfirm().
|
|
Title("You are already authenticated with OpenAI (ChatGPT/Codex)").
|
|
Description("Do you want to re-authenticate?").
|
|
Affirmative("Yes").
|
|
Negative("No").
|
|
Value(&reauth).
|
|
Run()
|
|
if err != nil || !reauth {
|
|
fmt.Println("Authentication cancelled.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Create OAuth client
|
|
client := auth.NewOpenAIOAuthClient()
|
|
|
|
// Generate authorization URL
|
|
fmt.Println("🔐 Starting OAuth authentication with OpenAI (ChatGPT/Codex)...")
|
|
fmt.Println("This will open your browser to authenticate with your ChatGPT account.")
|
|
fmt.Println()
|
|
|
|
authData, err := client.GetAuthorizationURL()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate authorization URL: %w", err)
|
|
}
|
|
|
|
// Start local callback server
|
|
callbackServer, err := startOpenAICallbackServer(authData.State)
|
|
if err != nil {
|
|
fmt.Printf("⚠️ Could not start local callback server: %v\n", err)
|
|
fmt.Println("Falling back to manual code entry.")
|
|
}
|
|
if callbackServer != nil {
|
|
defer callbackServer.Close()
|
|
}
|
|
|
|
// Display URL and try to open browser
|
|
fmt.Println("📱 Opening your browser for authentication...")
|
|
fmt.Println("If the browser doesn't open automatically, please visit this URL:")
|
|
fmt.Printf("\n%s\n\n", authData.URL)
|
|
|
|
// Try to open browser
|
|
auth.TryOpenBrowser(authData.URL)
|
|
|
|
// Wait for callback or manual input
|
|
var code string
|
|
if callbackServer != nil {
|
|
fmt.Println("Waiting for browser authentication...")
|
|
select {
|
|
case callbackCode := <-callbackServer.CodeChan:
|
|
if callbackCode != "" {
|
|
code = callbackCode
|
|
fmt.Println("✓ Received authorization code from browser callback.")
|
|
}
|
|
case <-time.After(2 * time.Minute):
|
|
fmt.Println("\n⏱️ Timeout waiting for browser callback.")
|
|
callbackServer.Close()
|
|
}
|
|
}
|
|
|
|
// If no code from callback, prompt for manual entry
|
|
if code == "" {
|
|
fmt.Println("\nAfter authorizing, paste the callback URL or authorization code below.")
|
|
fmt.Println("(The callback URL will look like: http://localhost:1455/auth/callback?code=...&state=...)")
|
|
fmt.Println()
|
|
|
|
var input string
|
|
err = huh.NewInput().
|
|
Title("Callback URL or Code").
|
|
Description("Paste the full callback URL or just the authorization code").
|
|
Value(&input).
|
|
Run()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read input: %w", err)
|
|
}
|
|
input = strings.TrimSpace(input)
|
|
|
|
if input == "" {
|
|
return fmt.Errorf("authorization code cannot be empty")
|
|
}
|
|
|
|
// Parse the input (could be full URL or just code)
|
|
parsedCode, parsedState := auth.ParseOpenAIAuthorizationInput(input)
|
|
if parsedCode == "" {
|
|
return fmt.Errorf("could not extract authorization code from input")
|
|
}
|
|
|
|
// Validate state if provided
|
|
if parsedState != "" && parsedState != authData.State {
|
|
return fmt.Errorf("state mismatch - possible security issue")
|
|
}
|
|
code = parsedCode
|
|
}
|
|
|
|
// Exchange code for tokens
|
|
fmt.Println("\n🔄 Exchanging authorization code for access token...")
|
|
creds, err := client.ExchangeCode(code, authData.Verifier)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to exchange authorization code: %w", err)
|
|
}
|
|
|
|
// Store the credentials
|
|
if err := cm.SetOpenAIOAuthCredentials(creds); err != nil {
|
|
return fmt.Errorf("failed to store credentials: %w", err)
|
|
}
|
|
|
|
fmt.Println("✅ Successfully authenticated with OpenAI (ChatGPT/Codex)!")
|
|
fmt.Printf("📁 Credentials stored in: %s\n", cm.GetCredentialsPath())
|
|
fmt.Printf("👤 Account ID: %s\n", creds.AccountID)
|
|
fmt.Println("\n🎉 Your OAuth credentials will now be used for OpenAI API calls.")
|
|
fmt.Println("💡 You can check your authentication status with: kit auth status")
|
|
|
|
// Set default model if requested
|
|
if err := setDefaultModelIfRequested("openai"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remind users how to set this as default if they didn't use --set-default
|
|
if !loginSetDefault {
|
|
fmt.Println("\n💡 To set OpenAI as your default model, run:")
|
|
fmt.Println(" kit auth login openai --set-default")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loginCopilot authenticates GitHub Copilot using GitHub device flow.
|
|
func loginCopilot(ctx context.Context) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
cm, err := kit.NewCredentialManager()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize credential manager: %w", err)
|
|
}
|
|
|
|
if hasAuth, err := cm.HasCopilotCredentials(); err == nil && hasAuth {
|
|
var reauth bool
|
|
err := huh.NewConfirm().
|
|
Title("You are already authenticated with GitHub Copilot").
|
|
Description("Do you want to re-authenticate?").
|
|
Affirmative("Yes").
|
|
Negative("No").
|
|
Value(&reauth).
|
|
Run()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to prompt for re-authentication: %w", err)
|
|
}
|
|
if !reauth {
|
|
fmt.Println("Authentication cancelled.")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
client := auth.NewCopilotOAuthClient()
|
|
|
|
fmt.Println("🔐 Starting GitHub Copilot authentication...")
|
|
fmt.Println("This uses GitHub device login and requires an active GitHub Copilot subscription.")
|
|
fmt.Println("Experimental: this uses VS Code Copilot Chat client identifiers.")
|
|
fmt.Println()
|
|
|
|
deviceCode, err := client.StartDeviceFlow(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to start GitHub device login: %w", err)
|
|
}
|
|
|
|
fmt.Println("📱 Open this page and enter the code:")
|
|
fmt.Printf("\n%s\n\n", deviceCode.VerificationURI)
|
|
fmt.Printf("Code: %s\n\n", deviceCode.UserCode)
|
|
auth.TryOpenBrowser(deviceCode.VerificationURI)
|
|
|
|
fmt.Println("Waiting for GitHub authorization...")
|
|
githubToken, err := client.PollDeviceToken(ctx, deviceCode)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to complete GitHub device login: %w", err)
|
|
}
|
|
|
|
fmt.Println("\n🔄 Exchanging GitHub token for Copilot access token...")
|
|
creds, err := client.ExchangeGitHubToken(ctx, githubToken)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get GitHub Copilot token: %w", err)
|
|
}
|
|
|
|
if err := cm.SetCopilotOAuthCredentials(creds); err != nil {
|
|
return fmt.Errorf("failed to store credentials: %w", err)
|
|
}
|
|
|
|
fmt.Println("✅ Successfully authenticated with GitHub Copilot!")
|
|
fmt.Printf("📁 Credentials stored in: %s\n", cm.GetCredentialsPath())
|
|
fmt.Println("\n🎉 Your GitHub Copilot credentials will now be used for copilot/* models.")
|
|
fmt.Println("💡 You can check your authentication status with: kit auth status")
|
|
|
|
if err := setDefaultModelIfRequested("copilot"); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !loginSetDefault {
|
|
fmt.Println("\n💡 To set Copilot as your default model, run:")
|
|
fmt.Println(" kit auth login copilot --set-default")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// callbackServer holds the HTTP server and channel for receiving the OAuth callback
|
|
type callbackServer struct {
|
|
Server *http.Server
|
|
CodeChan chan string
|
|
State string
|
|
}
|
|
|
|
// Close shuts down the callback server
|
|
func (cs *callbackServer) Close() {
|
|
if cs.Server != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = cs.Server.Shutdown(ctx)
|
|
}
|
|
}
|
|
|
|
// startOpenAICallbackServer starts a local HTTP server to receive the OAuth callback
|
|
func startOpenAICallbackServer(expectedState string) (*callbackServer, error) {
|
|
codeChan := make(chan string, 1)
|
|
|
|
mux := http.NewServeMux()
|
|
server := &http.Server{
|
|
Addr: "127.0.0.1:1455",
|
|
Handler: mux,
|
|
}
|
|
|
|
mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
|
|
// Check state
|
|
state := r.URL.Query().Get("state")
|
|
if state != expectedState {
|
|
http.Error(w, "State mismatch", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
code := r.URL.Query().Get("code")
|
|
if code == "" {
|
|
http.Error(w, "Missing authorization code", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Send code to channel
|
|
select {
|
|
case codeChan <- code:
|
|
default:
|
|
}
|
|
|
|
// Return success page
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Authentication Successful</title></head>
|
|
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
|
|
<h1>✓ Authentication Successful</h1>
|
|
<p>You can close this window and return to the terminal.</p>
|
|
</body>
|
|
</html>`)
|
|
})
|
|
|
|
// Try to start server
|
|
listener, err := net.Listen("tcp", "127.0.0.1:1455")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("port 1455 not available: %w", err)
|
|
}
|
|
_ = listener.Close()
|
|
|
|
go func() {
|
|
_ = server.ListenAndServe()
|
|
}()
|
|
|
|
return &callbackServer{
|
|
Server: server,
|
|
CodeChan: codeChan,
|
|
State: expectedState,
|
|
}, nil
|
|
}
|
|
|
|
func logoutOpenAI() error {
|
|
cm, err := kit.NewCredentialManager()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize credential manager: %w", err)
|
|
}
|
|
|
|
// Check if authenticated
|
|
hasAuth, err := cm.HasOpenAICredentials()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check authentication status: %w", err)
|
|
}
|
|
|
|
if !hasAuth {
|
|
fmt.Println("You are not currently authenticated with OpenAI.")
|
|
return nil
|
|
}
|
|
|
|
// Confirm logout
|
|
var confirm bool
|
|
err = huh.NewConfirm().
|
|
Title("Remove OpenAI credentials").
|
|
Description("Are you sure you want to remove your stored credentials?").
|
|
Affirmative("Yes").
|
|
Negative("No").
|
|
Value(&confirm).
|
|
Run()
|
|
if err != nil || !confirm {
|
|
fmt.Println("Logout cancelled.")
|
|
return nil
|
|
}
|
|
|
|
// Remove credentials
|
|
if err := cm.RemoveOpenAICredentials(); err != nil {
|
|
return fmt.Errorf("failed to remove credentials: %w", err)
|
|
}
|
|
|
|
fmt.Println("✓ Successfully logged out from OpenAI!")
|
|
fmt.Println("You will need to use environment variables or command-line flags for authentication.")
|
|
|
|
return nil
|
|
}
|
|
|
|
func logoutCopilot() error {
|
|
cm, err := kit.NewCredentialManager()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize credential manager: %w", err)
|
|
}
|
|
|
|
hasAuth, err := cm.HasCopilotCredentials()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check authentication status: %w", err)
|
|
}
|
|
|
|
if !hasAuth {
|
|
fmt.Println("You are not currently authenticated with GitHub Copilot.")
|
|
return nil
|
|
}
|
|
|
|
var confirm bool
|
|
err = huh.NewConfirm().
|
|
Title("Remove GitHub Copilot credentials").
|
|
Description("Are you sure you want to remove your stored credentials?").
|
|
Affirmative("Yes").
|
|
Negative("No").
|
|
Value(&confirm).
|
|
Run()
|
|
if err != nil || !confirm {
|
|
fmt.Println("Logout cancelled.")
|
|
return nil
|
|
}
|
|
|
|
if err := cm.RemoveCopilotCredentials(); err != nil {
|
|
return fmt.Errorf("failed to remove credentials: %w", err)
|
|
}
|
|
|
|
fmt.Println("✓ Successfully logged out from GitHub Copilot!")
|
|
fmt.Println("You will need to authenticate again with 'kit auth login copilot'.")
|
|
fmt.Println("Tip: this removes local credentials only. Revoke the GitHub OAuth grant at https://github.com/settings/applications")
|
|
|
|
return nil
|
|
}
|