From 0b9234989021aa0509e6d5ab4b31fb62c8ff2d66 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Fri, 23 Jan 2026 14:48:33 +0100 Subject: [PATCH] feat: Add GitHub Copilot SDK provider integration (#661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add GitHub Copilot SDK provider integration Adds comprehensive GitHub Copilot SDK provider support including: - CopilotProvider class with CLI detection and OAuth authentication check - Copilot models definition with GPT-4o, Claude, and o1/o3 series models - Settings UI integration with provider tab, model configuration, and navigation - Onboarding flow integration with Copilot setup step - Model selector integration for all phase-specific model dropdowns - Persistence of enabled models and default model settings via API sync - Server route for Copilot CLI status endpoint https://claude.ai/code/session_01D26w7ZyEzP4H6Dor3ttk9d * chore: update package-lock.json https://claude.ai/code/session_01D26w7ZyEzP4H6Dor3ttk9d * refactor: rename Copilot SDK to Copilot CLI and use GitHub icon - Update all references from "GitHub Copilot SDK" to "GitHub Copilot CLI" - Change install command from @github/copilot-sdk to @github/copilot - Update CopilotIcon to use official GitHub Octocat logo - Update error codes and comments throughout codebase Co-Authored-By: Claude * fix: update Copilot model definitions and add dynamic model discovery - Update COPILOT_MODEL_MAP with correct models from CLI (claude-sonnet-4.5, claude-haiku-4.5, claude-opus-4.5, claude-sonnet-4, gpt-5.x series, gpt-4.1, gemini-3-pro-preview) - Change default Copilot model to copilot-claude-sonnet-4.5 - Add model caching methods to CopilotProvider (hasCachedModels, clearModelCache, refreshModels) - Add API routes for dynamic model discovery: - GET /api/setup/copilot/models - POST /api/setup/copilot/models/refresh - POST /api/setup/copilot/cache/clear Co-Authored-By: Claude Opus 4.5 * refactor: use @github/copilot-sdk instead of direct CLI calls - Install @github/copilot-sdk package for proper SDK integration - Rewrite CopilotProvider to use SDK's CopilotClient API - Use client.createSession() for session management - Handle SDK events (assistant.message, tool.execution_*, session.idle) - Auto-approve permissions for autonomous agent operation - Remove incorrect CLI flags (--mode, --output-format) - Update default model to claude-sonnet-4.5 Co-Authored-By: Claude Opus 4.5 * fix: add Copilot and Gemini model support to model resolver - Import isCopilotModel and isGeminiModel from types - Add explicit checks for copilot- and gemini- prefixed models - Pass through Copilot/Gemini models unchanged to their providers - Update resolver documentation to list all supported providers Co-Authored-By: Claude Opus 4.5 * fix: pass working directory to Copilot SDK and reduce event noise - Create CopilotClient per execution with correct cwd from options.cwd - This ensures the CLI operates in the correct project directory, not the server's current directory - Skip assistant.message_delta events (they create excessive noise) - Only yield the final assistant.message event which has complete content - Clean up client on completion and error paths Co-Authored-By: Claude Opus 4.5 * fix: simplify Copilot SDK execution with sendAndWait - Use sendAndWait() instead of manual event polling for more reliable execution - Disable streaming (streaming: false) to simplify response handling - Increase timeout to 10 minutes for agentic operations - Still capture tool execution events for UI display - Add more debug logging for troubleshooting - This should fix the "invalid_request_body" error on subsequent calls Co-Authored-By: Claude Opus 4.5 * fix: allow Copilot model IDs with claude-, gemini-, gpt- prefixes Copilot's bare model IDs legitimately contain prefixes like claude-, gemini-, gpt- because those are the actual model names from the Copilot CLI (e.g., claude-sonnet-4.5, gemini-3-pro-preview, gpt-5.1). The generic validateBareModelId function was incorrectly rejecting these valid model IDs. Now we only check that the copilot- prefix has been stripped by the ProviderFactory. Co-Authored-By: Claude Opus 4.5 * feat: enable real-time streaming of tool events for Copilot - Switch back to streaming mode (streaming: true) for real-time events - Use async queue pattern to bridge SDK callbacks to async generator - Events are now yielded as they happen, not batched at the end - Tool calls (Read, Write, Edit, Bash, TodoWrite, etc.) show in real-time - Better progress visibility during agentic operations Co-Authored-By: Claude Opus 4.5 * feat: expand Copilot tool name and input normalization Tool name mapping additions: - view → Read (Copilot's file viewing tool) - create_file → Write - replace, patch → Edit - run_shell_command, terminal → Bash - search_file_content → Grep - list_directory → Ls - google_web_search → WebSearch - report_intent → ReportIntent (Copilot-specific planning) - think, plan → Think, Plan Input normalization improvements: - Read/Write/Edit: Map file, filename, filePath → file_path - Bash: Map cmd, script → command - Grep: Map query, search, regex → pattern Co-Authored-By: Claude Opus 4.5 * fix: convert git+ssh to git+https in package-lock.json The @electron/node-gyp dependency was resolved with a git+ssh URL which fails in CI environments without SSH keys. Convert to HTTPS. Co-Authored-By: Claude Opus 4.5 * fix: address code review feedback for Copilot SDK provider - Add guard for non-text prompts (vision not yet supported) - Clear runtime model cache on fetch failure - Fix race condition in async queue error handling - Import CopilotAuthStatus from shared types - Fix comment mismatch for default model constant - Add auth-copilot and deauth-copilot routes - Extract shared tool normalization utilities - Create base model configuration UI component - Add comprehensive unit tests for CopilotProvider - Replace magic strings with constants - Add debug logging for cleanup errors * fix: address CodeRabbit review nitpicks - Fix test mocks to include --version check for CLI detection - Add aria-label for accessibility on refresh button - Ensure default model checkbox always appears checked/enabled * fix: address CodeRabbit review feedback - Fix test mocks by creating fresh provider instances after mock setup - Extract COPILOT_DISCONNECTED_MARKER_FILE constant to common.ts - Add AUTONOMOUS MODE comment explaining auto-approval of permissions - Improve tool-normalization with union types and null guards - Handle 'canceled' (American spelling) status in todo normalization * refactor: extract copilot connection logic to service and fix test mocks - Create copilot-connection-service.ts with connect/disconnect logic - Update auth-copilot and deauth-copilot routes to use service - Fix test mocks for CLI detection: - Mock fs.existsSync for CLI path validation - Mock which/where command for CLI path detection --------- Co-authored-by: Claude --- apps/server/package.json | 1 + apps/server/src/providers/copilot-provider.ts | 942 ++++++++++++++++++ apps/server/src/providers/gemini-provider.ts | 15 +- apps/server/src/providers/index.ts | 6 + apps/server/src/providers/provider-factory.ts | 11 + .../src/providers/tool-normalization.ts | 112 +++ apps/server/src/routes/setup/common.ts | 5 + apps/server/src/routes/setup/index.ts | 18 + .../src/routes/setup/routes/auth-copilot.ts | 30 + .../src/routes/setup/routes/copilot-models.ts | 139 +++ .../src/routes/setup/routes/copilot-status.ts | 78 ++ .../src/routes/setup/routes/deauth-copilot.ts | 30 + .../services/copilot-connection-service.ts | 80 ++ .../unit/providers/copilot-provider.test.ts | 517 ++++++++++ .../unit/providers/provider-factory.test.ts | 19 +- apps/ui/src/components/ui/provider-icon.tsx | 18 + .../board-view/shared/model-constants.ts | 21 +- .../ui/src/components/views/settings-view.tsx | 3 + .../cli-status/copilot-cli-status.tsx | 234 +++++ .../components/settings-navigation.tsx | 1 + .../views/settings-view/config/navigation.ts | 2 + .../settings-view/hooks/use-settings-view.ts | 1 + .../model-defaults/phase-model-selector.tsx | 100 +- .../providers/copilot-model-configuration.tsx | 53 + .../providers/copilot-settings-tab.tsx | 130 +++ .../providers/gemini-model-configuration.tsx | 143 +-- .../views/settings-view/providers/index.ts | 1 + .../settings-view/providers/provider-tabs.tsx | 14 +- .../shared/base-model-configuration.tsx | 183 ++++ .../setup-view/steps/providers-setup-step.tsx | 292 +++++- apps/ui/src/hooks/queries/index.ts | 1 + apps/ui/src/hooks/queries/use-cli-status.ts | 20 + apps/ui/src/hooks/use-settings-sync.ts | 22 + apps/ui/src/lib/http-api-client.ts | 21 + apps/ui/src/lib/query-keys.ts | 2 + apps/ui/src/store/app-store.ts | 24 + apps/ui/src/store/setup-store.ts | 28 + libs/model-resolver/src/resolver.ts | 18 + libs/types/src/copilot-models.ts | 194 ++++ libs/types/src/index.ts | 4 + libs/types/src/provider-utils.ts | 35 +- libs/types/src/settings.ts | 22 +- package-lock.json | 143 ++- 43 files changed, 3588 insertions(+), 145 deletions(-) create mode 100644 apps/server/src/providers/copilot-provider.ts create mode 100644 apps/server/src/providers/tool-normalization.ts create mode 100644 apps/server/src/routes/setup/routes/auth-copilot.ts create mode 100644 apps/server/src/routes/setup/routes/copilot-models.ts create mode 100644 apps/server/src/routes/setup/routes/copilot-status.ts create mode 100644 apps/server/src/routes/setup/routes/deauth-copilot.ts create mode 100644 apps/server/src/services/copilot-connection-service.ts create mode 100644 apps/server/tests/unit/providers/copilot-provider.test.ts create mode 100644 apps/ui/src/components/views/settings-view/cli-status/copilot-cli-status.tsx create mode 100644 apps/ui/src/components/views/settings-view/providers/copilot-model-configuration.tsx create mode 100644 apps/ui/src/components/views/settings-view/providers/copilot-settings-tab.tsx create mode 100644 apps/ui/src/components/views/settings-view/providers/shared/base-model-configuration.tsx create mode 100644 libs/types/src/copilot-models.ts diff --git a/apps/server/package.json b/apps/server/package.json index 70ca0f1e..c9015aea 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -32,6 +32,7 @@ "@automaker/prompts": "1.0.0", "@automaker/types": "1.0.0", "@automaker/utils": "1.0.0", + "@github/copilot-sdk": "^0.1.16", "@modelcontextprotocol/sdk": "1.25.2", "@openai/codex-sdk": "^0.77.0", "cookie-parser": "1.4.7", diff --git a/apps/server/src/providers/copilot-provider.ts b/apps/server/src/providers/copilot-provider.ts new file mode 100644 index 00000000..64423047 --- /dev/null +++ b/apps/server/src/providers/copilot-provider.ts @@ -0,0 +1,942 @@ +/** + * Copilot Provider - Executes queries using the GitHub Copilot SDK + * + * Uses the official @github/copilot-sdk for: + * - Session management and streaming responses + * - GitHub OAuth authentication (via gh CLI) + * - Tool call handling and permission management + * - Runtime model discovery + * + * Based on https://github.com/github/copilot-sdk + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js'; +import type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from './types.js'; +// Note: validateBareModelId is not used because Copilot's bare model IDs +// legitimately contain prefixes like claude-, gemini-, gpt- +import { + COPILOT_MODEL_MAP, + type CopilotAuthStatus, + type CopilotRuntimeModel, +} from '@automaker/types'; +import { createLogger, isAbortError } from '@automaker/utils'; +import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk'; +import { + normalizeTodos, + normalizeFilePathInput, + normalizeCommandInput, + normalizePatternInput, +} from './tool-normalization.js'; + +// Create logger for this module +const logger = createLogger('CopilotProvider'); + +// Default bare model (without copilot- prefix) for SDK calls +const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5'; + +// ============================================================================= +// SDK Event Types (from @github/copilot-sdk) +// ============================================================================= + +/** + * SDK session event data types + */ +interface SdkEvent { + type: string; + data?: unknown; +} + +interface SdkMessageEvent extends SdkEvent { + type: 'assistant.message'; + data: { + content: string; + }; +} + +// Note: SdkMessageDeltaEvent is not used - we skip delta events to reduce noise +// The final assistant.message event contains the complete content + +interface SdkToolExecutionStartEvent extends SdkEvent { + type: 'tool.execution_start'; + data: { + toolName: string; + toolCallId: string; + input?: Record; + }; +} + +interface SdkToolExecutionEndEvent extends SdkEvent { + type: 'tool.execution_end'; + data: { + toolName: string; + toolCallId: string; + result?: string; + error?: string; + }; +} + +interface SdkSessionIdleEvent extends SdkEvent { + type: 'session.idle'; +} + +interface SdkSessionErrorEvent extends SdkEvent { + type: 'session.error'; + data: { + message: string; + code?: string; + }; +} + +// ============================================================================= +// Error Codes +// ============================================================================= + +export enum CopilotErrorCode { + NOT_INSTALLED = 'COPILOT_NOT_INSTALLED', + NOT_AUTHENTICATED = 'COPILOT_NOT_AUTHENTICATED', + RATE_LIMITED = 'COPILOT_RATE_LIMITED', + MODEL_UNAVAILABLE = 'COPILOT_MODEL_UNAVAILABLE', + NETWORK_ERROR = 'COPILOT_NETWORK_ERROR', + PROCESS_CRASHED = 'COPILOT_PROCESS_CRASHED', + TIMEOUT = 'COPILOT_TIMEOUT', + CLI_ERROR = 'COPILOT_CLI_ERROR', + SDK_ERROR = 'COPILOT_SDK_ERROR', + UNKNOWN = 'COPILOT_UNKNOWN_ERROR', +} + +export interface CopilotError extends Error { + code: CopilotErrorCode; + recoverable: boolean; + suggestion?: string; +} + +// ============================================================================= +// Tool Name Normalization +// ============================================================================= + +/** + * Copilot SDK tool name to standard tool name mapping + * + * Maps Copilot CLI tool names to our standard tool names for consistent UI display. + * Tool names are case-insensitive (normalized to lowercase before lookup). + */ +const COPILOT_TOOL_NAME_MAP: Record = { + // File operations + read_file: 'Read', + read: 'Read', + view: 'Read', // Copilot uses 'view' for reading files + read_many_files: 'Read', + write_file: 'Write', + write: 'Write', + create_file: 'Write', + edit_file: 'Edit', + edit: 'Edit', + replace: 'Edit', + patch: 'Edit', + // Shell operations + run_shell: 'Bash', + run_shell_command: 'Bash', + shell: 'Bash', + bash: 'Bash', + execute: 'Bash', + terminal: 'Bash', + // Search operations + search: 'Grep', + grep: 'Grep', + search_file_content: 'Grep', + find_files: 'Glob', + glob: 'Glob', + list_dir: 'Ls', + list_directory: 'Ls', + ls: 'Ls', + // Web operations + web_fetch: 'WebFetch', + fetch: 'WebFetch', + web_search: 'WebSearch', + search_web: 'WebSearch', + google_web_search: 'WebSearch', + // Todo operations + todo_write: 'TodoWrite', + write_todos: 'TodoWrite', + update_todos: 'TodoWrite', + // Planning/intent operations (Copilot-specific) + report_intent: 'ReportIntent', // Keep as-is, it's a planning tool + think: 'Think', + plan: 'Plan', +}; + +/** + * Normalize Copilot tool names to standard tool names + */ +function normalizeCopilotToolName(copilotToolName: string): string { + const lowerName = copilotToolName.toLowerCase(); + return COPILOT_TOOL_NAME_MAP[lowerName] || copilotToolName; +} + +/** + * Normalize Copilot tool input parameters to standard format + * + * Maps Copilot's parameter names to our standard parameter names. + * Uses shared utilities from tool-normalization.ts for common normalizations. + */ +function normalizeCopilotToolInput( + toolName: string, + input: Record +): Record { + const normalizedName = normalizeCopilotToolName(toolName); + + // Normalize todo_write / write_todos: ensure proper format + if (normalizedName === 'TodoWrite' && Array.isArray(input.todos)) { + return { todos: normalizeTodos(input.todos) }; + } + + // Normalize file path parameters for Read/Write/Edit tools + if (normalizedName === 'Read' || normalizedName === 'Write' || normalizedName === 'Edit') { + return normalizeFilePathInput(input); + } + + // Normalize shell command parameters for Bash tool + if (normalizedName === 'Bash') { + return normalizeCommandInput(input); + } + + // Normalize search parameters for Grep tool + if (normalizedName === 'Grep') { + return normalizePatternInput(input); + } + + return input; +} + +/** + * CopilotProvider - Integrates GitHub Copilot SDK as an AI provider + * + * Features: + * - GitHub OAuth authentication + * - SDK-based session management + * - Runtime model discovery + * - Tool call normalization + * - Per-execution working directory support + */ +export class CopilotProvider extends CliProvider { + private runtimeModels: CopilotRuntimeModel[] | null = null; + + constructor(config: ProviderConfig = {}) { + super(config); + // Trigger CLI detection on construction + this.ensureCliDetected(); + } + + // ========================================================================== + // CliProvider Abstract Method Implementations + // ========================================================================== + + getName(): string { + return 'copilot'; + } + + getCliName(): string { + return 'copilot'; + } + + getSpawnConfig(): CliSpawnConfig { + return { + windowsStrategy: 'npx', // Copilot CLI can be run via npx + npxPackage: '@github/copilot', // Official GitHub Copilot CLI package + commonPaths: { + linux: [ + path.join(os.homedir(), '.local/bin/copilot'), + '/usr/local/bin/copilot', + path.join(os.homedir(), '.npm-global/bin/copilot'), + ], + darwin: [ + path.join(os.homedir(), '.local/bin/copilot'), + '/usr/local/bin/copilot', + '/opt/homebrew/bin/copilot', + path.join(os.homedir(), '.npm-global/bin/copilot'), + ], + win32: [ + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'copilot.cmd'), + path.join(os.homedir(), '.npm-global', 'copilot.cmd'), + ], + }, + }; + } + + /** + * Extract prompt text from ExecuteOptions + * + * Note: CopilotProvider does not yet support vision/image inputs. + * If non-text content is provided, an error is thrown. + */ + private extractPromptText(options: ExecuteOptions): string { + if (typeof options.prompt === 'string') { + return options.prompt; + } else if (Array.isArray(options.prompt)) { + // Check for non-text content (images, etc.) which we don't support yet + const hasNonText = options.prompt.some((p) => p.type !== 'text'); + if (hasNonText) { + throw new Error( + 'CopilotProvider does not yet support non-text prompt parts (e.g., images). ' + + 'Please use text-only prompts or switch to a provider that supports vision.' + ); + } + return options.prompt + .filter((p) => p.type === 'text' && p.text) + .map((p) => p.text) + .join('\n'); + } else { + throw new Error('Invalid prompt format'); + } + } + + /** + * Not used with SDK approach - kept for interface compatibility + */ + buildCliArgs(_options: ExecuteOptions): string[] { + return []; + } + + /** + * Convert SDK event to AutoMaker ProviderMessage format + */ + normalizeEvent(event: unknown): ProviderMessage | null { + const sdkEvent = event as SdkEvent; + + switch (sdkEvent.type) { + case 'assistant.message': { + const messageEvent = sdkEvent as SdkMessageEvent; + return { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: messageEvent.data.content }], + }, + }; + } + + case 'assistant.message_delta': { + // Skip delta events - they create too much noise + // The final assistant.message event has the complete content + return null; + } + + case 'tool.execution_start': { + const toolEvent = sdkEvent as SdkToolExecutionStartEvent; + const normalizedName = normalizeCopilotToolName(toolEvent.data.toolName); + const normalizedInput = toolEvent.data.input + ? normalizeCopilotToolInput(toolEvent.data.toolName, toolEvent.data.input) + : {}; + + return { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: normalizedName, + tool_use_id: toolEvent.data.toolCallId, + input: normalizedInput, + }, + ], + }, + }; + } + + case 'tool.execution_end': { + const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent; + const isError = !!toolResultEvent.data.error; + const content = isError + ? `[ERROR] ${toolResultEvent.data.error}` + : toolResultEvent.data.result || ''; + + return { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: toolResultEvent.data.toolCallId, + content, + }, + ], + }, + }; + } + + case 'session.idle': { + logger.debug('Copilot session idle'); + return { + type: 'result', + subtype: 'success', + }; + } + + case 'session.error': { + const errorEvent = sdkEvent as SdkSessionErrorEvent; + return { + type: 'error', + error: errorEvent.data.message || 'Unknown error', + }; + } + + default: + logger.debug(`Unknown Copilot SDK event type: ${sdkEvent.type}`); + return null; + } + } + + // ========================================================================== + // CliProvider Overrides + // ========================================================================== + + /** + * Override error mapping for Copilot-specific error codes + */ + protected mapError(stderr: string, exitCode: number | null): CliErrorInfo { + const lower = stderr.toLowerCase(); + + if ( + lower.includes('not authenticated') || + lower.includes('please log in') || + lower.includes('unauthorized') || + lower.includes('login required') || + lower.includes('authentication required') || + lower.includes('github login') + ) { + return { + code: CopilotErrorCode.NOT_AUTHENTICATED, + message: 'GitHub Copilot is not authenticated', + recoverable: true, + suggestion: 'Run "gh auth login" or "copilot auth login" to authenticate with GitHub', + }; + } + + if ( + lower.includes('rate limit') || + lower.includes('too many requests') || + lower.includes('429') || + lower.includes('quota exceeded') + ) { + return { + code: CopilotErrorCode.RATE_LIMITED, + message: 'Copilot API rate limit exceeded', + recoverable: true, + suggestion: 'Wait a few minutes and try again', + }; + } + + if ( + lower.includes('model not available') || + lower.includes('invalid model') || + lower.includes('unknown model') || + lower.includes('model not found') || + (lower.includes('not found') && lower.includes('404')) + ) { + return { + code: CopilotErrorCode.MODEL_UNAVAILABLE, + message: 'Requested model is not available', + recoverable: true, + suggestion: `Try using "${DEFAULT_BARE_MODEL}" or select a different model`, + }; + } + + if ( + lower.includes('network') || + lower.includes('connection') || + lower.includes('econnrefused') || + lower.includes('timeout') + ) { + return { + code: CopilotErrorCode.NETWORK_ERROR, + message: 'Network connection error', + recoverable: true, + suggestion: 'Check your internet connection and try again', + }; + } + + if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) { + return { + code: CopilotErrorCode.PROCESS_CRASHED, + message: 'Copilot CLI process was terminated', + recoverable: true, + suggestion: 'The process may have run out of memory. Try a simpler task.', + }; + } + + return { + code: CopilotErrorCode.UNKNOWN, + message: stderr || `Copilot CLI exited with code ${exitCode}`, + recoverable: false, + }; + } + + /** + * Override install instructions for Copilot-specific guidance + */ + protected getInstallInstructions(): string { + return 'Install with: npm install -g @github/copilot (or visit https://github.com/github/copilot)'; + } + + /** + * Execute a prompt using Copilot SDK with real-time streaming + * + * Creates a new CopilotClient for each execution with the correct working directory. + * Streams tool execution events in real-time for UI display. + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + this.ensureCliDetected(); + + // Note: We don't use validateBareModelId here because Copilot's model IDs + // legitimately contain prefixes like claude-, gemini-, gpt- which are the + // actual model names from the Copilot CLI. We only need to ensure the + // copilot- prefix has been stripped by the ProviderFactory. + if (options.model?.startsWith('copilot-')) { + throw new Error( + `[CopilotProvider] Model ID should not have 'copilot-' prefix. Got: '${options.model}'. ` + + `The ProviderFactory should strip this prefix before passing to the provider.` + ); + } + + if (!this.cliPath) { + throw this.createError( + CopilotErrorCode.NOT_INSTALLED, + 'Copilot CLI is not installed', + true, + this.getInstallInstructions() + ); + } + + const promptText = this.extractPromptText(options); + const bareModel = options.model || DEFAULT_BARE_MODEL; + const workingDirectory = options.cwd || process.cwd(); + + logger.debug( + `CopilotProvider.executeQuery called with model: "${bareModel}", cwd: "${workingDirectory}"` + ); + logger.debug(`Prompt length: ${promptText.length} characters`); + + // Create a client for this execution with the correct working directory + const client = new CopilotClient({ + logLevel: 'warning', + autoRestart: false, + cwd: workingDirectory, + }); + + // Use an async queue to bridge callback-based SDK events to async generator + const eventQueue: SdkEvent[] = []; + let resolveWaiting: (() => void) | null = null; + let sessionComplete = false; + let sessionError: Error | null = null; + + const pushEvent = (event: SdkEvent) => { + eventQueue.push(event); + if (resolveWaiting) { + resolveWaiting(); + resolveWaiting = null; + } + }; + + const waitForEvent = (): Promise => { + if (eventQueue.length > 0 || sessionComplete) { + return Promise.resolve(); + } + return new Promise((resolve) => { + resolveWaiting = resolve; + }); + }; + + try { + await client.start(); + logger.debug(`CopilotClient started with cwd: ${workingDirectory}`); + + // Create session with streaming enabled for real-time events + const session = await client.createSession({ + model: bareModel, + streaming: true, + // AUTONOMOUS MODE: Auto-approve all permission requests. + // AutoMaker is designed for fully autonomous AI agent operation. + // Security boundary is provided by Docker containerization (see CLAUDE.md). + // User is warned about this at app startup. + onPermissionRequest: async ( + request: PermissionRequest + ): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> => { + logger.debug(`Permission request: ${request.kind}`); + return { kind: 'approved' }; + }, + }); + + const sessionId = session.sessionId; + logger.debug(`Session created: ${sessionId}`); + + // Set up event handler to push events to queue + session.on((event: SdkEvent) => { + logger.debug(`SDK event: ${event.type}`); + + if (event.type === 'session.idle') { + sessionComplete = true; + pushEvent(event); + } else if (event.type === 'session.error') { + const errorEvent = event as SdkSessionErrorEvent; + sessionError = new Error(errorEvent.data.message); + sessionComplete = true; + pushEvent(event); + } else { + // Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.) + pushEvent(event); + } + }); + + // Send the prompt (non-blocking) + await session.send({ prompt: promptText }); + + // Process events as they arrive + while (!sessionComplete || eventQueue.length > 0) { + await waitForEvent(); + + // Check for errors first (before processing events to avoid race condition) + if (sessionError) { + await session.destroy(); + await client.stop(); + throw sessionError; + } + + // Process all queued events + while (eventQueue.length > 0) { + const event = eventQueue.shift()!; + const normalized = this.normalizeEvent(event); + if (normalized) { + // Add session_id if not present + if (!normalized.session_id) { + normalized.session_id = sessionId; + } + yield normalized; + } + } + } + + // Cleanup + await session.destroy(); + await client.stop(); + logger.debug('CopilotClient stopped successfully'); + } catch (error) { + // Ensure client is stopped on error + try { + await client.stop(); + } catch (cleanupError) { + // Log but don't throw cleanup errors - the original error is more important + logger.debug(`Failed to stop client during cleanup: ${cleanupError}`); + } + + if (isAbortError(error)) { + logger.debug('Query aborted'); + return; + } + + // Map errors to CopilotError + if (error instanceof Error) { + logger.error(`Copilot SDK error: ${error.message}`); + const errorInfo = this.mapError(error.message, null); + throw this.createError( + errorInfo.code as CopilotErrorCode, + errorInfo.message, + errorInfo.recoverable, + errorInfo.suggestion + ); + } + throw error; + } + } + + // ========================================================================== + // Copilot-Specific Methods + // ========================================================================== + + /** + * Create a CopilotError with details + */ + private createError( + code: CopilotErrorCode, + message: string, + recoverable: boolean = false, + suggestion?: string + ): CopilotError { + const error = new Error(message) as CopilotError; + error.code = code; + error.recoverable = recoverable; + error.suggestion = suggestion; + error.name = 'CopilotError'; + return error; + } + + /** + * Get Copilot CLI version + */ + async getVersion(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) return null; + + try { + const result = execSync(`"${this.cliPath}" --version`, { + encoding: 'utf8', + timeout: 5000, + stdio: 'pipe', + }).trim(); + return result; + } catch { + return null; + } + } + + /** + * Check authentication status + * + * Uses GitHub CLI (gh) to check Copilot authentication status. + * The Copilot CLI relies on gh auth for authentication. + */ + async checkAuth(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) { + logger.debug('checkAuth: CLI not found'); + return { authenticated: false, method: 'none' }; + } + + logger.debug('checkAuth: Starting credential check'); + + // Try to check GitHub CLI authentication status first + // The Copilot CLI uses gh auth for authentication + try { + const ghStatus = execSync('gh auth status --hostname github.com', { + encoding: 'utf8', + timeout: 10000, + stdio: 'pipe', + }); + + logger.debug(`checkAuth: gh auth status output: ${ghStatus.substring(0, 200)}`); + + // Parse gh auth status output + const loggedInMatch = ghStatus.match(/Logged in to github\.com account (\S+)/); + if (loggedInMatch) { + return { + authenticated: true, + method: 'oauth', + login: loggedInMatch[1], + host: 'github.com', + }; + } + + // Check for token auth + if (ghStatus.includes('Logged in') || ghStatus.includes('Token:')) { + return { + authenticated: true, + method: 'oauth', + host: 'github.com', + }; + } + } catch (ghError) { + logger.debug(`checkAuth: gh auth status failed: ${ghError}`); + } + + // Try Copilot-specific auth check if gh is not available + try { + const result = execSync(`"${this.cliPath}" auth status`, { + encoding: 'utf8', + timeout: 10000, + stdio: 'pipe', + }); + + logger.debug(`checkAuth: copilot auth status output: ${result.substring(0, 200)}`); + + if (result.includes('authenticated') || result.includes('logged in')) { + return { + authenticated: true, + method: 'cli', + }; + } + } catch (copilotError) { + logger.debug(`checkAuth: copilot auth status failed: ${copilotError}`); + } + + // Check for GITHUB_TOKEN environment variable + if (process.env.GITHUB_TOKEN) { + logger.debug('checkAuth: Found GITHUB_TOKEN environment variable'); + return { + authenticated: true, + method: 'oauth', + statusMessage: 'Using GITHUB_TOKEN environment variable', + }; + } + + // Check for gh config file + const ghConfigPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml'); + try { + await fs.access(ghConfigPath); + const content = await fs.readFile(ghConfigPath, 'utf8'); + if (content.includes('github.com') && content.includes('oauth_token')) { + logger.debug('checkAuth: Found gh config with oauth_token'); + return { + authenticated: true, + method: 'oauth', + host: 'github.com', + }; + } + } catch { + logger.debug('checkAuth: No gh config found'); + } + + // No credentials found + logger.debug('checkAuth: No valid credentials found'); + return { + authenticated: false, + method: 'none', + error: + 'No authentication configured. Run "gh auth login" or install GitHub Copilot extension.', + }; + } + + /** + * Fetch available models from the CLI at runtime + */ + async fetchRuntimeModels(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) { + return []; + } + + try { + // Try to list models using the CLI + const result = execSync(`"${this.cliPath}" models list --format json`, { + encoding: 'utf8', + timeout: 15000, + stdio: 'pipe', + }); + + const models = JSON.parse(result) as CopilotRuntimeModel[]; + this.runtimeModels = models; + logger.debug(`Fetched ${models.length} runtime models from Copilot CLI`); + return models; + } catch (error) { + // Clear cache on failure to avoid returning stale data + this.runtimeModels = null; + logger.debug(`Failed to fetch runtime models: ${error}`); + return []; + } + } + + /** + * Detect installation status (required by BaseProvider) + */ + async detectInstallation(): Promise { + const installed = await this.isInstalled(); + const version = installed ? await this.getVersion() : undefined; + const auth = await this.checkAuth(); + + return { + installed, + version: version || undefined, + path: this.cliPath || undefined, + method: 'cli', + authenticated: auth.authenticated, + }; + } + + /** + * Get the detected CLI path (public accessor for status endpoints) + */ + getCliPath(): string | null { + this.ensureCliDetected(); + return this.cliPath; + } + + /** + * Get available Copilot models + * + * Returns both static model definitions and runtime-discovered models + */ + getAvailableModels(): ModelDefinition[] { + // Start with static model definitions - explicitly typed to allow runtime models + const staticModels: ModelDefinition[] = Object.entries(COPILOT_MODEL_MAP).map( + ([id, config]) => ({ + id, // Full model ID with copilot- prefix + name: config.label, + modelString: id.replace('copilot-', ''), // Bare model for CLI + provider: 'copilot', + description: config.description, + supportsTools: config.supportsTools, + supportsVision: config.supportsVision, + contextWindow: config.contextWindow, + }) + ); + + // Add runtime models if available (discovered via CLI) + if (this.runtimeModels) { + for (const runtimeModel of this.runtimeModels) { + // Skip if already in static list + const staticId = `copilot-${runtimeModel.id}`; + if (staticModels.some((m) => m.id === staticId)) { + continue; + } + + staticModels.push({ + id: staticId, + name: runtimeModel.name || runtimeModel.id, + modelString: runtimeModel.id, + provider: 'copilot', + description: `Dynamic model: ${runtimeModel.name || runtimeModel.id}`, + supportsTools: true, + supportsVision: runtimeModel.capabilities?.supportsVision ?? false, + contextWindow: runtimeModel.capabilities?.maxInputTokens, + }); + } + } + + return staticModels; + } + + /** + * Check if a feature is supported + * + * Note: Vision is NOT currently supported - the SDK doesn't handle image inputs yet. + * This may change in future versions of the Copilot SDK. + */ + supportsFeature(feature: string): boolean { + const supported = ['tools', 'text', 'streaming']; + return supported.includes(feature); + } + + /** + * Check if runtime models have been cached + */ + hasCachedModels(): boolean { + return this.runtimeModels !== null && this.runtimeModels.length > 0; + } + + /** + * Clear the runtime model cache + */ + clearModelCache(): void { + this.runtimeModels = null; + logger.debug('Cleared Copilot model cache'); + } + + /** + * Refresh models from CLI and return all available models + */ + async refreshModels(): Promise { + logger.debug('Refreshing Copilot models from CLI'); + await this.fetchRuntimeModels(); + return this.getAvailableModels(); + } +} diff --git a/apps/server/src/providers/gemini-provider.ts b/apps/server/src/providers/gemini-provider.ts index 9e09c462..09f16c16 100644 --- a/apps/server/src/providers/gemini-provider.ts +++ b/apps/server/src/providers/gemini-provider.ts @@ -26,6 +26,7 @@ import { validateBareModelId } from '@automaker/types'; import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types'; import { createLogger, isAbortError } from '@automaker/utils'; import { spawnJSONLProcess } from '@automaker/platform'; +import { normalizeTodos } from './tool-normalization.js'; // Create logger for this module const logger = createLogger('GeminiProvider'); @@ -150,6 +151,8 @@ function normalizeGeminiToolName(geminiToolName: string): string { /** * Normalize Gemini tool input parameters to standard format * + * Uses shared normalizeTodos utility for consistent todo normalization. + * * Gemini `write_todos` format: * {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]} * @@ -160,17 +163,9 @@ function normalizeGeminiToolInput( toolName: string, input: Record ): Record { - // Normalize write_todos: map 'description' to 'content', handle 'cancelled' status + // Normalize write_todos using shared utility if (toolName === 'write_todos' && Array.isArray(input.todos)) { - return { - todos: input.todos.map((todo: { description?: string; status?: string }) => ({ - content: todo.description || '', - // Map 'cancelled' to 'completed' since Claude doesn't have cancelled status - status: todo.status === 'cancelled' ? 'completed' : todo.status, - // Use description as activeForm since Gemini doesn't have it - activeForm: todo.description || '', - })), - }; + return { todos: normalizeTodos(input.todos) }; } return input; } diff --git a/apps/server/src/providers/index.ts b/apps/server/src/providers/index.ts index f04aa61d..0cb79470 100644 --- a/apps/server/src/providers/index.ts +++ b/apps/server/src/providers/index.ts @@ -38,6 +38,12 @@ export { CursorConfigManager } from './cursor-config-manager.js'; // OpenCode provider export { OpencodeProvider } from './opencode-provider.js'; +// Gemini provider +export { GeminiProvider, GeminiErrorCode } from './gemini-provider.js'; + +// Copilot provider (GitHub Copilot SDK) +export { CopilotProvider, CopilotErrorCode } from './copilot-provider.js'; + // Provider factory export { ProviderFactory } from './provider-factory.js'; diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index 40a9872b..1e91760f 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -12,6 +12,7 @@ import { isCodexModel, isOpencodeModel, isGeminiModel, + isCopilotModel, type ModelProvider, } from '@automaker/types'; import * as fs from 'fs'; @@ -23,6 +24,7 @@ const DISCONNECTED_MARKERS: Record = { cursor: '.cursor-disconnected', opencode: '.opencode-disconnected', gemini: '.gemini-disconnected', + copilot: '.copilot-disconnected', }; /** @@ -275,6 +277,7 @@ import { CursorProvider } from './cursor-provider.js'; import { CodexProvider } from './codex-provider.js'; import { OpencodeProvider } from './opencode-provider.js'; import { GeminiProvider } from './gemini-provider.js'; +import { CopilotProvider } from './copilot-provider.js'; // Register Claude provider registerProvider('claude', { @@ -317,3 +320,11 @@ registerProvider('gemini', { canHandleModel: (model: string) => isGeminiModel(model), priority: 4, // Between opencode (3) and codex (5) }); + +// Register Copilot provider (GitHub Copilot SDK) +registerProvider('copilot', { + factory: () => new CopilotProvider(), + aliases: ['github-copilot', 'github'], + canHandleModel: (model: string) => isCopilotModel(model), + priority: 6, // High priority - check before Codex since both can handle GPT models +}); diff --git a/apps/server/src/providers/tool-normalization.ts b/apps/server/src/providers/tool-normalization.ts new file mode 100644 index 00000000..27442a7a --- /dev/null +++ b/apps/server/src/providers/tool-normalization.ts @@ -0,0 +1,112 @@ +/** + * Shared tool normalization utilities for AI providers + * + * These utilities help normalize tool inputs from various AI providers + * to the standard format expected by the application. + */ + +/** + * Valid todo status values in the standard format + */ +type TodoStatus = 'pending' | 'in_progress' | 'completed'; + +/** + * Set of valid status values for validation + */ +const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed']); + +/** + * Todo item from various AI providers (Gemini, Copilot, etc.) + */ +interface ProviderTodo { + description?: string; + content?: string; + status?: string; +} + +/** + * Standard todo format used by the application + */ +interface NormalizedTodo { + content: string; + status: TodoStatus; + activeForm: string; +} + +/** + * Normalize a provider status value to a valid TodoStatus + */ +function normalizeStatus(status: string | undefined): TodoStatus { + if (!status) return 'pending'; + if (status === 'cancelled' || status === 'canceled') return 'completed'; + if (VALID_STATUSES.has(status as TodoStatus)) return status as TodoStatus; + return 'pending'; +} + +/** + * Normalize todos array from provider format to standard format + * + * Handles different formats from providers: + * - Gemini: { description, status } with 'cancelled' as possible status + * - Copilot: { content/description, status } with 'cancelled' as possible status + * + * Output format (Claude/Standard): + * - { content, status, activeForm } where status is 'pending'|'in_progress'|'completed' + */ +export function normalizeTodos(todos: ProviderTodo[] | null | undefined): NormalizedTodo[] { + if (!todos) return []; + return todos.map((todo) => ({ + content: todo.content || todo.description || '', + status: normalizeStatus(todo.status), + // Use content/description as activeForm since providers may not have it + activeForm: todo.content || todo.description || '', + })); +} + +/** + * Normalize file path parameters from various provider formats + * + * Different providers use different parameter names for file paths: + * - path, file, filename, filePath -> file_path + */ +export function normalizeFilePathInput(input: Record): Record { + const normalized = { ...input }; + if (!normalized.file_path) { + if (input.path) normalized.file_path = input.path; + else if (input.file) normalized.file_path = input.file; + else if (input.filename) normalized.file_path = input.filename; + else if (input.filePath) normalized.file_path = input.filePath; + } + return normalized; +} + +/** + * Normalize shell command parameters from various provider formats + * + * Different providers use different parameter names for commands: + * - cmd, script -> command + */ +export function normalizeCommandInput(input: Record): Record { + const normalized = { ...input }; + if (!normalized.command) { + if (input.cmd) normalized.command = input.cmd; + else if (input.script) normalized.command = input.script; + } + return normalized; +} + +/** + * Normalize search pattern parameters from various provider formats + * + * Different providers use different parameter names for search patterns: + * - query, search, regex -> pattern + */ +export function normalizePatternInput(input: Record): Record { + const normalized = { ...input }; + if (!normalized.pattern) { + if (input.query) normalized.pattern = input.query; + else if (input.search) normalized.pattern = input.search; + else if (input.regex) normalized.pattern = input.regex; + } + return normalized; +} diff --git a/apps/server/src/routes/setup/common.ts b/apps/server/src/routes/setup/common.ts index ebac7644..f03d9175 100644 --- a/apps/server/src/routes/setup/common.ts +++ b/apps/server/src/routes/setup/common.ts @@ -52,3 +52,8 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise => { + try { + await connectCopilot(); + + res.json({ + success: true, + message: 'Copilot CLI connected to app', + }); + } catch (error) { + logError(error, 'Auth Copilot failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/copilot-models.ts b/apps/server/src/routes/setup/routes/copilot-models.ts new file mode 100644 index 00000000..5a3da128 --- /dev/null +++ b/apps/server/src/routes/setup/routes/copilot-models.ts @@ -0,0 +1,139 @@ +/** + * Copilot Dynamic Models API Routes + * + * Provides endpoints for: + * - GET /api/setup/copilot/models - Get available models (cached or refreshed) + * - POST /api/setup/copilot/models/refresh - Force refresh models from CLI + */ + +import type { Request, Response } from 'express'; +import { CopilotProvider } from '../../../providers/copilot-provider.js'; +import { getErrorMessage, logError } from '../common.js'; +import type { ModelDefinition } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('CopilotModelsRoute'); + +// Singleton provider instance for caching +let providerInstance: CopilotProvider | null = null; + +function getProvider(): CopilotProvider { + if (!providerInstance) { + providerInstance = new CopilotProvider(); + } + return providerInstance; +} + +/** + * Response type for models endpoint + */ +interface ModelsResponse { + success: boolean; + models?: ModelDefinition[]; + count?: number; + cached?: boolean; + error?: string; +} + +/** + * Creates handler for GET /api/setup/copilot/models + * + * Returns currently available models (from cache if available). + * Query params: + * - refresh=true: Force refresh from CLI before returning + * + * Note: If cache is empty, this will trigger a refresh to get dynamic models. + */ +export function createGetCopilotModelsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const forceRefresh = req.query.refresh === 'true'; + + let models: ModelDefinition[]; + let cached = true; + + if (forceRefresh) { + models = await provider.refreshModels(); + cached = false; + } else { + // Check if we have cached models + if (!provider.hasCachedModels()) { + models = await provider.refreshModels(); + cached = false; + } else { + models = provider.getAvailableModels(); + } + } + + const response: ModelsResponse = { + success: true, + models, + count: models.length, + cached, + }; + + res.json(response); + } catch (error) { + logError(error, 'Get Copilot models failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ModelsResponse); + } + }; +} + +/** + * Creates handler for POST /api/setup/copilot/models/refresh + * + * Forces a refresh of models from the Copilot CLI. + */ +export function createRefreshCopilotModelsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const models = await provider.refreshModels(); + + const response: ModelsResponse = { + success: true, + models, + count: models.length, + cached: false, + }; + + res.json(response); + } catch (error) { + logError(error, 'Refresh Copilot models failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ModelsResponse); + } + }; +} + +/** + * Creates handler for POST /api/setup/copilot/cache/clear + * + * Clears the model cache, forcing a fresh fetch on next access. + */ +export function createClearCopilotCacheHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + provider.clearModelCache(); + + res.json({ + success: true, + message: 'Copilot model cache cleared', + }); + } catch (error) { + logError(error, 'Clear Copilot cache failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/copilot-status.ts b/apps/server/src/routes/setup/routes/copilot-status.ts new file mode 100644 index 00000000..c759c118 --- /dev/null +++ b/apps/server/src/routes/setup/routes/copilot-status.ts @@ -0,0 +1,78 @@ +/** + * GET /copilot-status endpoint - Get Copilot CLI installation and auth status + */ + +import type { Request, Response } from 'express'; +import { CopilotProvider } from '../../../providers/copilot-provider.js'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const DISCONNECTED_MARKER_FILE = '.copilot-disconnected'; + +async function isCopilotDisconnectedFromApp(): Promise { + try { + const projectRoot = process.cwd(); + const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE); + await fs.access(markerPath); + return true; + } catch { + return false; + } +} + +/** + * Creates handler for GET /api/setup/copilot-status + * Returns Copilot CLI installation and authentication status + */ +export function createCopilotStatusHandler() { + const installCommand = 'npm install -g @github/copilot'; + const loginCommand = 'gh auth login'; + + return async (_req: Request, res: Response): Promise => { + try { + // Check if user has manually disconnected from the app + if (await isCopilotDisconnectedFromApp()) { + res.json({ + success: true, + installed: true, + version: null, + path: null, + auth: { + authenticated: false, + method: 'none', + }, + installCommand, + loginCommand, + }); + return; + } + + const provider = new CopilotProvider(); + const status = await provider.detectInstallation(); + const auth = await provider.checkAuth(); + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + auth: { + authenticated: auth.authenticated, + method: auth.method, + login: auth.login, + host: auth.host, + error: auth.error, + }, + installCommand, + loginCommand, + }); + } catch (error) { + logError(error, 'Get Copilot status failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/deauth-copilot.ts b/apps/server/src/routes/setup/routes/deauth-copilot.ts new file mode 100644 index 00000000..a5420315 --- /dev/null +++ b/apps/server/src/routes/setup/routes/deauth-copilot.ts @@ -0,0 +1,30 @@ +/** + * POST /deauth-copilot endpoint - Disconnect Copilot CLI from the app + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { disconnectCopilot } from '../../../services/copilot-connection-service.js'; + +/** + * Creates handler for POST /api/setup/deauth-copilot + * Creates a marker file to disconnect Copilot CLI from the app + */ +export function createDeauthCopilotHandler() { + return async (_req: Request, res: Response): Promise => { + try { + await disconnectCopilot(); + + res.json({ + success: true, + message: 'Copilot CLI disconnected from app', + }); + } catch (error) { + logError(error, 'Deauth Copilot failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/services/copilot-connection-service.ts b/apps/server/src/services/copilot-connection-service.ts new file mode 100644 index 00000000..deb1e432 --- /dev/null +++ b/apps/server/src/services/copilot-connection-service.ts @@ -0,0 +1,80 @@ +/** + * Copilot Connection Service + * + * Handles the connection and disconnection of Copilot CLI to the app. + * Uses a marker file to track the disconnected state. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { createLogger } from '@automaker/utils'; +import { COPILOT_DISCONNECTED_MARKER_FILE } from '../routes/setup/common.js'; + +const logger = createLogger('CopilotConnectionService'); + +/** + * Get the path to the disconnected marker file + */ +function getMarkerPath(projectRoot?: string): string { + const root = projectRoot || process.cwd(); + const automakerDir = path.join(root, '.automaker'); + return path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE); +} + +/** + * Connect Copilot CLI to the app by removing the disconnected marker + * + * @param projectRoot - Optional project root directory (defaults to cwd) + * @returns Promise that resolves when the connection is established + */ +export async function connectCopilot(projectRoot?: string): Promise { + const markerPath = getMarkerPath(projectRoot); + + try { + await fs.unlink(markerPath); + logger.info('Copilot CLI connected to app (marker removed)'); + } catch (error) { + // File doesn't exist - that's fine, Copilot is already connected + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('Failed to remove disconnected marker:', error); + throw error; + } + logger.debug('Copilot already connected (no marker file found)'); + } +} + +/** + * Disconnect Copilot CLI from the app by creating the disconnected marker + * + * @param projectRoot - Optional project root directory (defaults to cwd) + * @returns Promise that resolves when the disconnection is complete + */ +export async function disconnectCopilot(projectRoot?: string): Promise { + const root = projectRoot || process.cwd(); + const automakerDir = path.join(root, '.automaker'); + const markerPath = path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE); + + // Ensure .automaker directory exists + await fs.mkdir(automakerDir, { recursive: true }); + + // Create the disconnection marker + await fs.writeFile(markerPath, 'Copilot CLI disconnected from app'); + logger.info('Copilot CLI disconnected from app (marker created)'); +} + +/** + * Check if Copilot CLI is connected (not disconnected) + * + * @param projectRoot - Optional project root directory (defaults to cwd) + * @returns Promise that resolves to true if connected, false if disconnected + */ +export async function isCopilotConnected(projectRoot?: string): Promise { + const markerPath = getMarkerPath(projectRoot); + + try { + await fs.access(markerPath); + return false; // Marker exists = disconnected + } catch { + return true; // Marker doesn't exist = connected + } +} diff --git a/apps/server/tests/unit/providers/copilot-provider.test.ts b/apps/server/tests/unit/providers/copilot-provider.test.ts new file mode 100644 index 00000000..ccd7ae28 --- /dev/null +++ b/apps/server/tests/unit/providers/copilot-provider.test.ts @@ -0,0 +1,517 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js'; + +// Mock the Copilot SDK +vi.mock('@github/copilot-sdk', () => ({ + CopilotClient: vi.fn().mockImplementation(() => ({ + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + createSession: vi.fn().mockResolvedValue({ + sessionId: 'test-session', + send: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + }), + })), +})); + +// Mock child_process with all needed exports +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn(), + }; +}); + +// Mock fs (synchronous) for CLI detection (existsSync) +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(true), + }; +}); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + access: vi.fn().mockRejectedValue(new Error('Not found')), + readFile: vi.fn().mockRejectedValue(new Error('Not found')), + mkdir: vi.fn().mockResolvedValue(undefined), +})); + +// Import execSync after mocking +import { execSync } from 'child_process'; +import * as fs from 'fs'; + +describe('copilot-provider.ts', () => { + let provider: CopilotProvider; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock fs.existsSync for CLI path validation + vi.mocked(fs.existsSync).mockReturnValue(true); + + // Mock CLI detection to find the CLI + // The CliProvider base class uses 'which copilot' (Unix) or 'where copilot' (Windows) + // to find the CLI path, then validates with fs.existsSync + vi.mocked(execSync).mockImplementation((cmd: string) => { + // CLI path detection (which/where command) + if (cmd.startsWith('which ') || cmd.startsWith('where ')) { + return '/usr/local/bin/copilot'; + } + if (cmd.includes('--version')) { + return '1.0.0'; + } + if (cmd.includes('gh auth status')) { + return 'Logged in to github.com account testuser'; + } + if (cmd.includes('models list')) { + return JSON.stringify([{ id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }]); + } + return ''; + }); + + provider = new CopilotProvider(); + delete process.env.GITHUB_TOKEN; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getName', () => { + it("should return 'copilot' as provider name", () => { + expect(provider.getName()).toBe('copilot'); + }); + }); + + describe('getCliName', () => { + it("should return 'copilot' as CLI name", () => { + expect(provider.getCliName()).toBe('copilot'); + }); + }); + + describe('supportsFeature', () => { + it('should support tools feature', () => { + expect(provider.supportsFeature('tools')).toBe(true); + }); + + it('should support text feature', () => { + expect(provider.supportsFeature('text')).toBe(true); + }); + + it('should support streaming feature', () => { + expect(provider.supportsFeature('streaming')).toBe(true); + }); + + it('should NOT support vision feature (not implemented yet)', () => { + expect(provider.supportsFeature('vision')).toBe(false); + }); + + it('should not support unknown feature', () => { + expect(provider.supportsFeature('unknown')).toBe(false); + }); + }); + + describe('getAvailableModels', () => { + it('should return static model definitions', () => { + const models = provider.getAvailableModels(); + expect(Array.isArray(models)).toBe(true); + expect(models.length).toBeGreaterThan(0); + + // All models should have required fields + models.forEach((model) => { + expect(model.id).toBeDefined(); + expect(model.name).toBeDefined(); + expect(model.provider).toBe('copilot'); + }); + }); + + it('should include copilot- prefix in model IDs', () => { + const models = provider.getAvailableModels(); + models.forEach((model) => { + expect(model.id).toMatch(/^copilot-/); + }); + }); + }); + + describe('checkAuth', () => { + it('should return authenticated status when gh CLI is logged in', async () => { + // Set up mocks BEFORE creating provider to ensure CLI detection succeeds + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(execSync).mockImplementation((cmd: string) => { + // CLI path detection (which/where command) + if (cmd.startsWith('which ') || cmd.startsWith('where ')) { + return '/usr/local/bin/copilot'; + } + if (cmd.includes('--version')) { + return '1.0.0'; + } + if (cmd.includes('gh auth status')) { + return 'Logged in to github.com account testuser'; + } + return ''; + }); + + // Create fresh provider with the mock in place + const freshProvider = new CopilotProvider(); + const status = await freshProvider.checkAuth(); + expect(status.authenticated).toBe(true); + expect(status.method).toBe('oauth'); + expect(status.login).toBe('testuser'); + }); + + it('should return unauthenticated when gh auth fails', async () => { + // Set up mocks BEFORE creating provider + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(execSync).mockImplementation((cmd: string) => { + // CLI path detection (which/where command) + if (cmd.startsWith('which ') || cmd.startsWith('where ')) { + return '/usr/local/bin/copilot'; + } + if (cmd.includes('--version')) { + return '1.0.0'; + } + if (cmd.includes('gh auth status')) { + throw new Error('Not logged in'); + } + if (cmd.includes('copilot auth status')) { + throw new Error('Not logged in'); + } + return ''; + }); + + // Create fresh provider with the mock in place + const freshProvider = new CopilotProvider(); + const status = await freshProvider.checkAuth(); + expect(status.authenticated).toBe(false); + expect(status.method).toBe('none'); + }); + + it('should detect GITHUB_TOKEN environment variable', async () => { + process.env.GITHUB_TOKEN = 'test-token'; + + // Set up mocks BEFORE creating provider + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(execSync).mockImplementation((cmd: string) => { + // CLI path detection (which/where command) + if (cmd.startsWith('which ') || cmd.startsWith('where ')) { + return '/usr/local/bin/copilot'; + } + if (cmd.includes('--version')) { + return '1.0.0'; + } + if (cmd.includes('gh auth status')) { + throw new Error('Not logged in'); + } + if (cmd.includes('copilot auth status')) { + throw new Error('Not logged in'); + } + return ''; + }); + + // Create fresh provider with the mock in place + const freshProvider = new CopilotProvider(); + const status = await freshProvider.checkAuth(); + expect(status.authenticated).toBe(true); + expect(status.method).toBe('oauth'); + + delete process.env.GITHUB_TOKEN; + }); + }); + + describe('detectInstallation', () => { + it('should detect installed CLI', async () => { + // Set up mocks BEFORE creating provider + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(execSync).mockImplementation((cmd: string) => { + // CLI path detection (which/where command) + if (cmd.startsWith('which ') || cmd.startsWith('where ')) { + return '/usr/local/bin/copilot'; + } + if (cmd.includes('--version')) { + return '1.2.3'; + } + if (cmd.includes('gh auth status')) { + return 'Logged in to github.com account testuser'; + } + return ''; + }); + + // Create fresh provider with the mock in place + const freshProvider = new CopilotProvider(); + const status = await freshProvider.detectInstallation(); + expect(status.installed).toBe(true); + expect(status.version).toBe('1.2.3'); + expect(status.authenticated).toBe(true); + }); + }); + + describe('normalizeEvent', () => { + it('should normalize assistant.message event', () => { + const event = { + type: 'assistant.message', + data: { content: 'Hello, world!' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).toEqual({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Hello, world!' }], + }, + }); + }); + + it('should skip assistant.message_delta event', () => { + const event = { + type: 'assistant.message_delta', + data: { delta: 'partial' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).toBeNull(); + }); + + it('should normalize tool.execution_start event', () => { + const event = { + type: 'tool.execution_start', + data: { + toolName: 'read_file', + toolCallId: 'call-123', + input: { path: '/test/file.txt' }, + }, + }; + + const result = provider.normalizeEvent(event); + expect(result).toEqual({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Read', // Normalized from read_file + tool_use_id: 'call-123', + input: { path: '/test/file.txt', file_path: '/test/file.txt' }, // Path normalized + }, + ], + }, + }); + }); + + it('should normalize tool.execution_end event', () => { + const event = { + type: 'tool.execution_end', + data: { + toolName: 'read_file', + toolCallId: 'call-123', + result: 'file content', + }, + }; + + const result = provider.normalizeEvent(event); + expect(result).toEqual({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-123', + content: 'file content', + }, + ], + }, + }); + }); + + it('should handle tool.execution_end with error', () => { + const event = { + type: 'tool.execution_end', + data: { + toolName: 'bash', + toolCallId: 'call-456', + error: 'Command failed', + }, + }; + + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ + type: 'tool_result', + content: '[ERROR] Command failed', + }); + }); + + it('should normalize session.idle to success result', () => { + const event = { type: 'session.idle' }; + + const result = provider.normalizeEvent(event); + expect(result).toEqual({ + type: 'result', + subtype: 'success', + }); + }); + + it('should normalize session.error to error event', () => { + const event = { + type: 'session.error', + data: { message: 'Something went wrong' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).toEqual({ + type: 'error', + error: 'Something went wrong', + }); + }); + + it('should return null for unknown event types', () => { + const event = { type: 'unknown.event' }; + + const result = provider.normalizeEvent(event); + expect(result).toBeNull(); + }); + }); + + describe('mapError', () => { + it('should map authentication errors', () => { + const errorInfo = (provider as any).mapError('not authenticated', null); + expect(errorInfo.code).toBe(CopilotErrorCode.NOT_AUTHENTICATED); + expect(errorInfo.recoverable).toBe(true); + }); + + it('should map rate limit errors', () => { + const errorInfo = (provider as any).mapError('rate limit exceeded', null); + expect(errorInfo.code).toBe(CopilotErrorCode.RATE_LIMITED); + expect(errorInfo.recoverable).toBe(true); + }); + + it('should map model unavailable errors', () => { + const errorInfo = (provider as any).mapError('model not available', null); + expect(errorInfo.code).toBe(CopilotErrorCode.MODEL_UNAVAILABLE); + expect(errorInfo.recoverable).toBe(true); + }); + + it('should map network errors', () => { + const errorInfo = (provider as any).mapError('connection refused', null); + expect(errorInfo.code).toBe(CopilotErrorCode.NETWORK_ERROR); + expect(errorInfo.recoverable).toBe(true); + }); + + it('should map process crash (exit code 137)', () => { + const errorInfo = (provider as any).mapError('', 137); + expect(errorInfo.code).toBe(CopilotErrorCode.PROCESS_CRASHED); + expect(errorInfo.recoverable).toBe(true); + }); + + it('should return unknown error for unrecognized errors', () => { + const errorInfo = (provider as any).mapError('some random error', 1); + expect(errorInfo.code).toBe(CopilotErrorCode.UNKNOWN); + expect(errorInfo.recoverable).toBe(false); + }); + }); + + describe('model cache', () => { + it('should indicate when cache is empty', () => { + expect(provider.hasCachedModels()).toBe(false); + }); + + it('should clear model cache', () => { + provider.clearModelCache(); + expect(provider.hasCachedModels()).toBe(false); + }); + }); + + describe('tool name normalization', () => { + it('should normalize read_file to Read', () => { + const event = { + type: 'tool.execution_start', + data: { toolName: 'read_file', toolCallId: 'id', input: {} }, + }; + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ name: 'Read' }); + }); + + it('should normalize write_file to Write', () => { + const event = { + type: 'tool.execution_start', + data: { toolName: 'write_file', toolCallId: 'id', input: {} }, + }; + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ name: 'Write' }); + }); + + it('should normalize run_shell to Bash', () => { + const event = { + type: 'tool.execution_start', + data: { toolName: 'run_shell', toolCallId: 'id', input: {} }, + }; + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ name: 'Bash' }); + }); + + it('should normalize search to Grep', () => { + const event = { + type: 'tool.execution_start', + data: { toolName: 'search', toolCallId: 'id', input: {} }, + }; + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ name: 'Grep' }); + }); + + it('should normalize todo_write to TodoWrite', () => { + const event = { + type: 'tool.execution_start', + data: { + toolName: 'todo_write', + toolCallId: 'id', + input: { + todos: [{ description: 'Test task', status: 'pending' }], + }, + }, + }; + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ name: 'TodoWrite' }); + }); + + it('should normalize todo content from description', () => { + const event = { + type: 'tool.execution_start', + data: { + toolName: 'todo_write', + toolCallId: 'id', + input: { + todos: [{ description: 'Test task', status: 'pending' }], + }, + }, + }; + const result = provider.normalizeEvent(event); + const todoInput = (result?.message?.content?.[0] as any)?.input; + expect(todoInput.todos[0]).toMatchObject({ + content: 'Test task', + status: 'pending', + activeForm: 'Test task', + }); + }); + + it('should map cancelled status to completed', () => { + const event = { + type: 'tool.execution_start', + data: { + toolName: 'todo_write', + toolCallId: 'id', + input: { + todos: [{ description: 'Cancelled task', status: 'cancelled' }], + }, + }, + }; + const result = provider.normalizeEvent(event); + const todoInput = (result?.message?.content?.[0] as any)?.input; + expect(todoInput.todos[0].status).toBe('completed'); + }); + }); +}); diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index fdbf1c4a..fbf01e90 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -5,6 +5,7 @@ import { CursorProvider } from '@/providers/cursor-provider.js'; import { CodexProvider } from '@/providers/codex-provider.js'; import { OpencodeProvider } from '@/providers/opencode-provider.js'; import { GeminiProvider } from '@/providers/gemini-provider.js'; +import { CopilotProvider } from '@/providers/copilot-provider.js'; describe('provider-factory.ts', () => { let consoleSpy: any; @@ -13,6 +14,7 @@ describe('provider-factory.ts', () => { let detectCodexSpy: any; let detectOpencodeSpy: any; let detectGeminiSpy: any; + let detectCopilotSpy: any; beforeEach(() => { consoleSpy = { @@ -35,6 +37,9 @@ describe('provider-factory.ts', () => { detectGeminiSpy = vi .spyOn(GeminiProvider.prototype, 'detectInstallation') .mockResolvedValue({ installed: true }); + detectCopilotSpy = vi + .spyOn(CopilotProvider.prototype, 'detectInstallation') + .mockResolvedValue({ installed: true }); }); afterEach(() => { @@ -44,6 +49,7 @@ describe('provider-factory.ts', () => { detectCodexSpy.mockRestore(); detectOpencodeSpy.mockRestore(); detectGeminiSpy.mockRestore(); + detectCopilotSpy.mockRestore(); }); describe('getProviderForModel', () => { @@ -172,9 +178,15 @@ describe('provider-factory.ts', () => { expect(hasClaudeProvider).toBe(true); }); - it('should return exactly 5 providers', () => { + it('should return exactly 6 providers', () => { const providers = ProviderFactory.getAllProviders(); - expect(providers).toHaveLength(5); + expect(providers).toHaveLength(6); + }); + + it('should include CopilotProvider', () => { + const providers = ProviderFactory.getAllProviders(); + const hasCopilotProvider = providers.some((p) => p instanceof CopilotProvider); + expect(hasCopilotProvider).toBe(true); }); it('should include GeminiProvider', () => { @@ -219,7 +231,8 @@ describe('provider-factory.ts', () => { expect(keys).toContain('codex'); expect(keys).toContain('opencode'); expect(keys).toContain('gemini'); - expect(keys).toHaveLength(5); + expect(keys).toContain('copilot'); + expect(keys).toHaveLength(6); }); it('should include cursor status', async () => { diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index 456a09c8..b2de8581 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -19,6 +19,7 @@ const PROVIDER_ICON_KEYS = { minimax: 'minimax', glm: 'glm', bigpickle: 'bigpickle', + copilot: 'copilot', } as const; type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS; @@ -113,6 +114,12 @@ const PROVIDER_ICON_DEFINITIONS: Record path: 'M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z', fill: '#4ADE80', }, + copilot: { + viewBox: '0 0 98 96', + // Official GitHub Octocat logo mark + path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z', + fill: '#ffffff', + }, }; export interface ProviderIconProps extends Omit, 'viewBox'> { @@ -198,6 +205,10 @@ export function GeminiIcon({ title, className, ...props }: GeminiIconProps) { ); } +export function CopilotIcon(props: Omit) { + return ; +} + export function GrokIcon(props: Omit) { return ; } @@ -424,6 +435,7 @@ export const PROVIDER_ICON_COMPONENTS: Record< codex: OpenAIIcon, opencode: OpenCodeIcon, gemini: GeminiIcon, + copilot: CopilotIcon, }; /** @@ -574,6 +586,10 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey { if (modelStr.includes('grok')) { return 'grok'; } + // GitHub Copilot models + if (modelStr.includes('copilot')) { + return 'copilot'; + } // Cursor models - canonical format includes 'cursor-' prefix // Also support legacy IDs for backward compatibility if ( @@ -591,6 +607,7 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey { if (provider === 'codex') return 'openai'; if (provider === 'cursor') return 'cursor'; if (provider === 'opencode') return 'opencode'; + if (provider === 'copilot') return 'copilot'; return 'anthropic'; } @@ -615,6 +632,7 @@ export function getProviderIconForModel( minimax: MiniMaxIcon, glm: GlmIcon, bigpickle: BigPickleIcon, + copilot: CopilotIcon, }; return iconMap[iconKey] || AnthropicIcon; diff --git a/apps/ui/src/components/views/board-view/shared/model-constants.ts b/apps/ui/src/components/views/board-view/shared/model-constants.ts index a619c112..83c75827 100644 --- a/apps/ui/src/components/views/board-view/shared/model-constants.ts +++ b/apps/ui/src/components/views/board-view/shared/model-constants.ts @@ -5,6 +5,7 @@ import { CODEX_MODEL_MAP, OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS, GEMINI_MODEL_MAP, + COPILOT_MODEL_MAP, } from '@automaker/types'; import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react'; import { @@ -13,6 +14,7 @@ import { OpenAIIcon, OpenCodeIcon, GeminiIcon, + CopilotIcon, } from '@/components/ui/provider-icon'; export type ModelOption = { @@ -140,7 +142,22 @@ export const GEMINI_MODELS: ModelOption[] = Object.entries(GEMINI_MODEL_MAP).map ); /** - * All available models (Claude + Cursor + Codex + OpenCode + Gemini) + * Copilot models derived from COPILOT_MODEL_MAP + * Model IDs already have 'copilot-' prefix + */ +export const COPILOT_MODELS: ModelOption[] = Object.entries(COPILOT_MODEL_MAP).map( + ([id, config]) => ({ + id, // IDs already have copilot- prefix (e.g., 'copilot-gpt-4o') + label: config.label, + description: config.description, + badge: config.supportsVision ? 'Vision' : 'Standard', + provider: 'copilot' as ModelProvider, + hasThinking: false, + }) +); + +/** + * All available models (Claude + Cursor + Codex + OpenCode + Gemini + Copilot) */ export const ALL_MODELS: ModelOption[] = [ ...CLAUDE_MODELS, @@ -148,6 +165,7 @@ export const ALL_MODELS: ModelOption[] = [ ...CODEX_MODELS, ...OPENCODE_MODELS, ...GEMINI_MODELS, + ...COPILOT_MODELS, ]; export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink']; @@ -195,4 +213,5 @@ export const PROFILE_ICONS: Record; case 'gemini-provider': return ; + case 'copilot-provider': + return ; case 'providers': case 'claude': // Backwards compatibility - redirect to claude-provider return ; diff --git a/apps/ui/src/components/views/settings-view/cli-status/copilot-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/copilot-cli-status.tsx new file mode 100644 index 00000000..9a1e8817 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/cli-status/copilot-cli-status.tsx @@ -0,0 +1,234 @@ +import { Button } from '@/components/ui/button'; +import { SkeletonPulse } from '@/components/ui/skeleton'; +import { Spinner } from '@/components/ui/spinner'; +import { CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { CliStatus } from '../shared/types'; +import { CopilotIcon } from '@/components/ui/provider-icon'; +import type { CopilotAuthStatus } from '@automaker/types'; + +// Re-export for backwards compatibility +export type { CopilotAuthStatus }; + +function getAuthMethodLabel(method: CopilotAuthStatus['method']): string { + switch (method) { + case 'oauth': + return 'GitHub OAuth'; + case 'cli': + return 'Copilot CLI'; + default: + return method || 'Unknown'; + } +} + +interface CopilotCliStatusProps { + status: CliStatus | null; + authStatus?: CopilotAuthStatus | null; + isChecking: boolean; + onRefresh: () => void; +} + +export function CopilotCliStatusSkeleton() { + return ( +
+
+
+
+ + +
+ +
+
+ +
+
+
+ {/* Installation status skeleton */} +
+ +
+ + + +
+
+ {/* Auth status skeleton */} +
+ +
+ + +
+
+
+
+ ); +} + +export function CopilotCliStatus({ + status, + authStatus, + isChecking, + onRefresh, +}: CopilotCliStatusProps) { + if (!status) return ; + + return ( +
+
+
+
+
+ +
+

+ GitHub Copilot CLI +

+
+ +
+

+ GitHub Copilot CLI provides access to GPT and Claude models via your Copilot subscription. +

+
+
+ {status.success && status.status === 'installed' ? ( +
+
+
+ +
+
+

Copilot CLI Installed

+
+ {status.method && ( +

+ Method: {status.method} +

+ )} + {status.version && ( +

+ Version: {status.version} +

+ )} + {status.path && ( +

+ Path: {status.path} +

+ )} +
+
+
+ + {/* Authentication Status */} + {authStatus?.authenticated ? ( +
+
+ +
+
+

Authenticated

+
+ {authStatus.method !== 'none' && ( +

+ Method:{' '} + {getAuthMethodLabel(authStatus.method)} +

+ )} + {authStatus.login && ( +

+ User: {authStatus.login} +

+ )} +
+
+
+ ) : ( +
+
+ +
+
+

Authentication Required

+ {authStatus?.error && ( +

{authStatus.error}

+ )} +

+ Run gh auth login{' '} + in your terminal to authenticate with GitHub. +

+
+
+ )} + + {status.recommendation && ( +

{status.recommendation}

+ )} +
+ ) : ( +
+
+
+ +
+
+

Copilot CLI Not Detected

+

+ {status.recommendation || + 'Install GitHub Copilot CLI to use models via your Copilot subscription.'} +

+
+
+ {status.installCommands && ( +
+

Installation Commands:

+
+ {status.installCommands.npm && ( +
+

+ npm +

+ + {status.installCommands.npm} + +
+ )} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx b/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx index 44c7fd84..4017dc6b 100644 --- a/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx +++ b/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx @@ -18,6 +18,7 @@ const NAV_ID_TO_PROVIDER: Record = { 'codex-provider': 'codex', 'opencode-provider': 'opencode', 'gemini-provider': 'gemini', + 'copilot-provider': 'copilot', }; interface SettingsNavigationProps { diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index deb086d2..e7647379 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -23,6 +23,7 @@ import { OpenAIIcon, OpenCodeIcon, GeminiIcon, + CopilotIcon, } from '@/components/ui/provider-icon'; import type { SettingsViewId } from '../hooks/use-settings-view'; @@ -58,6 +59,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [ { id: 'codex-provider', label: 'Codex', icon: OpenAIIcon }, { id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon }, { id: 'gemini-provider', label: 'Gemini', icon: GeminiIcon }, + { id: 'copilot-provider', label: 'Copilot', icon: CopilotIcon }, ], }, { id: 'mcp-servers', label: 'MCP Servers', icon: Plug }, diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts index 26976233..e466da5d 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts @@ -9,6 +9,7 @@ export type SettingsViewId = | 'codex-provider' | 'opencode-provider' | 'gemini-provider' + | 'copilot-provider' | 'mcp-servers' | 'prompts' | 'model-defaults' diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index 9b908d5a..d9adc44f 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -8,6 +8,7 @@ import type { CodexModelId, OpencodeModelId, GeminiModelId, + CopilotModelId, GroupedModel, PhaseModelEntry, ClaudeCompatibleProvider, @@ -27,6 +28,7 @@ import { CURSOR_MODELS, OPENCODE_MODELS, GEMINI_MODELS, + COPILOT_MODELS, THINKING_LEVELS, THINKING_LEVEL_LABELS, REASONING_EFFORT_LEVELS, @@ -42,6 +44,7 @@ import { GlmIcon, MiniMaxIcon, GeminiIcon, + CopilotIcon, getProviderIconForModel, } from '@/components/ui/provider-icon'; import { Button } from '@/components/ui/button'; @@ -172,6 +175,7 @@ export function PhaseModelSelector({ const { enabledCursorModels, enabledGeminiModels, + enabledCopilotModels, favoriteModels, toggleFavoriteModel, codexModels, @@ -331,6 +335,11 @@ export function PhaseModelSelector({ return enabledGeminiModels.includes(model.id as GeminiModelId); }); + // Filter Copilot models to only show enabled ones + const availableCopilotModels = COPILOT_MODELS.filter((model) => { + return enabledCopilotModels.includes(model.id as CopilotModelId); + }); + // Helper to find current selected model details const currentModel = useMemo(() => { const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel); @@ -378,6 +387,15 @@ export function PhaseModelSelector({ }; } + // Check Copilot models + const copilotModel = availableCopilotModels.find((m) => m.id === selectedModel); + if (copilotModel) { + return { + ...copilotModel, + icon: CopilotIcon, + }; + } + // Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel); if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) }; @@ -479,6 +497,7 @@ export function PhaseModelSelector({ selectedThinkingLevel, availableCursorModels, availableGeminiModels, + availableCopilotModels, transformedCodexModels, dynamicOpencodeModels, enabledProviders, @@ -545,19 +564,22 @@ export function PhaseModelSelector({ // Check if providers are disabled (needed for rendering conditions) const isCursorDisabled = disabledProviders.includes('cursor'); const isGeminiDisabled = disabledProviders.includes('gemini'); + const isCopilotDisabled = disabledProviders.includes('copilot'); // Group models (filtering out disabled providers) - const { favorites, claude, cursor, codex, gemini, opencode } = useMemo(() => { + const { favorites, claude, cursor, codex, gemini, copilot, opencode } = useMemo(() => { const favs: typeof CLAUDE_MODELS = []; const cModels: typeof CLAUDE_MODELS = []; const curModels: typeof CURSOR_MODELS = []; const codModels: typeof transformedCodexModels = []; const gemModels: typeof GEMINI_MODELS = []; + const copModels: typeof COPILOT_MODELS = []; const ocModels: ModelOption[] = []; const isClaudeDisabled = disabledProviders.includes('claude'); const isCodexDisabled = disabledProviders.includes('codex'); const isGeminiDisabledInner = disabledProviders.includes('gemini'); + const isCopilotDisabledInner = disabledProviders.includes('copilot'); const isOpencodeDisabled = disabledProviders.includes('opencode'); // Process Claude Models (skip if provider is disabled) @@ -604,6 +626,17 @@ export function PhaseModelSelector({ }); } + // Process Copilot Models (skip if provider is disabled) + if (!isCopilotDisabledInner) { + availableCopilotModels.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + copModels.push(model); + } + }); + } + // Process OpenCode Models (skip if provider is disabled) if (!isOpencodeDisabled) { allOpencodeModels.forEach((model) => { @@ -621,12 +654,14 @@ export function PhaseModelSelector({ cursor: curModels, codex: codModels, gemini: gemModels, + copilot: copModels, opencode: ocModels, }; }, [ favoriteModels, availableCursorModels, availableGeminiModels, + availableCopilotModels, transformedCodexModels, allOpencodeModels, disabledProviders, @@ -1117,6 +1152,59 @@ export function PhaseModelSelector({ ); }; + // Render Copilot model item - simple selector without thinking level + const renderCopilotModelItem = (model: (typeof COPILOT_MODELS)[0]) => { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + + return ( + { + onChange({ model: model.id as CopilotModelId }); + setOpen(false); + }} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + {model.description} +
+
+ +
+ + {isSelected && } +
+
+ ); + }; + // Render ClaudeCompatibleProvider model item with thinking level support const renderProviderModelItem = ( provider: ClaudeCompatibleProvider, @@ -1933,6 +2021,10 @@ export function PhaseModelSelector({ if (model.provider === 'gemini') { return renderGeminiModelItem(model as (typeof GEMINI_MODELS)[0]); } + // Copilot model + if (model.provider === 'copilot') { + return renderCopilotModelItem(model as (typeof COPILOT_MODELS)[0]); + } // OpenCode model if (model.provider === 'opencode') { return renderOpencodeModelItem(model); @@ -2017,6 +2109,12 @@ export function PhaseModelSelector({ )} + {!isCopilotDisabled && copilot.length > 0 && ( + + {copilot.map((model) => renderCopilotModelItem(model))} + + )} + {opencodeSections.length > 0 && ( {opencodeSections.map((section, sectionIndex) => ( diff --git a/apps/ui/src/components/views/settings-view/providers/copilot-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/copilot-model-configuration.tsx new file mode 100644 index 00000000..f5c35ea5 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/copilot-model-configuration.tsx @@ -0,0 +1,53 @@ +import type { CopilotModelId } from '@automaker/types'; +import { CopilotIcon } from '@/components/ui/provider-icon'; +import { COPILOT_MODEL_MAP } from '@automaker/types'; +import { BaseModelConfiguration, type BaseModelInfo } from './shared/base-model-configuration'; + +interface CopilotModelConfigurationProps { + enabledCopilotModels: CopilotModelId[]; + copilotDefaultModel: CopilotModelId; + isSaving: boolean; + onDefaultModelChange: (model: CopilotModelId) => void; + onModelToggle: (model: CopilotModelId, enabled: boolean) => void; +} + +interface CopilotModelInfo extends BaseModelInfo { + supportsVision: boolean; +} + +// Build model info from the COPILOT_MODEL_MAP +const COPILOT_MODELS: CopilotModelInfo[] = Object.entries(COPILOT_MODEL_MAP).map( + ([id, config]) => ({ + id: id as CopilotModelId, + label: config.label, + description: config.description, + supportsVision: config.supportsVision, + }) +); + +export function CopilotModelConfiguration({ + enabledCopilotModels, + copilotDefaultModel, + isSaving, + onDefaultModelChange, + onModelToggle, +}: CopilotModelConfigurationProps) { + return ( + + providerName="Copilot" + icon={} + iconGradient="from-violet-500/20 to-violet-600/10" + iconBorder="border-violet-500/20" + models={COPILOT_MODELS} + enabledModels={enabledCopilotModels} + defaultModel={copilotDefaultModel} + isSaving={isSaving} + onDefaultModelChange={onDefaultModelChange} + onModelToggle={onModelToggle} + getFeatureBadge={(model) => { + const copilotModel = model as CopilotModelInfo; + return copilotModel.supportsVision ? { show: true, label: 'Vision' } : null; + }} + /> + ); +} diff --git a/apps/ui/src/components/views/settings-view/providers/copilot-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/copilot-settings-tab.tsx new file mode 100644 index 00000000..28be8cb4 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/copilot-settings-tab.tsx @@ -0,0 +1,130 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { useAppStore } from '@/store/app-store'; +import { CopilotCliStatus, CopilotCliStatusSkeleton } from '../cli-status/copilot-cli-status'; +import { CopilotModelConfiguration } from './copilot-model-configuration'; +import { ProviderToggle } from './provider-toggle'; +import { useCopilotCliStatus } from '@/hooks/queries'; +import { queryKeys } from '@/lib/query-keys'; +import type { CliStatus as SharedCliStatus } from '../shared/types'; +import type { CopilotAuthStatus } from '../cli-status/copilot-cli-status'; +import type { CopilotModelId } from '@automaker/types'; + +export function CopilotSettingsTab() { + const queryClient = useQueryClient(); + const { enabledCopilotModels, copilotDefaultModel, setCopilotDefaultModel, toggleCopilotModel } = + useAppStore(); + + const [isSaving, setIsSaving] = useState(false); + + // React Query hooks for data fetching + const { + data: cliStatusData, + isLoading: isCheckingCopilotCli, + refetch: refetchCliStatus, + } = useCopilotCliStatus(); + + const isCliInstalled = cliStatusData?.installed ?? false; + + // Transform CLI status to the expected format + const cliStatus = useMemo((): SharedCliStatus | null => { + if (!cliStatusData) return null; + return { + success: cliStatusData.success ?? false, + status: cliStatusData.installed ? 'installed' : 'not_installed', + method: cliStatusData.auth?.method, + version: cliStatusData.version, + path: cliStatusData.path, + recommendation: cliStatusData.recommendation, + // Server sends installCommand (singular), transform to expected format + installCommands: cliStatusData.installCommand + ? { npm: cliStatusData.installCommand } + : cliStatusData.installCommands, + }; + }, [cliStatusData]); + + // Transform auth status to the expected format + const authStatus = useMemo((): CopilotAuthStatus | null => { + if (!cliStatusData?.auth) return null; + return { + authenticated: cliStatusData.auth.authenticated, + method: (cliStatusData.auth.method as CopilotAuthStatus['method']) || 'none', + login: cliStatusData.auth.login, + host: cliStatusData.auth.host, + error: cliStatusData.auth.error, + }; + }, [cliStatusData]); + + // Refresh all copilot-related queries + const handleRefreshCopilotCli = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.cli.copilot() }); + await refetchCliStatus(); + toast.success('Copilot CLI refreshed'); + }, [queryClient, refetchCliStatus]); + + const handleDefaultModelChange = useCallback( + (model: CopilotModelId) => { + setIsSaving(true); + try { + setCopilotDefaultModel(model); + toast.success('Default model updated'); + } catch { + toast.error('Failed to update default model'); + } finally { + setIsSaving(false); + } + }, + [setCopilotDefaultModel] + ); + + const handleModelToggle = useCallback( + (model: CopilotModelId, enabled: boolean) => { + setIsSaving(true); + try { + toggleCopilotModel(model, enabled); + } catch { + toast.error('Failed to update models'); + } finally { + setIsSaving(false); + } + }, + [toggleCopilotModel] + ); + + // Show skeleton only while checking CLI status initially + if (!cliStatus && isCheckingCopilotCli) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Provider Visibility Toggle */} + + + + + {/* Model Configuration - Only show when CLI is installed */} + {isCliInstalled && ( + + )} +
+ ); +} + +export default CopilotSettingsTab; diff --git a/apps/ui/src/components/views/settings-view/providers/gemini-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/gemini-model-configuration.tsx index 4d1d8e80..cce7d837 100644 --- a/apps/ui/src/components/views/settings-view/providers/gemini-model-configuration.tsx +++ b/apps/ui/src/components/views/settings-view/providers/gemini-model-configuration.tsx @@ -1,17 +1,7 @@ -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { cn } from '@/lib/utils'; import type { GeminiModelId } from '@automaker/types'; import { GeminiIcon } from '@/components/ui/provider-icon'; import { GEMINI_MODEL_MAP } from '@automaker/types'; +import { BaseModelConfiguration, type BaseModelInfo } from './shared/base-model-configuration'; interface GeminiModelConfigurationProps { enabledGeminiModels: GeminiModelId[]; @@ -21,25 +11,17 @@ interface GeminiModelConfigurationProps { onModelToggle: (model: GeminiModelId, enabled: boolean) => void; } -interface GeminiModelInfo { - id: GeminiModelId; - label: string; - description: string; +interface GeminiModelInfo extends BaseModelInfo { supportsThinking: boolean; } // Build model info from the GEMINI_MODEL_MAP -const GEMINI_MODEL_INFO: Record = Object.fromEntries( - Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => [ - id as GeminiModelId, - { - id: id as GeminiModelId, - label: config.label, - description: config.description, - supportsThinking: config.supportsThinking, - }, - ]) -) as Record; +const GEMINI_MODELS: GeminiModelInfo[] = Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({ + id: id as GeminiModelId, + label: config.label, + description: config.description, + supportsThinking: config.supportsThinking, +})); export function GeminiModelConfiguration({ enabledGeminiModels, @@ -48,99 +30,22 @@ export function GeminiModelConfiguration({ onDefaultModelChange, onModelToggle, }: GeminiModelConfigurationProps) { - const availableModels = Object.values(GEMINI_MODEL_INFO); - return ( -
-
-
-
- -
-

- Model Configuration -

-
-

- Configure which Gemini models are available in the feature modal -

-
-
-
- - -
- -
- -
- {availableModels.map((model) => { - const isEnabled = enabledGeminiModels.includes(model.id); - const isDefault = model.id === geminiDefaultModel; - - return ( -
-
- onModelToggle(model.id, !!checked)} - disabled={isSaving || isDefault} - /> -
-
- {model.label} - {model.supportsThinking && ( - - Thinking - - )} - {isDefault && ( - - Default - - )} -
-

{model.description}

-
-
-
- ); - })} -
-
-
-
+ + providerName="Gemini" + icon={} + iconGradient="from-blue-500/20 to-blue-600/10" + iconBorder="border-blue-500/20" + models={GEMINI_MODELS} + enabledModels={enabledGeminiModels} + defaultModel={geminiDefaultModel} + isSaving={isSaving} + onDefaultModelChange={onDefaultModelChange} + onModelToggle={onModelToggle} + getFeatureBadge={(model) => { + const geminiModel = model as GeminiModelInfo; + return geminiModel.supportsThinking ? { show: true, label: 'Thinking' } : null; + }} + /> ); } diff --git a/apps/ui/src/components/views/settings-view/providers/index.ts b/apps/ui/src/components/views/settings-view/providers/index.ts index 31560019..fab18b5f 100644 --- a/apps/ui/src/components/views/settings-view/providers/index.ts +++ b/apps/ui/src/components/views/settings-view/providers/index.ts @@ -4,3 +4,4 @@ export { CursorSettingsTab } from './cursor-settings-tab'; export { CodexSettingsTab } from './codex-settings-tab'; export { OpencodeSettingsTab } from './opencode-settings-tab'; export { GeminiSettingsTab } from './gemini-settings-tab'; +export { CopilotSettingsTab } from './copilot-settings-tab'; diff --git a/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx index 6802626a..54a1fb8b 100644 --- a/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx +++ b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx @@ -6,21 +6,23 @@ import { OpenAIIcon, GeminiIcon, OpenCodeIcon, + CopilotIcon, } from '@/components/ui/provider-icon'; import { CursorSettingsTab } from './cursor-settings-tab'; import { ClaudeSettingsTab } from './claude-settings-tab'; import { CodexSettingsTab } from './codex-settings-tab'; import { OpencodeSettingsTab } from './opencode-settings-tab'; import { GeminiSettingsTab } from './gemini-settings-tab'; +import { CopilotSettingsTab } from './copilot-settings-tab'; interface ProviderTabsProps { - defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini'; + defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot'; } export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { return ( - + Claude @@ -41,6 +43,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { Gemini + + + Copilot + @@ -62,6 +68,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { + + + + ); } diff --git a/apps/ui/src/components/views/settings-view/providers/shared/base-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/shared/base-model-configuration.tsx new file mode 100644 index 00000000..19352b8a --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/shared/base-model-configuration.tsx @@ -0,0 +1,183 @@ +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import type { ReactNode } from 'react'; + +/** + * Generic model info structure for model configuration components + */ +export interface BaseModelInfo { + id: T; + label: string; + description: string; +} + +/** + * Badge configuration for feature indicators + */ +export interface FeatureBadge { + show: boolean; + label: string; +} + +/** + * Props for the base model configuration component + */ +export interface BaseModelConfigurationProps { + /** Provider name for display (e.g., "Gemini", "Copilot") */ + providerName: string; + /** Icon component to display in header */ + icon: ReactNode; + /** Icon container gradient classes (e.g., "from-blue-500/20 to-blue-600/10") */ + iconGradient: string; + /** Icon border color class (e.g., "border-blue-500/20") */ + iconBorder: string; + /** List of available models */ + models: BaseModelInfo[]; + /** Currently enabled model IDs */ + enabledModels: T[]; + /** Currently selected default model */ + defaultModel: T; + /** Whether saving is in progress */ + isSaving: boolean; + /** Callback when default model changes */ + onDefaultModelChange: (model: T) => void; + /** Callback when a model is toggled */ + onModelToggle: (model: T, enabled: boolean) => void; + /** Function to determine if a model should show a feature badge */ + getFeatureBadge?: (model: BaseModelInfo) => FeatureBadge | null; +} + +/** + * Base component for provider model configuration + * + * Provides a consistent UI for configuring which models are available + * and which is the default. Individual provider components can customize + * by providing their own icon, colors, and feature badges. + */ +export function BaseModelConfiguration({ + providerName, + icon, + iconGradient, + iconBorder, + models, + enabledModels, + defaultModel, + isSaving, + onDefaultModelChange, + onModelToggle, + getFeatureBadge, +}: BaseModelConfigurationProps) { + return ( +
+
+
+
+ {icon} +
+

+ Model Configuration +

+
+

+ Configure which {providerName} models are available in the feature modal +

+
+
+
+ + +
+ +
+ +
+ {models.map((model) => { + const isDefault = model.id === defaultModel; + // Default model is always considered enabled + const isEnabled = isDefault || enabledModels.includes(model.id); + const badge = getFeatureBadge?.(model); + + return ( +
+
+ onModelToggle(model.id, !!checked)} + disabled={isSaving || isDefault} + /> +
+
+ {model.label} + {badge?.show && ( + + {badge.label} + + )} + {isDefault && ( + + Default + + )} +
+

{model.description}

+
+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx index f534e425..40d19f8a 100644 --- a/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx @@ -37,6 +37,7 @@ import { OpenAIIcon, OpenCodeIcon, GeminiIcon, + CopilotIcon, } from '@/components/ui/provider-icon'; import { TerminalOutput } from '../components'; import { useCliInstallation, useTokenSave } from '../hooks'; @@ -46,7 +47,7 @@ interface ProvidersSetupStepProps { onBack: () => void; } -type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini'; +type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot'; // ============================================================================ // Claude Content @@ -1527,6 +1528,245 @@ function GeminiContent() { ); } +// ============================================================================ +// Copilot Content +// ============================================================================ +function CopilotContent() { + const { copilotCliStatus, setCopilotCliStatus } = useSetupStore(); + const [isChecking, setIsChecking] = useState(false); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const pollIntervalRef = useRef(null); + + const checkStatus = useCallback(async () => { + setIsChecking(true); + try { + const api = getElectronAPI(); + if (!api.setup?.getCopilotStatus) return; + const result = await api.setup.getCopilotStatus(); + if (result.success) { + setCopilotCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }); + if (result.auth?.authenticated) { + toast.success('Copilot CLI is ready!'); + } + } + } catch { + // Ignore + } finally { + setIsChecking(false); + } + }, [setCopilotCliStatus]); + + useEffect(() => { + checkStatus(); + return () => { + if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); + }; + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success('Command copied to clipboard'); + }; + + const handleLogin = async () => { + setIsLoggingIn(true); + try { + const loginCommand = copilotCliStatus?.loginCommand || 'gh auth login'; + await navigator.clipboard.writeText(loginCommand); + toast.info('Login command copied! Paste in terminal to authenticate.'); + + let attempts = 0; + pollIntervalRef.current = setInterval(async () => { + attempts++; + try { + const api = getElectronAPI(); + if (!api.setup?.getCopilotStatus) return; + const result = await api.setup.getCopilotStatus(); + if (result.auth?.authenticated) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setCopilotCliStatus({ + ...copilotCliStatus, + installed: result.installed ?? true, + version: result.version, + path: result.path, + auth: result.auth, + }); + setIsLoggingIn(false); + toast.success('Successfully authenticated with GitHub!'); + } + } catch { + // Ignore + } + if (attempts >= 60) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setIsLoggingIn(false); + toast.error('Login timed out. Please try again.'); + } + }, 2000); + } catch { + toast.error('Failed to start login process'); + setIsLoggingIn(false); + } + }; + + const isReady = copilotCliStatus?.installed && copilotCliStatus?.auth?.authenticated; + + return ( + + +
+ + + GitHub Copilot CLI Status + + +
+ + {copilotCliStatus?.installed + ? copilotCliStatus.auth?.authenticated + ? `Authenticated${copilotCliStatus.version ? ` (v${copilotCliStatus.version})` : ''}` + : 'Installed but not authenticated' + : 'Not installed on your system'} + +
+ + {isReady && ( +
+
+ +
+

SDK Installed

+

+ {copilotCliStatus?.version && `Version: ${copilotCliStatus.version}`} +

+
+
+
+ +
+

Authenticated

+ {copilotCliStatus?.auth?.login && ( +

+ Logged in as {copilotCliStatus.auth.login} +

+ )} +
+
+
+ )} + + {!copilotCliStatus?.installed && !isChecking && ( +
+
+ +
+

Copilot CLI not found

+

+ Install the GitHub Copilot CLI to use Copilot models. +

+
+
+
+

Install Copilot CLI:

+
+ + {copilotCliStatus?.installCommand || 'npm install -g @github/copilot'} + + +
+
+
+ )} + + {copilotCliStatus?.installed && !copilotCliStatus?.auth?.authenticated && !isChecking && ( +
+ {/* Show SDK installed toast */} +
+ +
+

SDK Installed

+

+ {copilotCliStatus?.version && `Version: ${copilotCliStatus.version}`} +

+
+
+ +
+ +
+

GitHub not authenticated

+

+ Run the GitHub CLI login command to authenticate. +

+
+
+
+
+ + {copilotCliStatus?.loginCommand || 'gh auth login'} + + +
+ +
+
+ )} + + {isChecking && ( +
+ +

Checking Copilot CLI status...

+
+ )} +
+
+ ); +} + // ============================================================================ // Main Component // ============================================================================ @@ -1544,12 +1784,14 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) codexAuthStatus, opencodeCliStatus, geminiCliStatus, + copilotCliStatus, setClaudeCliStatus, setCursorCliStatus, setCodexCliStatus, setCodexAuthStatus, setOpencodeCliStatus, setGeminiCliStatus, + setCopilotCliStatus, } = useSetupStore(); // Check all providers on mount @@ -1659,8 +1901,35 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) } }; + // Check Copilot + const checkCopilot = async () => { + try { + if (!api.setup?.getCopilotStatus) return; + const result = await api.setup.getCopilotStatus(); + if (result.success) { + setCopilotCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.path, + auth: result.auth, + installCommand: result.installCommand, + loginCommand: result.loginCommand, + }); + } + } catch { + // Ignore errors + } + }; + // Run all checks in parallel - await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode(), checkGemini()]); + await Promise.all([ + checkClaude(), + checkCursor(), + checkCodex(), + checkOpencode(), + checkGemini(), + checkCopilot(), + ]); setIsInitialChecking(false); }, [ setClaudeCliStatus, @@ -1669,6 +1938,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) setCodexAuthStatus, setOpencodeCliStatus, setGeminiCliStatus, + setCopilotCliStatus, ]); useEffect(() => { @@ -1698,12 +1968,16 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) const isGeminiInstalled = geminiCliStatus?.installed === true; const isGeminiAuthenticated = geminiCliStatus?.auth?.authenticated === true; + const isCopilotInstalled = copilotCliStatus?.installed === true; + const isCopilotAuthenticated = copilotCliStatus?.auth?.authenticated === true; + const hasAtLeastOneProvider = isClaudeAuthenticated || isCursorAuthenticated || isCodexAuthenticated || isOpencodeAuthenticated || - isGeminiAuthenticated; + isGeminiAuthenticated || + isCopilotAuthenticated; type ProviderStatus = 'not_installed' | 'installed_not_auth' | 'authenticated' | 'verifying'; @@ -1754,6 +2028,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) status: getProviderStatus(isGeminiInstalled, isGeminiAuthenticated), color: 'text-blue-500', }, + { + id: 'copilot' as const, + label: 'Copilot', + icon: CopilotIcon, + status: getProviderStatus(isCopilotInstalled, isCopilotAuthenticated), + color: 'text-violet-500', + }, ]; const renderStatusIcon = (status: ProviderStatus) => { @@ -1790,7 +2071,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) )} setActiveTab(v as ProviderTab)}> - + {providers.map((provider) => { const Icon = provider.icon; return ( @@ -1839,6 +2120,9 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) + + + diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index e58b5945..414f3f7a 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -64,6 +64,7 @@ export { useCodexCliStatus, useOpencodeCliStatus, useGeminiCliStatus, + useCopilotCliStatus, useGitHubCliStatus, useApiKeysStatus, usePlatformInfo, diff --git a/apps/ui/src/hooks/queries/use-cli-status.ts b/apps/ui/src/hooks/queries/use-cli-status.ts index 4b6705aa..527ca261 100644 --- a/apps/ui/src/hooks/queries/use-cli-status.ts +++ b/apps/ui/src/hooks/queries/use-cli-status.ts @@ -109,6 +109,26 @@ export function useGeminiCliStatus() { }); } +/** + * Fetch Copilot SDK status + * + * @returns Query result with Copilot SDK status + */ +export function useCopilotCliStatus() { + return useQuery({ + queryKey: queryKeys.cli.copilot(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getCopilotStatus(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Copilot status'); + } + return result; + }, + staleTime: STALE_TIMES.CLI_STATUS, + }); +} + /** * Fetch GitHub CLI status * diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 80f30267..8c7d9961 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -22,11 +22,13 @@ import { waitForMigrationComplete, resetMigrationState } from './use-settings-mi import { DEFAULT_OPENCODE_MODEL, DEFAULT_GEMINI_MODEL, + DEFAULT_COPILOT_MODEL, DEFAULT_MAX_CONCURRENCY, getAllOpencodeModelIds, getAllCursorModelIds, getAllCodexModelIds, getAllGeminiModelIds, + getAllCopilotModelIds, migrateCursorModelIds, migrateOpencodeModelIds, migratePhaseModelEntry, @@ -35,6 +37,7 @@ import { type OpencodeModelId, type CodexModelId, type GeminiModelId, + type CopilotModelId, } from '@automaker/types'; const logger = createLogger('SettingsSync'); @@ -75,6 +78,8 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'codexDefaultModel', 'enabledGeminiModels', 'geminiDefaultModel', + 'enabledCopilotModels', + 'copilotDefaultModel', 'enabledDynamicModelIds', 'disabledProviders', 'autoLoadClaudeMd', @@ -607,6 +612,21 @@ export async function refreshSettingsFromServer(): Promise { sanitizedEnabledGeminiModels.push(sanitizedGeminiDefaultModel); } + // Sanitize Copilot models + const validCopilotModelIds = new Set(getAllCopilotModelIds()); + const sanitizedEnabledCopilotModels = (serverSettings.enabledCopilotModels ?? []).filter( + (id): id is CopilotModelId => validCopilotModelIds.has(id as CopilotModelId) + ); + const sanitizedCopilotDefaultModel = validCopilotModelIds.has( + serverSettings.copilotDefaultModel as CopilotModelId + ) + ? (serverSettings.copilotDefaultModel as CopilotModelId) + : DEFAULT_COPILOT_MODEL; + + if (!sanitizedEnabledCopilotModels.includes(sanitizedCopilotDefaultModel)) { + sanitizedEnabledCopilotModels.push(sanitizedCopilotDefaultModel); + } + const persistedDynamicModelIds = serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds; const sanitizedDynamicModelIds = persistedDynamicModelIds.filter( @@ -703,6 +723,8 @@ export async function refreshSettingsFromServer(): Promise { codexDefaultModel: sanitizedCodexDefaultModel, enabledGeminiModels: sanitizedEnabledGeminiModels, geminiDefaultModel: sanitizedGeminiDefaultModel, + enabledCopilotModels: sanitizedEnabledCopilotModels, + copilotDefaultModel: sanitizedCopilotDefaultModel, enabledDynamicModelIds: sanitizedDynamicModelIds, disabledProviders: serverSettings.disabledProviders ?? [], autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false, diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 5282374d..1ef03fee 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1697,6 +1697,27 @@ export class HttpApiClient implements ElectronAPI { error?: string; }> => this.post('/api/setup/deauth-gemini'), + // Copilot SDK methods + getCopilotStatus: (): Promise<{ + success: boolean; + status?: string; + installed?: boolean; + method?: string; + version?: string; + path?: string; + recommendation?: string; + auth?: { + authenticated: boolean; + method: string; + login?: string; + host?: string; + error?: string; + }; + loginCommand?: string; + installCommand?: string; + error?: string; + }> => this.get('/api/setup/copilot-status'), + onInstallProgress: (callback: (progress: unknown) => void) => { return this.subscribeToEvent('agent:stream', callback); }, diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index 794515c4..afe4b5b0 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -178,6 +178,8 @@ export const queryKeys = { opencode: () => ['cli', 'opencode'] as const, /** Gemini CLI status */ gemini: () => ['cli', 'gemini'] as const, + /** Copilot SDK status */ + copilot: () => ['cli', 'copilot'] as const, /** GitHub CLI status */ github: () => ['cli', 'github'] as const, /** API keys status */ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index a8098262..1fc95ddf 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -22,6 +22,7 @@ import type { CodexModelId, OpencodeModelId, GeminiModelId, + CopilotModelId, PhaseModelConfig, PhaseModelKey, PhaseModelEntry, @@ -41,9 +42,11 @@ import { getAllCodexModelIds, getAllOpencodeModelIds, getAllGeminiModelIds, + getAllCopilotModelIds, DEFAULT_PHASE_MODELS, DEFAULT_OPENCODE_MODEL, DEFAULT_GEMINI_MODEL, + DEFAULT_COPILOT_MODEL, DEFAULT_MAX_CONCURRENCY, DEFAULT_GLOBAL_SETTINGS, } from '@automaker/types'; @@ -736,6 +739,10 @@ export interface AppState { enabledGeminiModels: GeminiModelId[]; // Which Gemini models are available in feature modal geminiDefaultModel: GeminiModelId; // Default Gemini model selection + // Copilot SDK Settings (global) + enabledCopilotModels: CopilotModelId[]; // Which Copilot models are available in feature modal + copilotDefaultModel: CopilotModelId; // Default Copilot model selection + // Provider Visibility Settings disabledProviders: ModelProvider[]; // Providers that are disabled and hidden from dropdowns @@ -1230,6 +1237,11 @@ export interface AppActions { setGeminiDefaultModel: (model: GeminiModelId) => void; toggleGeminiModel: (model: GeminiModelId, enabled: boolean) => void; + // Copilot SDK Settings actions + setEnabledCopilotModels: (models: CopilotModelId[]) => void; + setCopilotDefaultModel: (model: CopilotModelId) => void; + toggleCopilotModel: (model: CopilotModelId, enabled: boolean) => void; + // Provider Visibility Settings actions setDisabledProviders: (providers: ModelProvider[]) => void; toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void; @@ -1517,6 +1529,8 @@ const initialState: AppState = { opencodeModelsLastFailedAt: null, enabledGeminiModels: getAllGeminiModelIds(), // All Gemini models enabled by default geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Default to Gemini 2.5 Flash + enabledCopilotModels: getAllCopilotModelIds(), // All Copilot models enabled by default + copilotDefaultModel: DEFAULT_COPILOT_MODEL, // Default to Claude Sonnet 4.5 disabledProviders: [], // No providers disabled by default autoLoadClaudeMd: false, // Default to disabled (user must opt-in) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) @@ -2759,6 +2773,16 @@ export const useAppStore = create()((set, get) => ({ : state.enabledGeminiModels.filter((m) => m !== model), })), + // Copilot SDK Settings actions + setEnabledCopilotModels: (models) => set({ enabledCopilotModels: models }), + setCopilotDefaultModel: (model) => set({ copilotDefaultModel: model }), + toggleCopilotModel: (model, enabled) => + set((state) => ({ + enabledCopilotModels: enabled + ? [...state.enabledCopilotModels, model] + : state.enabledCopilotModels.filter((m) => m !== model), + })), + // Provider Visibility Settings actions setDisabledProviders: (providers) => set({ disabledProviders: providers }), toggleProviderDisabled: (provider, disabled) => diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index c2f9821b..f354e5b1 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -79,6 +79,22 @@ export interface GeminiCliStatus { error?: string; } +// Copilot SDK Status +export interface CopilotCliStatus { + installed: boolean; + version?: string | null; + path?: string | null; + auth?: { + authenticated: boolean; + method: string; + login?: string; + host?: string; + }; + installCommand?: string; + loginCommand?: string; + error?: string; +} + // Codex Auth Method export type CodexAuthMethod = | 'api_key_env' // OPENAI_API_KEY environment variable @@ -137,6 +153,7 @@ export type SetupStep = | 'codex' | 'opencode' | 'gemini' + | 'copilot' | 'github' | 'complete'; @@ -169,6 +186,9 @@ export interface SetupState { // Gemini CLI state geminiCliStatus: GeminiCliStatus | null; + // Copilot SDK state + copilotCliStatus: CopilotCliStatus | null; + // Setup preferences skipClaudeSetup: boolean; } @@ -206,6 +226,9 @@ export interface SetupActions { // Gemini CLI setGeminiCliStatus: (status: GeminiCliStatus | null) => void; + // Copilot SDK + setCopilotCliStatus: (status: CopilotCliStatus | null) => void; + // Preferences setSkipClaudeSetup: (skip: boolean) => void; } @@ -241,6 +264,8 @@ const initialState: SetupState = { geminiCliStatus: null, + copilotCliStatus: null, + skipClaudeSetup: shouldSkipSetup, }; @@ -316,6 +341,9 @@ export const useSetupStore = create()((set, get) => ( // Gemini CLI setGeminiCliStatus: (status) => set({ geminiCliStatus: status }), + // Copilot SDK + setCopilotCliStatus: (status) => set({ copilotCliStatus: status }), + // Preferences setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), })); diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts index d486d61b..d642ecde 100644 --- a/libs/model-resolver/src/resolver.ts +++ b/libs/model-resolver/src/resolver.ts @@ -4,12 +4,16 @@ * Provides centralized model resolution logic: * - Maps Claude model aliases to full model strings * - Passes through Cursor models unchanged (handled by CursorProvider) + * - Passes through Copilot models unchanged (handled by CopilotProvider) + * - Passes through Gemini models unchanged (handled by GeminiProvider) * - Provides default models per provider * - Handles multiple model sources with priority * * With canonical model IDs: * - Cursor: cursor-auto, cursor-composer-1, cursor-gpt-5.2 * - OpenCode: opencode-big-pickle, opencode-grok-code + * - Copilot: copilot-gpt-5.1, copilot-claude-sonnet-4.5, copilot-gemini-3-pro-preview + * - Gemini: gemini-2.5-flash, gemini-2.5-pro * - Claude: claude-haiku, claude-sonnet, claude-opus (also supports legacy aliases) */ @@ -22,6 +26,8 @@ import { PROVIDER_PREFIXES, isCursorModel, isOpencodeModel, + isCopilotModel, + isGeminiModel, stripProviderPrefix, migrateModelId, type PhaseModelEntry, @@ -83,6 +89,18 @@ export function resolveModelString( return canonicalKey; } + // Copilot model with explicit prefix (e.g., "copilot-gpt-5.1", "copilot-claude-sonnet-4.5") + if (isCopilotModel(canonicalKey)) { + console.log(`[ModelResolver] Using Copilot model: ${canonicalKey}`); + return canonicalKey; + } + + // Gemini model with explicit prefix (e.g., "gemini-2.5-flash", "gemini-2.5-pro") + if (isGeminiModel(canonicalKey)) { + console.log(`[ModelResolver] Using Gemini model: ${canonicalKey}`); + return canonicalKey; + } + // Claude canonical ID (claude-haiku, claude-sonnet, claude-opus) // Map to full model string if (canonicalKey in CLAUDE_CANONICAL_MAP) { diff --git a/libs/types/src/copilot-models.ts b/libs/types/src/copilot-models.ts new file mode 100644 index 00000000..21207133 --- /dev/null +++ b/libs/types/src/copilot-models.ts @@ -0,0 +1,194 @@ +/** + * GitHub Copilot CLI Model Definitions + * + * Defines available models for GitHub Copilot CLI integration. + * Based on https://github.com/github/copilot + * + * The CLI provides runtime model discovery, but we define common models + * for UI consistency and offline use. + */ + +/** + * Copilot model configuration + */ +export interface CopilotModelConfig { + label: string; + description: string; + supportsVision: boolean; + supportsTools: boolean; + contextWindow?: number; +} + +/** + * Available Copilot models via the GitHub Copilot CLI + * + * Model IDs use 'copilot-' prefix for consistent provider routing. + * When passed to the CLI, the prefix is stripped. + * + * Note: Actual available models depend on the user's Copilot subscription + * and can be discovered at runtime via the CLI's listModels() method. + */ +export const COPILOT_MODEL_MAP = { + // Claude models (Anthropic via GitHub Copilot) + 'copilot-claude-sonnet-4.5': { + label: 'Claude Sonnet 4.5', + description: 'Anthropic Claude Sonnet 4.5 via GitHub Copilot.', + supportsVision: true, + supportsTools: true, + contextWindow: 200000, + }, + 'copilot-claude-haiku-4.5': { + label: 'Claude Haiku 4.5', + description: 'Fast and efficient Claude Haiku 4.5 via GitHub Copilot.', + supportsVision: true, + supportsTools: true, + contextWindow: 200000, + }, + 'copilot-claude-opus-4.5': { + label: 'Claude Opus 4.5', + description: 'Most capable Claude Opus 4.5 via GitHub Copilot.', + supportsVision: true, + supportsTools: true, + contextWindow: 200000, + }, + 'copilot-claude-sonnet-4': { + label: 'Claude Sonnet 4', + description: 'Anthropic Claude Sonnet 4 via GitHub Copilot.', + supportsVision: true, + supportsTools: true, + contextWindow: 200000, + }, + // GPT-5 series (OpenAI via GitHub Copilot) + 'copilot-gpt-5.2-codex': { + label: 'GPT-5.2 Codex', + description: 'OpenAI GPT-5.2 Codex for advanced coding tasks.', + supportsVision: true, + supportsTools: true, + contextWindow: 128000, + }, + 'copilot-gpt-5.1-codex-max': { + label: 'GPT-5.1 Codex Max', + description: 'Maximum capability GPT-5.1 Codex model.', + supportsVision: true, + supportsTools: true, + contextWindow: 128000, + }, + 'copilot-gpt-5.1-codex': { + label: 'GPT-5.1 Codex', + description: 'OpenAI GPT-5.1 Codex for coding tasks.', + supportsVision: true, + supportsTools: true, + contextWindow: 128000, + }, + 'copilot-gpt-5.2': { + label: 'GPT-5.2', + description: 'Latest OpenAI GPT-5.2 model.', + supportsVision: true, + supportsTools: true, + contextWindow: 128000, + }, + 'copilot-gpt-5.1': { + label: 'GPT-5.1', + description: 'OpenAI GPT-5.1 model.', + supportsVision: true, + supportsTools: true, + contextWindow: 128000, + }, + 'copilot-gpt-5': { + label: 'GPT-5', + description: 'OpenAI GPT-5 base model.', + supportsVision: true, + supportsTools: true, + contextWindow: 128000, + }, + 'copilot-gpt-5.1-codex-mini': { + label: 'GPT-5.1 Codex Mini', + description: 'Fast and efficient GPT-5.1 Codex Mini.', + supportsVision: true, + supportsTools: true, + contextWindow: 128000, + }, + 'copilot-gpt-5-mini': { + label: 'GPT-5 Mini', + description: 'Lightweight GPT-5 Mini model.', + supportsVision: true, + supportsTools: true, + contextWindow: 128000, + }, + 'copilot-gpt-4.1': { + label: 'GPT-4.1', + description: 'OpenAI GPT-4.1 model.', + supportsVision: true, + supportsTools: true, + contextWindow: 128000, + }, + // Gemini models (Google via GitHub Copilot) + 'copilot-gemini-3-pro-preview': { + label: 'Gemini 3 Pro Preview', + description: 'Google Gemini 3 Pro Preview via GitHub Copilot.', + supportsVision: true, + supportsTools: true, + contextWindow: 1000000, + }, +} as const satisfies Record; + +/** + * Copilot model ID type (keys have copilot- prefix) + */ +export type CopilotModelId = keyof typeof COPILOT_MODEL_MAP; + +/** + * Get all Copilot model IDs + */ +export function getAllCopilotModelIds(): CopilotModelId[] { + return Object.keys(COPILOT_MODEL_MAP) as CopilotModelId[]; +} + +/** + * Default Copilot model + */ +export const DEFAULT_COPILOT_MODEL: CopilotModelId = 'copilot-claude-sonnet-4.5'; + +/** + * GitHub Copilot authentication status + */ +export interface CopilotAuthStatus { + authenticated: boolean; + method: 'oauth' | 'cli' | 'none'; + authType?: string; + login?: string; + host?: string; + statusMessage?: string; + error?: string; +} + +/** + * Copilot CLI status (used for installation detection) + */ +export interface CopilotCliStatus { + installed: boolean; + version?: string; + path?: string; + auth?: CopilotAuthStatus; + error?: string; +} + +/** + * Copilot model info from SDK runtime discovery + */ +export interface CopilotRuntimeModel { + id: string; + name: string; + capabilities?: { + supportsVision?: boolean; + maxInputTokens?: number; + maxOutputTokens?: number; + }; + policy?: { + state: 'enabled' | 'disabled' | 'unconfigured'; + terms?: string; + }; + billing?: { + multiplier: number; + }; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index f94b7ff9..87975a81 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -253,6 +253,9 @@ export * from './opencode-models.js'; // Gemini types export * from './gemini-models.js'; +// Copilot types +export * from './copilot-models.js'; + // Provider utilities export { PROVIDER_PREFIXES, @@ -261,6 +264,7 @@ export { isCodexModel, isOpencodeModel, isGeminiModel, + isCopilotModel, getModelProvider, stripProviderPrefix, addProviderPrefix, diff --git a/libs/types/src/provider-utils.ts b/libs/types/src/provider-utils.ts index fc84783e..025322e6 100644 --- a/libs/types/src/provider-utils.ts +++ b/libs/types/src/provider-utils.ts @@ -11,6 +11,7 @@ import { CURSOR_MODEL_MAP, LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js'; import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js'; import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js'; import { GEMINI_MODEL_MAP } from './gemini-models.js'; +import { COPILOT_MODEL_MAP } from './copilot-models.js'; /** Provider prefix constants */ export const PROVIDER_PREFIXES = { @@ -18,6 +19,7 @@ export const PROVIDER_PREFIXES = { codex: 'codex-', opencode: 'opencode-', gemini: 'gemini-', + copilot: 'copilot-', } as const; /** @@ -114,6 +116,28 @@ export function isGeminiModel(model: string | undefined | null): boolean { return false; } +/** + * Check if a model string represents a GitHub Copilot model + * + * @param model - Model string to check (e.g., "copilot-gpt-4o", "copilot-claude-3.5-sonnet") + * @returns true if the model is a Copilot model + */ +export function isCopilotModel(model: string | undefined | null): boolean { + if (!model || typeof model !== 'string') return false; + + // Canonical format: copilot- prefix (e.g., "copilot-gpt-4o") + if (model.startsWith(PROVIDER_PREFIXES.copilot)) { + return true; + } + + // Check if it's a known Copilot model ID (map keys include copilot- prefix) + if (model in COPILOT_MODEL_MAP) { + return true; + } + + return false; +} + /** * Check if a model string represents an OpenCode model * @@ -175,7 +199,11 @@ export function isOpencodeModel(model: string | undefined | null): boolean { * @returns The provider type, defaults to 'claude' for unknown models */ export function getModelProvider(model: string | undefined | null): ModelProvider { - // Check Gemini first since it uses gemini- prefix + // Check Copilot first since it has a unique prefix + if (isCopilotModel(model)) { + return 'copilot'; + } + // Check Gemini since it uses gemini- prefix if (isGeminiModel(model)) { return 'gemini'; } @@ -248,6 +276,10 @@ export function addProviderPrefix(model: string, provider: ModelProvider): strin if (!model.startsWith(PROVIDER_PREFIXES.gemini)) { return `${PROVIDER_PREFIXES.gemini}${model}`; } + } else if (provider === 'copilot') { + if (!model.startsWith(PROVIDER_PREFIXES.copilot)) { + return `${PROVIDER_PREFIXES.copilot}${model}`; + } } // Claude models don't use prefixes return model; @@ -284,6 +316,7 @@ export function normalizeModelString(model: string | undefined | null): string { model.startsWith(PROVIDER_PREFIXES.codex) || model.startsWith(PROVIDER_PREFIXES.opencode) || model.startsWith(PROVIDER_PREFIXES.gemini) || + model.startsWith(PROVIDER_PREFIXES.copilot) || model.startsWith('claude-') ) { return model; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index e67af911..e04110c5 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -11,6 +11,10 @@ import type { CursorModelId } from './cursor-models.js'; import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js'; import type { OpencodeModelId } from './opencode-models.js'; import { getAllOpencodeModelIds, DEFAULT_OPENCODE_MODEL } from './opencode-models.js'; +import type { GeminiModelId } from './gemini-models.js'; +import { getAllGeminiModelIds, DEFAULT_GEMINI_MODEL } from './gemini-models.js'; +import type { CopilotModelId } from './copilot-models.js'; +import { getAllCopilotModelIds, DEFAULT_COPILOT_MODEL } from './copilot-models.js'; import type { PromptCustomization } from './prompts.js'; import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; import type { ReasoningEffort } from './provider.js'; @@ -99,7 +103,7 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number } /** ModelProvider - AI model provider for credentials and API key management */ -export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini'; +export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot'; // ============================================================================ // Claude-Compatible Providers - Configuration for Claude-compatible API endpoints @@ -895,6 +899,18 @@ export interface GlobalSettings { /** Which dynamic OpenCode models are enabled (empty = all discovered) */ enabledDynamicModelIds?: string[]; + // Gemini CLI Settings (global) + /** Which Gemini models are available in feature modal (empty = all) */ + enabledGeminiModels?: GeminiModelId[]; + /** Default Gemini model selection when switching to Gemini CLI */ + geminiDefaultModel?: GeminiModelId; + + // Copilot CLI Settings (global) + /** Which Copilot models are available in feature modal (empty = all) */ + enabledCopilotModels?: CopilotModelId[]; + /** Default Copilot model selection when switching to Copilot CLI */ + copilotDefaultModel?: CopilotModelId; + // Provider Visibility Settings /** Providers that are disabled and should not appear in model dropdowns */ disabledProviders?: ModelProvider[]; @@ -1316,6 +1332,10 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { enabledOpencodeModels: getAllOpencodeModelIds(), // Returns prefixed IDs opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Already prefixed enabledDynamicModelIds: [], + enabledGeminiModels: getAllGeminiModelIds(), // Returns prefixed IDs + geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Already prefixed + enabledCopilotModels: getAllCopilotModelIds(), // Returns prefixed IDs + copilotDefaultModel: DEFAULT_COPILOT_MODEL, // Already prefixed disabledProviders: [], keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, projects: [], diff --git a/package-lock.json b/package-lock.json index 652e215d..8498749d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@automaker/prompts": "1.0.0", "@automaker/types": "1.0.0", "@automaker/utils": "1.0.0", + "@github/copilot-sdk": "^0.1.16", "@modelcontextprotocol/sdk": "1.25.2", "@openai/codex-sdk": "^0.77.0", "cookie-parser": "1.4.7", @@ -3032,6 +3033,133 @@ "dev": true, "license": "MIT" }, + "node_modules/@github/copilot": { + "version": "0.0.389", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.389.tgz", + "integrity": "sha512-XCHMCd8fu7g9WAp+ZepXBF1ud8vdfxDG4ajstGJqHfbdz0RxQktB35R5s/vKizpYXSZogFqwjxl41qX8DypY6g==", + "license": "MIT", + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "0.0.389", + "@github/copilot-darwin-x64": "0.0.389", + "@github/copilot-linux-arm64": "0.0.389", + "@github/copilot-linux-x64": "0.0.389", + "@github/copilot-win32-arm64": "0.0.389", + "@github/copilot-win32-x64": "0.0.389" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "0.0.389", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.389.tgz", + "integrity": "sha512-4Crm/C9//ZPsK+NP5E5BEjltAGuij9XkvRILvZ/mqlaiDXRncFvUtdOoV+/Of+i4Zva/1sWnc7CrS7PHGJDyFg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "0.0.389", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.389.tgz", + "integrity": "sha512-w0LB+lw29UmRS9oW8ENyZhrf3S5LQ3Pz796dQY8LZybp7WxEGtQhvXN48mye9gGzOHNoHxQ2+10+OzsjC/mLUQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "0.0.389", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.389.tgz", + "integrity": "sha512-8QNvfs4r6nrbQrT4llu0CbJHcCJosyj+ZgLSpA+lqIiO/TiTQ48kV41uNjzTz1RmR6/qBKcz81FB7HcHXpT3xw==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "0.0.389", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.389.tgz", + "integrity": "sha512-ls42wSzspC7sLiweoqu2zT75mqMsLWs+IZBfCqcuH1BV+C/j/XSEHsSrJxAI3TPtIsOTolPbTAa8jye1nGDxeg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.16.tgz", + "integrity": "sha512-yEZrrUl9w6rvKmjJpzpqovL39GzFrHxnIXOSK/bQfFwk7Ak/drmBk2gOwJqDVJcbhUm2dsoeLIfok7vtyjAxTw==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^0.0.389", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "0.0.389", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.389.tgz", + "integrity": "sha512-loniaCnrty9okQMl3EhxeeyDhnrJ/lJK0Q0r7wkLf1d/TM2swp3tsGZyIRlhDKx5lgcnCPm1m0BqauMo8Vs34g==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "0.0.389", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.389.tgz", + "integrity": "sha512-L1ZzwV/vsxnrz0WO4qLDUlXXFQQ9fOFuBGKWy6TXS7aniaxI/7mdRQR1YjIEqy+AzRw9BaXR2UUUUDk0gb1+kw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, "node_modules/@hono/node-server": { "version": "1.19.7", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", @@ -16410,6 +16538,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -16646,9 +16783,9 @@ } }, "node_modules/zod": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", - "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks"