diff --git a/config/config.go b/config/config.go index 0de1cdc..d9315ca 100644 --- a/config/config.go +++ b/config/config.go @@ -23,6 +23,7 @@ type Config struct { BlacklistPatterns []string `mapstructure:"blacklist_patterns"` OpenRouter OpenRouterConfig `mapstructure:"openrouter"` AzureOpenAI AzureOpenAIConfig `mapstructure:"azure_openai"` + Copilot CopilotConfig `mapstructure:"copilot"` Prompts PromptsConfig `mapstructure:"prompts"` } @@ -41,6 +42,12 @@ type AzureOpenAIConfig struct { DeploymentName string `mapstructure:"deployment_name"` } +// CopilotConfig holds GitHub Copilot configuration +type CopilotConfig struct { + Enabled bool `mapstructure:"enabled"` + Model string `mapstructure:"model"` +} + // PromptsConfig holds customizable prompt templates type PromptsConfig struct { BaseSystem string `mapstructure:"base_system"` @@ -66,6 +73,10 @@ func DefaultConfig() *Config { Model: "google/gemini-2.5-flash-preview", }, AzureOpenAI: AzureOpenAIConfig{}, + Copilot: CopilotConfig{ + Enabled: true, + Model: "gpt-4o", + }, Prompts: PromptsConfig{ BaseSystem: ``, ChatAssistant: ``, diff --git a/copilot.md b/copilot.md new file mode 100644 index 0000000..5de8381 --- /dev/null +++ b/copilot.md @@ -0,0 +1,251 @@ +# GitHub Copilot Integration Implementation Plan + +## Overview +This document outlines the implementation plan for adding GitHub Copilot support to TmuxAI, allowing users to leverage Copilot's AI models (GPT-4o, Claude 3.5 Sonnet, etc.) directly within their tmux sessions. + +## Key Features +- OAuth device flow authentication (no manual token hunting) +- Automatic token refresh for Copilot's short-lived API tokens +- Model discovery and selection +- Seamless provider switching between Copilot, OpenRouter, and Azure OpenAI +- Zero-config startup (no API key required to launch TmuxAI) + +## Architecture Changes + +### 1. Remove Hard API Key Requirement +**File**: `internal/manager.go` +- Modify `NewManager()` to allow initialization without API keys +- Add `HasConfiguredProvider()` method to check if any provider is available +- Track provider status with new field: `ProviderStatus string` + +### 2. Provider Priority System +Order of preference: +1. GitHub Copilot (if authenticated) +2. Azure OpenAI (if configured) +3. OpenRouter (if configured) +4. None (show setup instructions) + +## Implementation Steps + +### Step 1: Config Structure Extension +**File**: `config/config.go` +```go +type CopilotConfig struct { + Enabled bool `mapstructure:"enabled"` + Model string `mapstructure:"model"` + AuthToken string // Loaded from file, not config +} +``` + +Add to main Config struct: +```go +type Config struct { + // ... existing fields + Copilot CopilotConfig `mapstructure:"copilot"` +} +``` + +### Step 2: Copilot Authentication Module +**New File**: `internal/copilot_auth.go` + +Key components: +- `RequestDeviceCode()` - Initiate OAuth device flow +- `PollForAuth(deviceCode)` - Poll GitHub for auth completion +- `SaveAuthToken(token)` - Store in `~/.config/tmuxai/.copilot-auth-token` +- `LoadAuthToken()` - Read stored auth token +- `GetCopilotAPIToken(authToken)` - Exchange for short-lived API token +- Token cache with 1-hour TTL + +OAuth endpoints: +- Device code: `https://github.com/login/device/code` +- Token exchange: `https://github.com/login/oauth/access_token` +- Copilot token: `https://api.github.com/copilot_internal/v2/token` + +Client ID: `Iv1.b507a08c87ecfe98` + +### Step 3: Extend AI Client +**File**: `internal/ai_client.go` + +Modifications: +```go +type AiClient struct { + config *config.Config + client *http.Client + copilotAuthToken string + copilotAPIToken *CopilotToken // Cached with expiry +} + +type CopilotToken struct { + Token string + ExpiresAt time.Time +} +``` + +Update `ChatCompletion()`: +- Check provider priority +- If Copilot: refresh API token if expired +- Set endpoint to `https://api.githubcopilot.com` +- Use Copilot token in Authorization header + +### Step 4: Command Interface +**File**: `internal/chat_command.go` + +New commands: +- `/copilot login` - Start OAuth device flow +- `/copilot logout` - Clear stored tokens +- `/copilot status` - Show auth status +- `/copilot models` - List available models + +Implementation: +```go +case prefixMatch(commandPrefix, "/copilot"): + m.processCopilotCommand(parts) + return +``` + +### Step 5: Message Interception +**File**: `internal/process_message.go` + +Before processing messages, check: +```go +if !m.HasConfiguredProvider() { + m.ShowProviderSetupInstructions() + return +} +``` + +Setup instructions: +``` +No API provider configured. Please configure one: + +• GitHub Copilot (recommended): + /copilot login + +• OpenRouter: + export TMUXAI_OPENROUTER_API_KEY="your-key" + +• Azure OpenAI: + Set credentials in ~/.config/tmuxai/config.yaml + +Run /help for more information. +``` + +### Step 6: Model Discovery +**Endpoint**: `GET https://api.githubcopilot.com/models` + +Headers: +``` +Authorization: Bearer {copilot_api_token} +Content-Type: application/json +Copilot-Integration-Id: vscode-chat +``` + +Parse response and cache available models. + +## User Experience Flow + +### First-Time Setup +1. User runs `tmuxai` (no API key required) +2. User sends message → sees provider setup instructions +3. User runs `/copilot login` +4. TmuxAI shows: + ``` + First copy your one-time code: + XXXX-XXXX + + Then visit: https://github.com/login/device + + Waiting for authentication... + Press Enter to check status... + ``` +5. User completes GitHub auth +6. TmuxAI confirms: "Authentication successful!" +7. Ready to use + +### Subsequent Usage +1. TmuxAI loads saved auth token on startup +2. Automatically refreshes Copilot API token as needed +3. Seamless experience with no manual intervention + +## Configuration Example + +```yaml +# ~/.config/tmuxai/config.yaml +copilot: + enabled: true + model: gpt-4o # or claude-3-5-sonnet, o1-mini, etc. + +# Copilot takes precedence if enabled and authenticated +openrouter: + api_key: ${OPENROUTER_API_KEY} + model: gpt-4o-mini +``` + +## Testing Plan + +1. **Auth Flow Testing** + - Test device code request + - Test auth polling with timeout + - Test token storage and retrieval + - Test invalid/expired tokens + +2. **Provider Switching** + - Test Copilot → OpenRouter fallback + - Test missing all providers + - Test config override via `/config set` + +3. **Model Discovery** + - Test listing available models + - Test model selection + - Test invalid model handling + +4. **Token Refresh** + - Test automatic refresh on expiry + - Test handling of refresh failures + - Test concurrent request handling + +## Error Handling + +1. **Auth Failures** + - Network errors → retry with backoff + - Invalid device code → restart flow + - Expired auth token → prompt re-login + +2. **API Failures** + - Token refresh fails → fall back to other providers + - Model not available → suggest alternatives + - Rate limits → show clear error message + +## Security Considerations + +1. **Token Storage** + - Store auth token with 0600 permissions + - Never log tokens + - Clear tokens on `/copilot logout` + +2. **Token Transmission** + - Always use HTTPS + - Include proper headers + - No token in URL parameters + +## Migration Path for Existing Users + +1. Existing users continue working with current providers +2. Copilot becomes available via `/copilot login` +3. No breaking changes to existing configs +4. Can disable Copilot with `copilot.enabled: false` + +## Implementation Timeline + +- Phase 1: Core auth flow and token management +- Phase 2: AI client integration +- Phase 3: Command interface and UX +- Phase 4: Model discovery and advanced features +- Phase 5: Testing and documentation + +## References + +- [GitHub Device Flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow) +- [Copilot REST API](https://docs.github.com/en/copilot/using-github-copilot/using-github-copilot-in-the-command-line) +- [Shell-ask implementation](https://github.com/egoist/shell-ask) +- [Aider Copilot docs](https://aider.chat/docs/llms/copilot.html) \ No newline at end of file diff --git a/internal/ai_client.go b/internal/ai_client.go index 885f3cd..dfcbbe9 100644 --- a/internal/ai_client.go +++ b/internal/ai_client.go @@ -15,10 +15,11 @@ import ( "github.com/alvinunreal/tmuxai/logger" ) -// AiClient represents an AI client for interacting with OpenAI-compatible APIs including Azure OpenAI +// AiClient represents an AI client for interacting with OpenAI-compatible APIs including Azure OpenAI and Copilot type AiClient struct { - config *config.Config - client *http.Client + config *config.Config + client *http.Client + copilotAuth *CopilotAuth } // Message represents a chat message @@ -49,11 +50,16 @@ type ChatCompletionResponse struct { func NewAiClient(cfg *config.Config) *AiClient { return &AiClient{ - config: cfg, - client: &http.Client{}, + config: cfg, + client: &http.Client{}, } } +// SetCopilotAuth sets the CopilotAuth instance to use +func (c *AiClient) SetCopilotAuth(auth *CopilotAuth) { + c.copilotAuth = auth +} + // GetResponseFromChatMessages gets a response from the AI based on chat messages func (c *AiClient) GetResponseFromChatMessages(ctx context.Context, chatMessages []ChatMessage, model string) (string, error) { // Convert chat messages to AI client format @@ -98,6 +104,41 @@ func (c *AiClient) ChatCompletion(ctx context.Context, messages []Message, model var url string var apiKeyHeader string var apiKey string + var additionalHeaders map[string]string + + // Check provider priority: Copilot -> Azure -> OpenRouter + if c.config.Copilot.Enabled && c.copilotAuth != nil { + logger.Debug("Copilot enabled, checking authentication...") + if c.copilotAuth.IsAuthenticated() { + logger.Debug("Copilot authenticated, getting API token...") + // Use GitHub Copilot endpoint + copilotToken, err := c.copilotAuth.GetCopilotAPIToken() + if err != nil { + logger.Error("Failed to get Copilot token: %v", err) + // Don't fall back, return the actual error + return "", fmt.Errorf("failed to get Copilot API token: %w", err) + } + url = "https://api.githubcopilot.com/chat/completions" + apiKeyHeader = "Authorization" + apiKey = "Bearer " + copilotToken + additionalHeaders = map[string]string{ + "User-Agent": "GithubCopilot/1.155.0", + "Editor-Plugin-Version": "copilot/1.155.0", + "Editor-Version": "vscode/1.85.1", + "Copilot-Integration-Id": "vscode-chat", + } + // Use Copilot model if specified, otherwise use default + if c.config.Copilot.Model != "" { + reqBody.Model = c.config.Copilot.Model + } + logger.Debug("Using GitHub Copilot with model: %s", reqBody.Model) + goto sendRequest + } else { + logger.Debug("Copilot not authenticated") + } + } else { + logger.Debug("Copilot not enabled or copilotAuth is nil: enabled=%v, copilotAuth=%v", c.config.Copilot.Enabled, c.copilotAuth != nil) + } if c.config.AzureOpenAI.APIKey != "" { // Use Azure OpenAI endpoint @@ -111,14 +152,18 @@ func (c *AiClient) ChatCompletion(ctx context.Context, messages []Message, model // Azure endpoint doesn't expect model in body reqBody.Model = "" - } else { + } else if c.config.OpenRouter.APIKey != "" { // default OpenRouter/OpenAI compatible endpoint baseURL := strings.TrimSuffix(c.config.OpenRouter.BaseURL, "/") url = baseURL + "/chat/completions" apiKeyHeader = "Authorization" apiKey = "Bearer " + c.config.OpenRouter.APIKey + } else { + return "", fmt.Errorf("no API provider configured") } +sendRequest: + reqJSON, err := json.Marshal(reqBody) if err != nil { logger.Error("Failed to marshal request: %v", err) @@ -138,6 +183,11 @@ func (c *AiClient) ChatCompletion(ctx context.Context, messages []Message, model req.Header.Set("HTTP-Referer", "https://github.com/alvinunreal/tmuxai") req.Header.Set("X-Title", "TmuxAI") + // Add any additional headers (e.g., for Copilot) + for k, v := range additionalHeaders { + req.Header.Set(k, v) + } + // Log the request details for debugging before sending logger.Debug("Sending API request to: %s with model: %s", url, model) diff --git a/internal/chat_command.go b/internal/chat_command.go index 28d6df2..4f4a4c6 100644 --- a/internal/chat_command.go +++ b/internal/chat_command.go @@ -3,6 +3,8 @@ package internal import ( "fmt" "os" + "os/exec" + "runtime" "strings" "time" @@ -18,6 +20,10 @@ const helpMessage = `Available commands: - /prepare: Prepare the pane for TmuxAI automation - /watch : Start watch mode - /squash: Summarize the chat history +- /copilot login: Authenticate with GitHub Copilot +- /copilot logout: Remove Copilot authentication +- /copilot status: Show Copilot authentication status +- /copilot models: List available Copilot models - /exit: Exit the application` var commands = []string{ @@ -30,6 +36,7 @@ var commands = []string{ "/prepare", "/config", "/squash", + "/copilot", } // checks if the given content is a command @@ -163,6 +170,10 @@ Watch for: ` + watchDesc m.Println("Usage: /watch ") return + case prefixMatch(commandPrefix, "/copilot"): + m.processCopilotCommand(parts) + return + case prefixMatch(commandPrefix, "/config"): // Helper function to check if a key is allowed isKeyAllowed := func(key string) bool { @@ -245,3 +256,152 @@ func (m *Manager) formatInfo() { fmt.Println(pane.FormatInfo(formatter)) } } + +// processCopilotCommand handles /copilot subcommands +func (m *Manager) processCopilotCommand(parts []string) { + if len(parts) < 2 { + m.Println("Usage: /copilot ") + return + } + + subcommand := parts[1] + switch subcommand { + case "login": + m.copilotLogin() + case "logout": + m.copilotLogout() + case "status": + m.copilotStatus() + case "models": + m.copilotListModels() + default: + m.Println(fmt.Sprintf("Unknown copilot command: %s", subcommand)) + m.Println("Available commands: login, logout, status, models") + } +} + +// copilotLogin handles the Copilot authentication flow +func (m *Manager) copilotLogin() { + // Request device code + deviceCode, err := m.CopilotAuth.RequestDeviceCode() + if err != nil { + m.Println(fmt.Sprintf("Failed to request device code: %v", err)) + return + } + + // Show instructions in a single formatted message + authMessage := fmt.Sprintf(` +GitHub Copilot Authentication + +1. Copy your one-time code: %s + +2. Visit GitHub to authorize: %s + +Waiting for authentication... +`, deviceCode.UserCode, deviceCode.VerificationURI) + + m.Println(authMessage) + + // Try to open the browser automatically + openBrowser(deviceCode.VerificationURI) + + // Start polling in background + go func() { + interval := time.Duration(deviceCode.Interval) * time.Second + maxAttempts := deviceCode.ExpiresIn / deviceCode.Interval + + for i := 0; i < maxAttempts; i++ { + time.Sleep(interval) + + tokenResp, err := m.CopilotAuth.PollForAccessToken(deviceCode) + if err != nil { + fmt.Printf("\nAuthentication failed: %v\n", err) + return + } + + if tokenResp != nil { + // Save the token + if err := m.CopilotAuth.SaveAuthToken(tokenResp.AccessToken); err != nil { + fmt.Printf("\nFailed to save auth token: %v\n", err) + return + } + + // Use fmt.Println to avoid the prompt prefix in async context + fmt.Println("\n✓ Authentication successful!") + fmt.Println("You can now use GitHub Copilot models.") + fmt.Println() // Extra line for clarity + return + } + } + + fmt.Println("\nAuthentication timed out. Please try again.") + }() +} + +// copilotLogout removes the stored authentication +func (m *Manager) copilotLogout() { + if err := m.CopilotAuth.RemoveAuthToken(); err != nil { + m.Println(fmt.Sprintf("Failed to logout: %v", err)) + return + } + m.Println("Successfully logged out of GitHub Copilot.") +} + +// copilotStatus shows the current authentication status +func (m *Manager) copilotStatus() { + if m.CopilotAuth.IsAuthenticated() { + m.Println("✓ Authenticated with GitHub Copilot") + + // Try to get current model from config + model := m.Config.Copilot.Model + if model == "" { + model = "gpt-4o" + } + m.Println(fmt.Sprintf("Current model: %s", model)) + } else { + m.Println("✗ Not authenticated with GitHub Copilot") + m.Println("Run '/copilot login' to authenticate") + } +} + +// copilotListModels lists available Copilot models +func (m *Manager) copilotListModels() { + if !m.CopilotAuth.IsAuthenticated() { + m.Println("Not authenticated. Run '/copilot login' first.") + return + } + + models, err := m.CopilotAuth.ListModels() + if err != nil { + m.Println(fmt.Sprintf("Failed to list models: %v", err)) + return + } + + m.Println("\nAvailable GitHub Copilot models:") + for _, model := range models { + prefix := " " + if model == m.Config.Copilot.Model { + prefix = "→ " + } + m.Println(fmt.Sprintf("%s%s", prefix, model)) + } +} + +// openBrowser tries to open a URL in the default browser +func openBrowser(url string) { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return + } + + // Run in background, ignore errors (browser might not be available in SSH sessions) + go cmd.Run() +} diff --git a/internal/copilot_auth.go b/internal/copilot_auth.go new file mode 100644 index 0000000..2c07809 --- /dev/null +++ b/internal/copilot_auth.go @@ -0,0 +1,309 @@ +package internal + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/alvinunreal/tmuxai/config" + "github.com/alvinunreal/tmuxai/logger" +) + +const ( + clientID = "Iv1.b507a08c87ecfe98" + deviceCodeURL = "https://github.com/login/device/code" + accessTokenURL = "https://github.com/login/oauth/access_token" + copilotTokenURL = "https://api.github.com/copilot_internal/v2/token" + copilotAuthTokenFile = ".copilot-auth-token" +) + +// DeviceCodeResponse represents GitHub's device code response +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// AccessTokenResponse represents GitHub's access token response +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error,omitempty"` +} + +// CopilotTokenResponse represents Copilot's API token response +type CopilotTokenResponse struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expires_at"` +} + +// CopilotAuth manages GitHub Copilot authentication +type CopilotAuth struct { + config *config.Config + client *http.Client + authToken string + copilotToken *CopilotTokenResponse + copilotTokenMux sync.RWMutex +} + +// NewCopilotAuth creates a new CopilotAuth instance +func NewCopilotAuth(cfg *config.Config) *CopilotAuth { + return &CopilotAuth{ + config: cfg, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// RequestDeviceCode initiates the OAuth device flow +func (c *CopilotAuth) RequestDeviceCode() (*DeviceCodeResponse, error) { + data := url.Values{ + "client_id": {clientID}, + "scope": {"read:user"}, + } + + req, err := http.NewRequest("POST", deviceCodeURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create device code request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request device code: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("device code request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var deviceCode DeviceCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&deviceCode); err != nil { + return nil, fmt.Errorf("failed to decode device code response: %w", err) + } + + return &deviceCode, nil +} + +// PollForAccessToken polls GitHub for the access token +func (c *CopilotAuth) PollForAccessToken(deviceCode *DeviceCodeResponse) (*AccessTokenResponse, error) { + data := url.Values{ + "client_id": {clientID}, + "device_code": {deviceCode.DeviceCode}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + } + + req, err := http.NewRequest("POST", accessTokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create access token request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request access token: %w", err) + } + defer resp.Body.Close() + + var tokenResp AccessTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return nil, fmt.Errorf("failed to decode access token response: %w", err) + } + + if tokenResp.Error != "" { + if tokenResp.Error == "authorization_pending" { + return nil, nil // Continue polling + } + return nil, fmt.Errorf("access token error: %s", tokenResp.Error) + } + + if tokenResp.AccessToken == "" { + return nil, fmt.Errorf("received empty access token") + } + + return &tokenResp, nil +} + +// GetCopilotAPIToken exchanges GitHub auth token for Copilot API token +func (c *CopilotAuth) GetCopilotAPIToken() (string, error) { + c.copilotTokenMux.RLock() + if c.copilotToken != nil && time.Now().Unix() < c.copilotToken.ExpiresAt-60 { + token := c.copilotToken.Token + c.copilotTokenMux.RUnlock() + return token, nil + } + c.copilotTokenMux.RUnlock() + + c.copilotTokenMux.Lock() + defer c.copilotTokenMux.Unlock() + + // Double-check after acquiring write lock + if c.copilotToken != nil && time.Now().Unix() < c.copilotToken.ExpiresAt-60 { + return c.copilotToken.Token, nil + } + + if c.authToken == "" { + // Try to load auth token from file + if err := c.LoadAuthToken(); err != nil { + return "", fmt.Errorf("no auth token available, please run /copilot login") + } + } + + req, err := http.NewRequest("GET", copilotTokenURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create copilot token request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.authToken)) + req.Header.Set("Accept", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to request copilot token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("copilot token request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp CopilotTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", fmt.Errorf("failed to decode copilot token response: %w", err) + } + + c.copilotToken = &tokenResp + logger.Debug("Got new Copilot API token, expires at %d", tokenResp.ExpiresAt) + + return tokenResp.Token, nil +} + +// SaveAuthToken saves the GitHub auth token to file +func (c *CopilotAuth) SaveAuthToken(token string) error { + configDir, err := config.GetConfigDir() + if err != nil { + return fmt.Errorf("failed to get config directory: %w", err) + } + + tokenPath := filepath.Join(configDir, copilotAuthTokenFile) + if err := os.WriteFile(tokenPath, []byte(token), 0600); err != nil { + return fmt.Errorf("failed to save auth token: %w", err) + } + + c.authToken = token + logger.Info("Auth token saved successfully") + return nil +} + +// LoadAuthToken loads the GitHub auth token from file +func (c *CopilotAuth) LoadAuthToken() error { + configDir, err := config.GetConfigDir() + if err != nil { + return fmt.Errorf("failed to get config directory: %w", err) + } + + tokenPath := filepath.Join(configDir, copilotAuthTokenFile) + data, err := os.ReadFile(tokenPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("no saved auth token found") + } + return fmt.Errorf("failed to read auth token: %w", err) + } + + c.authToken = strings.TrimSpace(string(data)) + logger.Debug("Loaded auth token from file") + return nil +} + +// RemoveAuthToken removes the saved auth token +func (c *CopilotAuth) RemoveAuthToken() error { + configDir, err := config.GetConfigDir() + if err != nil { + return fmt.Errorf("failed to get config directory: %w", err) + } + + tokenPath := filepath.Join(configDir, copilotAuthTokenFile) + if err := os.Remove(tokenPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove auth token: %w", err) + } + + c.authToken = "" + c.copilotToken = nil + logger.Info("Auth token removed successfully") + return nil +} + +// IsAuthenticated checks if we have a valid auth token +func (c *CopilotAuth) IsAuthenticated() bool { + if c.authToken != "" { + return true + } + // Try to load from file + err := c.LoadAuthToken() + return err == nil && c.authToken != "" +} + +// ListModels fetches available models from Copilot API +func (c *CopilotAuth) ListModels() ([]string, error) { + token, err := c.GetCopilotAPIToken() + if err != nil { + return nil, fmt.Errorf("failed to get copilot token: %w", err) + } + + req, err := http.NewRequest("GET", "https://api.githubcopilot.com/models", nil) + if err != nil { + return nil, fmt.Errorf("failed to create models request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "GithubCopilot/1.155.0") + req.Header.Set("Editor-Plugin-Version", "copilot/1.155.0") + req.Header.Set("Editor-Version", "vscode/1.85.1") + req.Header.Set("Copilot-Integration-Id", "vscode-chat") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request models: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("models request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var modelsResp struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&modelsResp); err != nil { + return nil, fmt.Errorf("failed to decode models response: %w", err) + } + + models := make([]string, len(modelsResp.Data)) + for i, model := range modelsResp.Data { + models[i] = model.ID + } + + return models, nil +} \ No newline at end of file diff --git a/internal/manager.go b/internal/manager.go index 2ebc27b..dda8319 100644 --- a/internal/manager.go +++ b/internal/manager.go @@ -34,6 +34,7 @@ type CommandExecHistory struct { type Manager struct { Config *config.Config AiClient *AiClient + CopilotAuth *CopilotAuth Status string // running, waiting, done PaneId string ExecPane *system.TmuxPaneDetails @@ -50,10 +51,7 @@ type Manager struct { // NewManager creates a new manager agent func NewManager(cfg *config.Config) (*Manager, error) { - if cfg.OpenRouter.APIKey == "" && cfg.AzureOpenAI.APIKey == "" { - fmt.Println("An API key is required. Set OpenRouter or Azure OpenAI credentials in the config file or environment variables.") - return nil, fmt.Errorf("API key required") - } + // Allow starting without API key - we'll check later when needed paneId, err := system.TmuxCurrentPaneId() if err != nil { @@ -76,11 +74,17 @@ func NewManager(cfg *config.Config) (*Manager, error) { } aiClient := NewAiClient(cfg) + copilotAuth := NewCopilotAuth(cfg) + + // Share the same CopilotAuth instance with AiClient + aiClient.SetCopilotAuth(copilotAuth) + os := system.GetOSDetails() manager := &Manager{ Config: cfg, AiClient: aiClient, + CopilotAuth: copilotAuth, PaneId: paneId, Messages: []ChatMessage{}, ExecPane: &system.TmuxPaneDetails{}, @@ -95,6 +99,35 @@ func NewManager(cfg *config.Config) (*Manager, error) { return manager, nil } +// HasConfiguredProvider checks if any API provider is configured +func (m *Manager) HasConfiguredProvider() bool { + // Check Copilot first (highest priority) + if m.Config.Copilot.Enabled && m.CopilotAuth != nil && m.CopilotAuth.IsAuthenticated() { + return true + } + // Check Azure OpenAI + if m.Config.AzureOpenAI.APIKey != "" { + return true + } + // Check OpenRouter + if m.Config.OpenRouter.APIKey != "" { + return true + } + return false +} + +// ShowProviderSetupInstructions shows instructions for setting up an API provider +func (m *Manager) ShowProviderSetupInstructions() { + m.Println("\nNo API provider configured. Please configure one:\n") + m.Println("• GitHub Copilot (recommended):") + m.Println(" /copilot login\n") + m.Println("• OpenRouter:") + m.Println(" export TMUXAI_OPENROUTER_API_KEY=\"your-key\"\n") + m.Println("• Azure OpenAI:") + m.Println(" Set credentials in ~/.config/tmuxai/config.yaml\n") + m.Println("Run /help for more information.") +} + // Start starts the manager agent func (m *Manager) Start(initMessage string) error { cliInterface := NewCLIInterface(m) diff --git a/internal/process_message.go b/internal/process_message.go index 02d499b..342cd57 100644 --- a/internal/process_message.go +++ b/internal/process_message.go @@ -13,6 +13,12 @@ import ( // Main function to process regular user messages // Returns true if the request was accomplished and no further processing should happen func (m *Manager) ProcessUserMessage(ctx context.Context, message string) bool { + // Check if any provider is configured + if !m.HasConfiguredProvider() { + m.ShowProviderSetupInstructions() + return false + } + // Check if context management is needed before sending if m.needSquash() { m.Println("Exceeded context size, squashing history...")