feat: Add GitHub Copilot SDK provider integration (#661)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
This commit is contained in:
Stefan de Vogelaere
2026-01-23 14:48:33 +01:00
committed by GitHub
parent 51a75ae589
commit 0b92349890
43 changed files with 3588 additions and 145 deletions

View File

@@ -32,6 +32,7 @@
"@automaker/prompts": "1.0.0", "@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0", "@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0", "@automaker/utils": "1.0.0",
"@github/copilot-sdk": "^0.1.16",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.77.0", "@openai/codex-sdk": "^0.77.0",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",

View File

@@ -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<string, unknown>;
};
}
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<string, string> = {
// 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<string, unknown>
): Record<string, unknown> {
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<ProviderMessage> {
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<void> => {
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<string | null> {
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<CopilotAuthStatus> {
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<CopilotRuntimeModel[]> {
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<InstallationStatus> {
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<ModelDefinition[]> {
logger.debug('Refreshing Copilot models from CLI');
await this.fetchRuntimeModels();
return this.getAvailableModels();
}
}

View File

@@ -26,6 +26,7 @@ import { validateBareModelId } from '@automaker/types';
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types'; import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils'; import { createLogger, isAbortError } from '@automaker/utils';
import { spawnJSONLProcess } from '@automaker/platform'; import { spawnJSONLProcess } from '@automaker/platform';
import { normalizeTodos } from './tool-normalization.js';
// Create logger for this module // Create logger for this module
const logger = createLogger('GeminiProvider'); const logger = createLogger('GeminiProvider');
@@ -150,6 +151,8 @@ function normalizeGeminiToolName(geminiToolName: string): string {
/** /**
* Normalize Gemini tool input parameters to standard format * Normalize Gemini tool input parameters to standard format
* *
* Uses shared normalizeTodos utility for consistent todo normalization.
*
* Gemini `write_todos` format: * Gemini `write_todos` format:
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]} * {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
* *
@@ -160,17 +163,9 @@ function normalizeGeminiToolInput(
toolName: string, toolName: string,
input: Record<string, unknown> input: Record<string, unknown>
): Record<string, unknown> { ): Record<string, unknown> {
// Normalize write_todos: map 'description' to 'content', handle 'cancelled' status // Normalize write_todos using shared utility
if (toolName === 'write_todos' && Array.isArray(input.todos)) { if (toolName === 'write_todos' && Array.isArray(input.todos)) {
return { return { todos: normalizeTodos(input.todos) };
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 input; return input;
} }

View File

@@ -38,6 +38,12 @@ export { CursorConfigManager } from './cursor-config-manager.js';
// OpenCode provider // OpenCode provider
export { OpencodeProvider } from './opencode-provider.js'; 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 // Provider factory
export { ProviderFactory } from './provider-factory.js'; export { ProviderFactory } from './provider-factory.js';

View File

@@ -12,6 +12,7 @@ import {
isCodexModel, isCodexModel,
isOpencodeModel, isOpencodeModel,
isGeminiModel, isGeminiModel,
isCopilotModel,
type ModelProvider, type ModelProvider,
} from '@automaker/types'; } from '@automaker/types';
import * as fs from 'fs'; import * as fs from 'fs';
@@ -23,6 +24,7 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
cursor: '.cursor-disconnected', cursor: '.cursor-disconnected',
opencode: '.opencode-disconnected', opencode: '.opencode-disconnected',
gemini: '.gemini-disconnected', gemini: '.gemini-disconnected',
copilot: '.copilot-disconnected',
}; };
/** /**
@@ -275,6 +277,7 @@ import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js'; import { CodexProvider } from './codex-provider.js';
import { OpencodeProvider } from './opencode-provider.js'; import { OpencodeProvider } from './opencode-provider.js';
import { GeminiProvider } from './gemini-provider.js'; import { GeminiProvider } from './gemini-provider.js';
import { CopilotProvider } from './copilot-provider.js';
// Register Claude provider // Register Claude provider
registerProvider('claude', { registerProvider('claude', {
@@ -317,3 +320,11 @@ registerProvider('gemini', {
canHandleModel: (model: string) => isGeminiModel(model), canHandleModel: (model: string) => isGeminiModel(model),
priority: 4, // Between opencode (3) and codex (5) 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
});

View File

@@ -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<TodoStatus>(['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<string, unknown>): Record<string, unknown> {
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<string, unknown>): Record<string, unknown> {
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<string, unknown>): Record<string, unknown> {
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;
}

View File

@@ -52,3 +52,8 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
// Re-export shared utilities // Re-export shared utilities
export { getErrorMessageShared as getErrorMessage }; export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger); export const logError = createLogError(logger);
/**
* Marker file used to indicate a provider has been explicitly disconnected by user
*/
export const COPILOT_DISCONNECTED_MARKER_FILE = '.copilot-disconnected';

View File

@@ -27,6 +27,14 @@ import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import { createGeminiStatusHandler } from './routes/gemini-status.js'; import { createGeminiStatusHandler } from './routes/gemini-status.js';
import { createAuthGeminiHandler } from './routes/auth-gemini.js'; import { createAuthGeminiHandler } from './routes/auth-gemini.js';
import { createDeauthGeminiHandler } from './routes/deauth-gemini.js'; import { createDeauthGeminiHandler } from './routes/deauth-gemini.js';
import { createCopilotStatusHandler } from './routes/copilot-status.js';
import { createAuthCopilotHandler } from './routes/auth-copilot.js';
import { createDeauthCopilotHandler } from './routes/deauth-copilot.js';
import {
createGetCopilotModelsHandler,
createRefreshCopilotModelsHandler,
createClearCopilotCacheHandler,
} from './routes/copilot-models.js';
import { import {
createGetOpencodeModelsHandler, createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler, createRefreshOpencodeModelsHandler,
@@ -80,6 +88,16 @@ export function createSetupRoutes(): Router {
router.post('/auth-gemini', createAuthGeminiHandler()); router.post('/auth-gemini', createAuthGeminiHandler());
router.post('/deauth-gemini', createDeauthGeminiHandler()); router.post('/deauth-gemini', createDeauthGeminiHandler());
// Copilot CLI routes
router.get('/copilot-status', createCopilotStatusHandler());
router.post('/auth-copilot', createAuthCopilotHandler());
router.post('/deauth-copilot', createDeauthCopilotHandler());
// Copilot Dynamic Model Discovery routes
router.get('/copilot/models', createGetCopilotModelsHandler());
router.post('/copilot/models/refresh', createRefreshCopilotModelsHandler());
router.post('/copilot/cache/clear', createClearCopilotCacheHandler());
// OpenCode Dynamic Model Discovery routes // OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler()); router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler()); router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());

View File

@@ -0,0 +1,30 @@
/**
* POST /auth-copilot endpoint - Connect Copilot CLI to the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { connectCopilot } from '../../../services/copilot-connection-service.js';
/**
* Creates handler for POST /api/setup/auth-copilot
* Removes the disconnection marker to allow Copilot CLI to be used
*/
export function createAuthCopilotHandler() {
return async (_req: Request, res: Response): Promise<void> => {
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),
});
}
};
}

View File

@@ -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<void> => {
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<void> => {
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<void> => {
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),
});
}
};
}

View File

@@ -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<boolean> {
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<void> => {
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),
});
}
};
}

View File

@@ -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<void> => {
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),
});
}
};
}

View File

@@ -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<void> {
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<void> {
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<boolean> {
const markerPath = getMarkerPath(projectRoot);
try {
await fs.access(markerPath);
return false; // Marker exists = disconnected
} catch {
return true; // Marker doesn't exist = connected
}
}

View File

@@ -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<typeof import('child_process')>();
return {
...actual,
execSync: vi.fn(),
};
});
// Mock fs (synchronous) for CLI detection (existsSync)
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
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');
});
});
});

View File

@@ -5,6 +5,7 @@ import { CursorProvider } from '@/providers/cursor-provider.js';
import { CodexProvider } from '@/providers/codex-provider.js'; import { CodexProvider } from '@/providers/codex-provider.js';
import { OpencodeProvider } from '@/providers/opencode-provider.js'; import { OpencodeProvider } from '@/providers/opencode-provider.js';
import { GeminiProvider } from '@/providers/gemini-provider.js'; import { GeminiProvider } from '@/providers/gemini-provider.js';
import { CopilotProvider } from '@/providers/copilot-provider.js';
describe('provider-factory.ts', () => { describe('provider-factory.ts', () => {
let consoleSpy: any; let consoleSpy: any;
@@ -13,6 +14,7 @@ describe('provider-factory.ts', () => {
let detectCodexSpy: any; let detectCodexSpy: any;
let detectOpencodeSpy: any; let detectOpencodeSpy: any;
let detectGeminiSpy: any; let detectGeminiSpy: any;
let detectCopilotSpy: any;
beforeEach(() => { beforeEach(() => {
consoleSpy = { consoleSpy = {
@@ -35,6 +37,9 @@ describe('provider-factory.ts', () => {
detectGeminiSpy = vi detectGeminiSpy = vi
.spyOn(GeminiProvider.prototype, 'detectInstallation') .spyOn(GeminiProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true }); .mockResolvedValue({ installed: true });
detectCopilotSpy = vi
.spyOn(CopilotProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
}); });
afterEach(() => { afterEach(() => {
@@ -44,6 +49,7 @@ describe('provider-factory.ts', () => {
detectCodexSpy.mockRestore(); detectCodexSpy.mockRestore();
detectOpencodeSpy.mockRestore(); detectOpencodeSpy.mockRestore();
detectGeminiSpy.mockRestore(); detectGeminiSpy.mockRestore();
detectCopilotSpy.mockRestore();
}); });
describe('getProviderForModel', () => { describe('getProviderForModel', () => {
@@ -172,9 +178,15 @@ describe('provider-factory.ts', () => {
expect(hasClaudeProvider).toBe(true); expect(hasClaudeProvider).toBe(true);
}); });
it('should return exactly 5 providers', () => { it('should return exactly 6 providers', () => {
const providers = ProviderFactory.getAllProviders(); 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', () => { it('should include GeminiProvider', () => {
@@ -219,7 +231,8 @@ describe('provider-factory.ts', () => {
expect(keys).toContain('codex'); expect(keys).toContain('codex');
expect(keys).toContain('opencode'); expect(keys).toContain('opencode');
expect(keys).toContain('gemini'); expect(keys).toContain('gemini');
expect(keys).toHaveLength(5); expect(keys).toContain('copilot');
expect(keys).toHaveLength(6);
}); });
it('should include cursor status', async () => { it('should include cursor status', async () => {

View File

@@ -19,6 +19,7 @@ const PROVIDER_ICON_KEYS = {
minimax: 'minimax', minimax: 'minimax',
glm: 'glm', glm: 'glm',
bigpickle: 'bigpickle', bigpickle: 'bigpickle',
copilot: 'copilot',
} as const; } as const;
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS; type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
@@ -113,6 +114,12 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
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', 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', 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<SVGProps<SVGSVGElement>, 'viewBox'> { export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
@@ -198,6 +205,10 @@ export function GeminiIcon({ title, className, ...props }: GeminiIconProps) {
); );
} }
export function CopilotIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.copilot} {...props} />;
}
export function GrokIcon(props: Omit<ProviderIconProps, 'provider'>) { export function GrokIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.grok} {...props} />; return <ProviderIcon provider={PROVIDER_ICON_KEYS.grok} {...props} />;
} }
@@ -424,6 +435,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
codex: OpenAIIcon, codex: OpenAIIcon,
opencode: OpenCodeIcon, opencode: OpenCodeIcon,
gemini: GeminiIcon, gemini: GeminiIcon,
copilot: CopilotIcon,
}; };
/** /**
@@ -574,6 +586,10 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
if (modelStr.includes('grok')) { if (modelStr.includes('grok')) {
return 'grok'; return 'grok';
} }
// GitHub Copilot models
if (modelStr.includes('copilot')) {
return 'copilot';
}
// Cursor models - canonical format includes 'cursor-' prefix // Cursor models - canonical format includes 'cursor-' prefix
// Also support legacy IDs for backward compatibility // Also support legacy IDs for backward compatibility
if ( if (
@@ -591,6 +607,7 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
if (provider === 'codex') return 'openai'; if (provider === 'codex') return 'openai';
if (provider === 'cursor') return 'cursor'; if (provider === 'cursor') return 'cursor';
if (provider === 'opencode') return 'opencode'; if (provider === 'opencode') return 'opencode';
if (provider === 'copilot') return 'copilot';
return 'anthropic'; return 'anthropic';
} }
@@ -615,6 +632,7 @@ export function getProviderIconForModel(
minimax: MiniMaxIcon, minimax: MiniMaxIcon,
glm: GlmIcon, glm: GlmIcon,
bigpickle: BigPickleIcon, bigpickle: BigPickleIcon,
copilot: CopilotIcon,
}; };
return iconMap[iconKey] || AnthropicIcon; return iconMap[iconKey] || AnthropicIcon;

View File

@@ -5,6 +5,7 @@ import {
CODEX_MODEL_MAP, CODEX_MODEL_MAP,
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS, OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
GEMINI_MODEL_MAP, GEMINI_MODEL_MAP,
COPILOT_MODEL_MAP,
} from '@automaker/types'; } from '@automaker/types';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react'; import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import { import {
@@ -13,6 +14,7 @@ import {
OpenAIIcon, OpenAIIcon,
OpenCodeIcon, OpenCodeIcon,
GeminiIcon, GeminiIcon,
CopilotIcon,
} from '@/components/ui/provider-icon'; } from '@/components/ui/provider-icon';
export type ModelOption = { 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[] = [ export const ALL_MODELS: ModelOption[] = [
...CLAUDE_MODELS, ...CLAUDE_MODELS,
@@ -148,6 +165,7 @@ export const ALL_MODELS: ModelOption[] = [
...CODEX_MODELS, ...CODEX_MODELS,
...OPENCODE_MODELS, ...OPENCODE_MODELS,
...GEMINI_MODELS, ...GEMINI_MODELS,
...COPILOT_MODELS,
]; ];
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink']; export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
@@ -195,4 +213,5 @@ export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: str
Codex: OpenAIIcon, Codex: OpenAIIcon,
OpenCode: OpenCodeIcon, OpenCode: OpenCodeIcon,
Gemini: GeminiIcon, Gemini: GeminiIcon,
Copilot: CopilotIcon,
}; };

View File

@@ -24,6 +24,7 @@ import {
CodexSettingsTab, CodexSettingsTab,
OpencodeSettingsTab, OpencodeSettingsTab,
GeminiSettingsTab, GeminiSettingsTab,
CopilotSettingsTab,
} from './settings-view/providers'; } from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers'; import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts'; import { PromptCustomizationSection } from './settings-view/prompts';
@@ -126,6 +127,8 @@ export function SettingsView() {
return <OpencodeSettingsTab />; return <OpencodeSettingsTab />;
case 'gemini-provider': case 'gemini-provider':
return <GeminiSettingsTab />; return <GeminiSettingsTab />;
case 'copilot-provider':
return <CopilotSettingsTab />;
case 'providers': case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />; return <ClaudeSettingsTab />;

View File

@@ -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 (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-36" />
</div>
<SkeletonPulse className="w-9 h-9 rounded-lg" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-80" />
</div>
</div>
<div className="p-6 space-y-4">
{/* Installation status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-40" />
<SkeletonPulse className="h-3 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
{/* Auth status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-36" />
</div>
</div>
</div>
</div>
);
}
export function CopilotCliStatus({
status,
authStatus,
isChecking,
onRefresh,
}: CopilotCliStatusProps) {
if (!status) return <CopilotCliStatusSkeleton />;
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500/20 to-violet-600/10 flex items-center justify-center border border-violet-500/20">
<CopilotIcon className="w-5 h-5 text-violet-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
GitHub Copilot CLI
</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-copilot-cli"
title="Refresh Copilot CLI detection"
aria-label="Refresh Copilot CLI detection"
className={cn(
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
GitHub Copilot CLI provides access to GPT and Claude models via your Copilot subscription.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Copilot CLI Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
{authStatus.method !== 'none' && (
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
)}
{authStatus.login && (
<p>
User: <span className="font-mono">{authStatus.login}</span>
</p>
)}
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<div className="w-10 h-10 rounded-xl bg-red-500/15 flex items-center justify-center border border-red-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-red-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-red-400">Authentication Required</p>
{authStatus?.error && (
<p className="text-xs text-red-400/70 mt-1">{authStatus.error}</p>
)}
<p className="text-xs text-red-400/70 mt-2">
Run <code className="font-mono bg-red-500/10 px-1 rounded">gh auth login</code>{' '}
in your terminal to authenticate with GitHub.
</p>
</div>
</div>
)}
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Copilot CLI Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation ||
'Install GitHub Copilot CLI to use models via your Copilot subscription.'}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
<div className="space-y-2">
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
npm
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -18,6 +18,7 @@ const NAV_ID_TO_PROVIDER: Record<string, ModelProvider> = {
'codex-provider': 'codex', 'codex-provider': 'codex',
'opencode-provider': 'opencode', 'opencode-provider': 'opencode',
'gemini-provider': 'gemini', 'gemini-provider': 'gemini',
'copilot-provider': 'copilot',
}; };
interface SettingsNavigationProps { interface SettingsNavigationProps {

View File

@@ -23,6 +23,7 @@ import {
OpenAIIcon, OpenAIIcon,
OpenCodeIcon, OpenCodeIcon,
GeminiIcon, GeminiIcon,
CopilotIcon,
} from '@/components/ui/provider-icon'; } from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view'; 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: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
{ id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon }, { id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon },
{ id: 'gemini-provider', label: 'Gemini', icon: GeminiIcon }, { id: 'gemini-provider', label: 'Gemini', icon: GeminiIcon },
{ id: 'copilot-provider', label: 'Copilot', icon: CopilotIcon },
], ],
}, },
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug }, { id: 'mcp-servers', label: 'MCP Servers', icon: Plug },

View File

@@ -9,6 +9,7 @@ export type SettingsViewId =
| 'codex-provider' | 'codex-provider'
| 'opencode-provider' | 'opencode-provider'
| 'gemini-provider' | 'gemini-provider'
| 'copilot-provider'
| 'mcp-servers' | 'mcp-servers'
| 'prompts' | 'prompts'
| 'model-defaults' | 'model-defaults'

View File

@@ -8,6 +8,7 @@ import type {
CodexModelId, CodexModelId,
OpencodeModelId, OpencodeModelId,
GeminiModelId, GeminiModelId,
CopilotModelId,
GroupedModel, GroupedModel,
PhaseModelEntry, PhaseModelEntry,
ClaudeCompatibleProvider, ClaudeCompatibleProvider,
@@ -27,6 +28,7 @@ import {
CURSOR_MODELS, CURSOR_MODELS,
OPENCODE_MODELS, OPENCODE_MODELS,
GEMINI_MODELS, GEMINI_MODELS,
COPILOT_MODELS,
THINKING_LEVELS, THINKING_LEVELS,
THINKING_LEVEL_LABELS, THINKING_LEVEL_LABELS,
REASONING_EFFORT_LEVELS, REASONING_EFFORT_LEVELS,
@@ -42,6 +44,7 @@ import {
GlmIcon, GlmIcon,
MiniMaxIcon, MiniMaxIcon,
GeminiIcon, GeminiIcon,
CopilotIcon,
getProviderIconForModel, getProviderIconForModel,
} from '@/components/ui/provider-icon'; } from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -172,6 +175,7 @@ export function PhaseModelSelector({
const { const {
enabledCursorModels, enabledCursorModels,
enabledGeminiModels, enabledGeminiModels,
enabledCopilotModels,
favoriteModels, favoriteModels,
toggleFavoriteModel, toggleFavoriteModel,
codexModels, codexModels,
@@ -331,6 +335,11 @@ export function PhaseModelSelector({
return enabledGeminiModels.includes(model.id as GeminiModelId); 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 // Helper to find current selected model details
const currentModel = useMemo(() => { const currentModel = useMemo(() => {
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel); 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 // Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel); const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) }; if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) };
@@ -479,6 +497,7 @@ export function PhaseModelSelector({
selectedThinkingLevel, selectedThinkingLevel,
availableCursorModels, availableCursorModels,
availableGeminiModels, availableGeminiModels,
availableCopilotModels,
transformedCodexModels, transformedCodexModels,
dynamicOpencodeModels, dynamicOpencodeModels,
enabledProviders, enabledProviders,
@@ -545,19 +564,22 @@ export function PhaseModelSelector({
// Check if providers are disabled (needed for rendering conditions) // Check if providers are disabled (needed for rendering conditions)
const isCursorDisabled = disabledProviders.includes('cursor'); const isCursorDisabled = disabledProviders.includes('cursor');
const isGeminiDisabled = disabledProviders.includes('gemini'); const isGeminiDisabled = disabledProviders.includes('gemini');
const isCopilotDisabled = disabledProviders.includes('copilot');
// Group models (filtering out disabled providers) // 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 favs: typeof CLAUDE_MODELS = [];
const cModels: typeof CLAUDE_MODELS = []; const cModels: typeof CLAUDE_MODELS = [];
const curModels: typeof CURSOR_MODELS = []; const curModels: typeof CURSOR_MODELS = [];
const codModels: typeof transformedCodexModels = []; const codModels: typeof transformedCodexModels = [];
const gemModels: typeof GEMINI_MODELS = []; const gemModels: typeof GEMINI_MODELS = [];
const copModels: typeof COPILOT_MODELS = [];
const ocModels: ModelOption[] = []; const ocModels: ModelOption[] = [];
const isClaudeDisabled = disabledProviders.includes('claude'); const isClaudeDisabled = disabledProviders.includes('claude');
const isCodexDisabled = disabledProviders.includes('codex'); const isCodexDisabled = disabledProviders.includes('codex');
const isGeminiDisabledInner = disabledProviders.includes('gemini'); const isGeminiDisabledInner = disabledProviders.includes('gemini');
const isCopilotDisabledInner = disabledProviders.includes('copilot');
const isOpencodeDisabled = disabledProviders.includes('opencode'); const isOpencodeDisabled = disabledProviders.includes('opencode');
// Process Claude Models (skip if provider is disabled) // 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) // Process OpenCode Models (skip if provider is disabled)
if (!isOpencodeDisabled) { if (!isOpencodeDisabled) {
allOpencodeModels.forEach((model) => { allOpencodeModels.forEach((model) => {
@@ -621,12 +654,14 @@ export function PhaseModelSelector({
cursor: curModels, cursor: curModels,
codex: codModels, codex: codModels,
gemini: gemModels, gemini: gemModels,
copilot: copModels,
opencode: ocModels, opencode: ocModels,
}; };
}, [ }, [
favoriteModels, favoriteModels,
availableCursorModels, availableCursorModels,
availableGeminiModels, availableGeminiModels,
availableCopilotModels,
transformedCodexModels, transformedCodexModels,
allOpencodeModels, allOpencodeModels,
disabledProviders, 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 (
<CommandItem
key={model.id}
value={model.label}
onSelect={() => {
onChange({ model: model.id as CopilotModelId });
setOpen(false);
}}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<CopilotIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
</div>
</CommandItem>
);
};
// Render ClaudeCompatibleProvider model item with thinking level support // Render ClaudeCompatibleProvider model item with thinking level support
const renderProviderModelItem = ( const renderProviderModelItem = (
provider: ClaudeCompatibleProvider, provider: ClaudeCompatibleProvider,
@@ -1933,6 +2021,10 @@ export function PhaseModelSelector({
if (model.provider === 'gemini') { if (model.provider === 'gemini') {
return renderGeminiModelItem(model as (typeof GEMINI_MODELS)[0]); return renderGeminiModelItem(model as (typeof GEMINI_MODELS)[0]);
} }
// Copilot model
if (model.provider === 'copilot') {
return renderCopilotModelItem(model as (typeof COPILOT_MODELS)[0]);
}
// OpenCode model // OpenCode model
if (model.provider === 'opencode') { if (model.provider === 'opencode') {
return renderOpencodeModelItem(model); return renderOpencodeModelItem(model);
@@ -2017,6 +2109,12 @@ export function PhaseModelSelector({
</CommandGroup> </CommandGroup>
)} )}
{!isCopilotDisabled && copilot.length > 0 && (
<CommandGroup heading="Copilot Models">
{copilot.map((model) => renderCopilotModelItem(model))}
</CommandGroup>
)}
{opencodeSections.length > 0 && ( {opencodeSections.length > 0 && (
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}> <CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
{opencodeSections.map((section, sectionIndex) => ( {opencodeSections.map((section, sectionIndex) => (

View File

@@ -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<CopilotModelId> {
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 (
<BaseModelConfiguration<CopilotModelId>
providerName="Copilot"
icon={<CopilotIcon className="w-5 h-5 text-violet-500" />}
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;
}}
/>
);
}

View File

@@ -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 (
<div className="space-y-6">
<CopilotCliStatusSkeleton />
</div>
);
}
return (
<div className="space-y-6">
{/* Provider Visibility Toggle */}
<ProviderToggle provider="copilot" providerLabel="GitHub Copilot" />
<CopilotCliStatus
status={cliStatus}
authStatus={authStatus}
isChecking={isCheckingCopilotCli}
onRefresh={handleRefreshCopilotCli}
/>
{/* Model Configuration - Only show when CLI is installed */}
{isCliInstalled && (
<CopilotModelConfiguration
enabledCopilotModels={enabledCopilotModels}
copilotDefaultModel={copilotDefaultModel}
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
/>
)}
</div>
);
}
export default CopilotSettingsTab;

View File

@@ -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 type { GeminiModelId } from '@automaker/types';
import { GeminiIcon } from '@/components/ui/provider-icon'; import { GeminiIcon } from '@/components/ui/provider-icon';
import { GEMINI_MODEL_MAP } from '@automaker/types'; import { GEMINI_MODEL_MAP } from '@automaker/types';
import { BaseModelConfiguration, type BaseModelInfo } from './shared/base-model-configuration';
interface GeminiModelConfigurationProps { interface GeminiModelConfigurationProps {
enabledGeminiModels: GeminiModelId[]; enabledGeminiModels: GeminiModelId[];
@@ -21,25 +11,17 @@ interface GeminiModelConfigurationProps {
onModelToggle: (model: GeminiModelId, enabled: boolean) => void; onModelToggle: (model: GeminiModelId, enabled: boolean) => void;
} }
interface GeminiModelInfo { interface GeminiModelInfo extends BaseModelInfo<GeminiModelId> {
id: GeminiModelId;
label: string;
description: string;
supportsThinking: boolean; supportsThinking: boolean;
} }
// Build model info from the GEMINI_MODEL_MAP // Build model info from the GEMINI_MODEL_MAP
const GEMINI_MODEL_INFO: Record<GeminiModelId, GeminiModelInfo> = Object.fromEntries( const GEMINI_MODELS: GeminiModelInfo[] = Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({
Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => [ id: id as GeminiModelId,
id as GeminiModelId, label: config.label,
{ description: config.description,
id: id as GeminiModelId, supportsThinking: config.supportsThinking,
label: config.label, }));
description: config.description,
supportsThinking: config.supportsThinking,
},
])
) as Record<GeminiModelId, GeminiModelInfo>;
export function GeminiModelConfiguration({ export function GeminiModelConfiguration({
enabledGeminiModels, enabledGeminiModels,
@@ -48,99 +30,22 @@ export function GeminiModelConfiguration({
onDefaultModelChange, onDefaultModelChange,
onModelToggle, onModelToggle,
}: GeminiModelConfigurationProps) { }: GeminiModelConfigurationProps) {
const availableModels = Object.values(GEMINI_MODEL_INFO);
return ( return (
<div <BaseModelConfiguration<GeminiModelId>
className={cn( providerName="Gemini"
'rounded-2xl overflow-hidden', icon={<GeminiIcon className="w-5 h-5 text-blue-500" />}
'border border-border/50', iconGradient="from-blue-500/20 to-blue-600/10"
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl', iconBorder="border-blue-500/20"
'shadow-sm shadow-black/5' models={GEMINI_MODELS}
)} enabledModels={enabledGeminiModels}
> defaultModel={geminiDefaultModel}
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent"> isSaving={isSaving}
<div className="flex items-center gap-3 mb-2"> onDefaultModelChange={onDefaultModelChange}
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20"> onModelToggle={onModelToggle}
<GeminiIcon className="w-5 h-5 text-blue-500" /> getFeatureBadge={(model) => {
</div> const geminiModel = model as GeminiModelInfo;
<h2 className="text-lg font-semibold text-foreground tracking-tight"> return geminiModel.supportsThinking ? { show: true, label: 'Thinking' } : null;
Model Configuration }}
</h2> />
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure which Gemini models are available in the feature modal
</p>
</div>
<div className="p-6 space-y-6">
<div className="space-y-2">
<Label>Default Model</Label>
<Select
value={geminiDefaultModel}
onValueChange={(v) => onDefaultModelChange(v as GeminiModelId)}
disabled={isSaving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span>{model.label}</span>
{model.supportsThinking && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>Available Models</Label>
<div className="grid gap-3">
{availableModels.map((model) => {
const isEnabled = enabledGeminiModels.includes(model.id);
const isDefault = model.id === geminiDefaultModel;
return (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
disabled={isSaving || isDefault}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.label}</span>
{model.supportsThinking && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
{isDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{model.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
); );
} }

View File

@@ -4,3 +4,4 @@ export { CursorSettingsTab } from './cursor-settings-tab';
export { CodexSettingsTab } from './codex-settings-tab'; export { CodexSettingsTab } from './codex-settings-tab';
export { OpencodeSettingsTab } from './opencode-settings-tab'; export { OpencodeSettingsTab } from './opencode-settings-tab';
export { GeminiSettingsTab } from './gemini-settings-tab'; export { GeminiSettingsTab } from './gemini-settings-tab';
export { CopilotSettingsTab } from './copilot-settings-tab';

View File

@@ -6,21 +6,23 @@ import {
OpenAIIcon, OpenAIIcon,
GeminiIcon, GeminiIcon,
OpenCodeIcon, OpenCodeIcon,
CopilotIcon,
} from '@/components/ui/provider-icon'; } from '@/components/ui/provider-icon';
import { CursorSettingsTab } from './cursor-settings-tab'; import { CursorSettingsTab } from './cursor-settings-tab';
import { ClaudeSettingsTab } from './claude-settings-tab'; import { ClaudeSettingsTab } from './claude-settings-tab';
import { CodexSettingsTab } from './codex-settings-tab'; import { CodexSettingsTab } from './codex-settings-tab';
import { OpencodeSettingsTab } from './opencode-settings-tab'; import { OpencodeSettingsTab } from './opencode-settings-tab';
import { GeminiSettingsTab } from './gemini-settings-tab'; import { GeminiSettingsTab } from './gemini-settings-tab';
import { CopilotSettingsTab } from './copilot-settings-tab';
interface ProviderTabsProps { interface ProviderTabsProps {
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini'; defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot';
} }
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
return ( return (
<Tabs defaultValue={defaultTab} className="w-full"> <Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-5 mb-6"> <TabsList className="grid w-full grid-cols-6 mb-6">
<TabsTrigger value="claude" className="flex items-center gap-2"> <TabsTrigger value="claude" className="flex items-center gap-2">
<AnthropicIcon className="w-4 h-4" /> <AnthropicIcon className="w-4 h-4" />
Claude Claude
@@ -41,6 +43,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
<GeminiIcon className="w-4 h-4" /> <GeminiIcon className="w-4 h-4" />
Gemini Gemini
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="copilot" className="flex items-center gap-2">
<CopilotIcon className="w-4 h-4" />
Copilot
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="claude"> <TabsContent value="claude">
@@ -62,6 +68,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
<TabsContent value="gemini"> <TabsContent value="gemini">
<GeminiSettingsTab /> <GeminiSettingsTab />
</TabsContent> </TabsContent>
<TabsContent value="copilot">
<CopilotSettingsTab />
</TabsContent>
</Tabs> </Tabs>
); );
} }

View File

@@ -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<T extends string> {
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<T extends string> {
/** 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<T>[];
/** 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<T>) => 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<T extends string>({
providerName,
icon,
iconGradient,
iconBorder,
models,
enabledModels,
defaultModel,
isSaving,
onDefaultModelChange,
onModelToggle,
getFeatureBadge,
}: BaseModelConfigurationProps<T>) {
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div
className={cn(
'w-9 h-9 rounded-xl flex items-center justify-center border',
`bg-gradient-to-br ${iconGradient}`,
iconBorder
)}
>
{icon}
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Model Configuration
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure which {providerName} models are available in the feature modal
</p>
</div>
<div className="p-6 space-y-6">
<div className="space-y-2">
<Label>Default Model</Label>
<Select
value={defaultModel}
onValueChange={(v) => onDefaultModelChange(v as T)}
disabled={isSaving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((model) => {
const badge = getFeatureBadge?.(model);
return (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span>{model.label}</span>
{badge?.show && (
<Badge variant="outline" className="text-xs">
{badge.label}
</Badge>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>Available Models</Label>
<div className="grid gap-3">
{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 (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
disabled={isSaving || isDefault}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.label}</span>
{badge?.show && (
<Badge variant="outline" className="text-xs">
{badge.label}
</Badge>
)}
{isDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{model.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -37,6 +37,7 @@ import {
OpenAIIcon, OpenAIIcon,
OpenCodeIcon, OpenCodeIcon,
GeminiIcon, GeminiIcon,
CopilotIcon,
} from '@/components/ui/provider-icon'; } from '@/components/ui/provider-icon';
import { TerminalOutput } from '../components'; import { TerminalOutput } from '../components';
import { useCliInstallation, useTokenSave } from '../hooks'; import { useCliInstallation, useTokenSave } from '../hooks';
@@ -46,7 +47,7 @@ interface ProvidersSetupStepProps {
onBack: () => void; onBack: () => void;
} }
type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini'; type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot';
// ============================================================================ // ============================================================================
// Claude Content // 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<NodeJS.Timeout | null>(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 (
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<CopilotIcon className="w-5 h-5" />
GitHub Copilot CLI Status
</CardTitle>
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<CardDescription>
{copilotCliStatus?.installed
? copilotCliStatus.auth?.authenticated
? `Authenticated${copilotCliStatus.version ? ` (v${copilotCliStatus.version})` : ''}`
: 'Installed but not authenticated'
: 'Not installed on your system'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isReady && (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">SDK Installed</p>
<p className="text-sm text-muted-foreground">
{copilotCliStatus?.version && `Version: ${copilotCliStatus.version}`}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">Authenticated</p>
{copilotCliStatus?.auth?.login && (
<p className="text-sm text-muted-foreground">
Logged in as {copilotCliStatus.auth.login}
</p>
)}
</div>
</div>
</div>
)}
{!copilotCliStatus?.installed && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">Copilot CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the GitHub Copilot CLI to use Copilot models.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<p className="font-medium text-foreground text-sm">Install Copilot CLI:</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
{copilotCliStatus?.installCommand || 'npm install -g @github/copilot'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(
copilotCliStatus?.installCommand || 'npm install -g @github/copilot'
)
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
{copilotCliStatus?.installed && !copilotCliStatus?.auth?.authenticated && !isChecking && (
<div className="space-y-4">
{/* Show SDK installed toast */}
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">SDK Installed</p>
<p className="text-sm text-muted-foreground">
{copilotCliStatus?.version && `Version: ${copilotCliStatus.version}`}
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">GitHub not authenticated</p>
<p className="text-sm text-muted-foreground mt-1">
Run the GitHub CLI login command to authenticate.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
{copilotCliStatus?.loginCommand || 'gh auth login'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand(copilotCliStatus?.loginCommand || 'gh auth login')}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<Button
onClick={handleLogin}
disabled={isLoggingIn}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
Waiting for login...
</>
) : (
'Copy Command & Wait for Login'
)}
</Button>
</div>
</div>
)}
{isChecking && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Spinner size="md" />
<p className="font-medium text-foreground">Checking Copilot CLI status...</p>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================ // ============================================================================
// Main Component // Main Component
// ============================================================================ // ============================================================================
@@ -1544,12 +1784,14 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
codexAuthStatus, codexAuthStatus,
opencodeCliStatus, opencodeCliStatus,
geminiCliStatus, geminiCliStatus,
copilotCliStatus,
setClaudeCliStatus, setClaudeCliStatus,
setCursorCliStatus, setCursorCliStatus,
setCodexCliStatus, setCodexCliStatus,
setCodexAuthStatus, setCodexAuthStatus,
setOpencodeCliStatus, setOpencodeCliStatus,
setGeminiCliStatus, setGeminiCliStatus,
setCopilotCliStatus,
} = useSetupStore(); } = useSetupStore();
// Check all providers on mount // 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 // Run all checks in parallel
await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode(), checkGemini()]); await Promise.all([
checkClaude(),
checkCursor(),
checkCodex(),
checkOpencode(),
checkGemini(),
checkCopilot(),
]);
setIsInitialChecking(false); setIsInitialChecking(false);
}, [ }, [
setClaudeCliStatus, setClaudeCliStatus,
@@ -1669,6 +1938,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
setCodexAuthStatus, setCodexAuthStatus,
setOpencodeCliStatus, setOpencodeCliStatus,
setGeminiCliStatus, setGeminiCliStatus,
setCopilotCliStatus,
]); ]);
useEffect(() => { useEffect(() => {
@@ -1698,12 +1968,16 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
const isGeminiInstalled = geminiCliStatus?.installed === true; const isGeminiInstalled = geminiCliStatus?.installed === true;
const isGeminiAuthenticated = geminiCliStatus?.auth?.authenticated === true; const isGeminiAuthenticated = geminiCliStatus?.auth?.authenticated === true;
const isCopilotInstalled = copilotCliStatus?.installed === true;
const isCopilotAuthenticated = copilotCliStatus?.auth?.authenticated === true;
const hasAtLeastOneProvider = const hasAtLeastOneProvider =
isClaudeAuthenticated || isClaudeAuthenticated ||
isCursorAuthenticated || isCursorAuthenticated ||
isCodexAuthenticated || isCodexAuthenticated ||
isOpencodeAuthenticated || isOpencodeAuthenticated ||
isGeminiAuthenticated; isGeminiAuthenticated ||
isCopilotAuthenticated;
type ProviderStatus = 'not_installed' | 'installed_not_auth' | 'authenticated' | 'verifying'; type ProviderStatus = 'not_installed' | 'installed_not_auth' | 'authenticated' | 'verifying';
@@ -1754,6 +2028,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
status: getProviderStatus(isGeminiInstalled, isGeminiAuthenticated), status: getProviderStatus(isGeminiInstalled, isGeminiAuthenticated),
color: 'text-blue-500', color: 'text-blue-500',
}, },
{
id: 'copilot' as const,
label: 'Copilot',
icon: CopilotIcon,
status: getProviderStatus(isCopilotInstalled, isCopilotAuthenticated),
color: 'text-violet-500',
},
]; ];
const renderStatusIcon = (status: ProviderStatus) => { const renderStatusIcon = (status: ProviderStatus) => {
@@ -1790,7 +2071,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
)} )}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ProviderTab)}> <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ProviderTab)}>
<TabsList className="grid w-full grid-cols-5 h-auto p-1"> <TabsList className="grid w-full grid-cols-6 h-auto p-1">
{providers.map((provider) => { {providers.map((provider) => {
const Icon = provider.icon; const Icon = provider.icon;
return ( return (
@@ -1839,6 +2120,9 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
<TabsContent value="gemini" className="mt-0"> <TabsContent value="gemini" className="mt-0">
<GeminiContent /> <GeminiContent />
</TabsContent> </TabsContent>
<TabsContent value="copilot" className="mt-0">
<CopilotContent />
</TabsContent>
</div> </div>
</Tabs> </Tabs>

View File

@@ -64,6 +64,7 @@ export {
useCodexCliStatus, useCodexCliStatus,
useOpencodeCliStatus, useOpencodeCliStatus,
useGeminiCliStatus, useGeminiCliStatus,
useCopilotCliStatus,
useGitHubCliStatus, useGitHubCliStatus,
useApiKeysStatus, useApiKeysStatus,
usePlatformInfo, usePlatformInfo,

View File

@@ -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 * Fetch GitHub CLI status
* *

View File

@@ -22,11 +22,13 @@ import { waitForMigrationComplete, resetMigrationState } from './use-settings-mi
import { import {
DEFAULT_OPENCODE_MODEL, DEFAULT_OPENCODE_MODEL,
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
DEFAULT_COPILOT_MODEL,
DEFAULT_MAX_CONCURRENCY, DEFAULT_MAX_CONCURRENCY,
getAllOpencodeModelIds, getAllOpencodeModelIds,
getAllCursorModelIds, getAllCursorModelIds,
getAllCodexModelIds, getAllCodexModelIds,
getAllGeminiModelIds, getAllGeminiModelIds,
getAllCopilotModelIds,
migrateCursorModelIds, migrateCursorModelIds,
migrateOpencodeModelIds, migrateOpencodeModelIds,
migratePhaseModelEntry, migratePhaseModelEntry,
@@ -35,6 +37,7 @@ import {
type OpencodeModelId, type OpencodeModelId,
type CodexModelId, type CodexModelId,
type GeminiModelId, type GeminiModelId,
type CopilotModelId,
} from '@automaker/types'; } from '@automaker/types';
const logger = createLogger('SettingsSync'); const logger = createLogger('SettingsSync');
@@ -75,6 +78,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
'codexDefaultModel', 'codexDefaultModel',
'enabledGeminiModels', 'enabledGeminiModels',
'geminiDefaultModel', 'geminiDefaultModel',
'enabledCopilotModels',
'copilotDefaultModel',
'enabledDynamicModelIds', 'enabledDynamicModelIds',
'disabledProviders', 'disabledProviders',
'autoLoadClaudeMd', 'autoLoadClaudeMd',
@@ -607,6 +612,21 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
sanitizedEnabledGeminiModels.push(sanitizedGeminiDefaultModel); 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 = const persistedDynamicModelIds =
serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds; serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds;
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter( const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
@@ -703,6 +723,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
codexDefaultModel: sanitizedCodexDefaultModel, codexDefaultModel: sanitizedCodexDefaultModel,
enabledGeminiModels: sanitizedEnabledGeminiModels, enabledGeminiModels: sanitizedEnabledGeminiModels,
geminiDefaultModel: sanitizedGeminiDefaultModel, geminiDefaultModel: sanitizedGeminiDefaultModel,
enabledCopilotModels: sanitizedEnabledCopilotModels,
copilotDefaultModel: sanitizedCopilotDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds, enabledDynamicModelIds: sanitizedDynamicModelIds,
disabledProviders: serverSettings.disabledProviders ?? [], disabledProviders: serverSettings.disabledProviders ?? [],
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false, autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,

View File

@@ -1697,6 +1697,27 @@ export class HttpApiClient implements ElectronAPI {
error?: string; error?: string;
}> => this.post('/api/setup/deauth-gemini'), }> => 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) => { onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent('agent:stream', callback); return this.subscribeToEvent('agent:stream', callback);
}, },

View File

@@ -178,6 +178,8 @@ export const queryKeys = {
opencode: () => ['cli', 'opencode'] as const, opencode: () => ['cli', 'opencode'] as const,
/** Gemini CLI status */ /** Gemini CLI status */
gemini: () => ['cli', 'gemini'] as const, gemini: () => ['cli', 'gemini'] as const,
/** Copilot SDK status */
copilot: () => ['cli', 'copilot'] as const,
/** GitHub CLI status */ /** GitHub CLI status */
github: () => ['cli', 'github'] as const, github: () => ['cli', 'github'] as const,
/** API keys status */ /** API keys status */

View File

@@ -22,6 +22,7 @@ import type {
CodexModelId, CodexModelId,
OpencodeModelId, OpencodeModelId,
GeminiModelId, GeminiModelId,
CopilotModelId,
PhaseModelConfig, PhaseModelConfig,
PhaseModelKey, PhaseModelKey,
PhaseModelEntry, PhaseModelEntry,
@@ -41,9 +42,11 @@ import {
getAllCodexModelIds, getAllCodexModelIds,
getAllOpencodeModelIds, getAllOpencodeModelIds,
getAllGeminiModelIds, getAllGeminiModelIds,
getAllCopilotModelIds,
DEFAULT_PHASE_MODELS, DEFAULT_PHASE_MODELS,
DEFAULT_OPENCODE_MODEL, DEFAULT_OPENCODE_MODEL,
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
DEFAULT_COPILOT_MODEL,
DEFAULT_MAX_CONCURRENCY, DEFAULT_MAX_CONCURRENCY,
DEFAULT_GLOBAL_SETTINGS, DEFAULT_GLOBAL_SETTINGS,
} from '@automaker/types'; } from '@automaker/types';
@@ -736,6 +739,10 @@ export interface AppState {
enabledGeminiModels: GeminiModelId[]; // Which Gemini models are available in feature modal enabledGeminiModels: GeminiModelId[]; // Which Gemini models are available in feature modal
geminiDefaultModel: GeminiModelId; // Default Gemini model selection 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 // Provider Visibility Settings
disabledProviders: ModelProvider[]; // Providers that are disabled and hidden from dropdowns disabledProviders: ModelProvider[]; // Providers that are disabled and hidden from dropdowns
@@ -1230,6 +1237,11 @@ export interface AppActions {
setGeminiDefaultModel: (model: GeminiModelId) => void; setGeminiDefaultModel: (model: GeminiModelId) => void;
toggleGeminiModel: (model: GeminiModelId, enabled: boolean) => 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 // Provider Visibility Settings actions
setDisabledProviders: (providers: ModelProvider[]) => void; setDisabledProviders: (providers: ModelProvider[]) => void;
toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void; toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void;
@@ -1517,6 +1529,8 @@ const initialState: AppState = {
opencodeModelsLastFailedAt: null, opencodeModelsLastFailedAt: null,
enabledGeminiModels: getAllGeminiModelIds(), // All Gemini models enabled by default enabledGeminiModels: getAllGeminiModelIds(), // All Gemini models enabled by default
geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Default to Gemini 2.5 Flash 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 disabledProviders: [], // No providers disabled by default
autoLoadClaudeMd: false, // Default to disabled (user must opt-in) autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
@@ -2759,6 +2773,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
: state.enabledGeminiModels.filter((m) => m !== model), : 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 // Provider Visibility Settings actions
setDisabledProviders: (providers) => set({ disabledProviders: providers }), setDisabledProviders: (providers) => set({ disabledProviders: providers }),
toggleProviderDisabled: (provider, disabled) => toggleProviderDisabled: (provider, disabled) =>

View File

@@ -79,6 +79,22 @@ export interface GeminiCliStatus {
error?: string; 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 // Codex Auth Method
export type CodexAuthMethod = export type CodexAuthMethod =
| 'api_key_env' // OPENAI_API_KEY environment variable | 'api_key_env' // OPENAI_API_KEY environment variable
@@ -137,6 +153,7 @@ export type SetupStep =
| 'codex' | 'codex'
| 'opencode' | 'opencode'
| 'gemini' | 'gemini'
| 'copilot'
| 'github' | 'github'
| 'complete'; | 'complete';
@@ -169,6 +186,9 @@ export interface SetupState {
// Gemini CLI state // Gemini CLI state
geminiCliStatus: GeminiCliStatus | null; geminiCliStatus: GeminiCliStatus | null;
// Copilot SDK state
copilotCliStatus: CopilotCliStatus | null;
// Setup preferences // Setup preferences
skipClaudeSetup: boolean; skipClaudeSetup: boolean;
} }
@@ -206,6 +226,9 @@ export interface SetupActions {
// Gemini CLI // Gemini CLI
setGeminiCliStatus: (status: GeminiCliStatus | null) => void; setGeminiCliStatus: (status: GeminiCliStatus | null) => void;
// Copilot SDK
setCopilotCliStatus: (status: CopilotCliStatus | null) => void;
// Preferences // Preferences
setSkipClaudeSetup: (skip: boolean) => void; setSkipClaudeSetup: (skip: boolean) => void;
} }
@@ -241,6 +264,8 @@ const initialState: SetupState = {
geminiCliStatus: null, geminiCliStatus: null,
copilotCliStatus: null,
skipClaudeSetup: shouldSkipSetup, skipClaudeSetup: shouldSkipSetup,
}; };
@@ -316,6 +341,9 @@ export const useSetupStore = create<SetupState & SetupActions>()((set, get) => (
// Gemini CLI // Gemini CLI
setGeminiCliStatus: (status) => set({ geminiCliStatus: status }), setGeminiCliStatus: (status) => set({ geminiCliStatus: status }),
// Copilot SDK
setCopilotCliStatus: (status) => set({ copilotCliStatus: status }),
// Preferences // Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
})); }));

View File

@@ -4,12 +4,16 @@
* Provides centralized model resolution logic: * Provides centralized model resolution logic:
* - Maps Claude model aliases to full model strings * - Maps Claude model aliases to full model strings
* - Passes through Cursor models unchanged (handled by CursorProvider) * - 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 * - Provides default models per provider
* - Handles multiple model sources with priority * - Handles multiple model sources with priority
* *
* With canonical model IDs: * With canonical model IDs:
* - Cursor: cursor-auto, cursor-composer-1, cursor-gpt-5.2 * - Cursor: cursor-auto, cursor-composer-1, cursor-gpt-5.2
* - OpenCode: opencode-big-pickle, opencode-grok-code * - 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) * - Claude: claude-haiku, claude-sonnet, claude-opus (also supports legacy aliases)
*/ */
@@ -22,6 +26,8 @@ import {
PROVIDER_PREFIXES, PROVIDER_PREFIXES,
isCursorModel, isCursorModel,
isOpencodeModel, isOpencodeModel,
isCopilotModel,
isGeminiModel,
stripProviderPrefix, stripProviderPrefix,
migrateModelId, migrateModelId,
type PhaseModelEntry, type PhaseModelEntry,
@@ -83,6 +89,18 @@ export function resolveModelString(
return canonicalKey; 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) // Claude canonical ID (claude-haiku, claude-sonnet, claude-opus)
// Map to full model string // Map to full model string
if (canonicalKey in CLAUDE_CANONICAL_MAP) { if (canonicalKey in CLAUDE_CANONICAL_MAP) {

View File

@@ -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<string, CopilotModelConfig>;
/**
* 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;
};
}

View File

@@ -253,6 +253,9 @@ export * from './opencode-models.js';
// Gemini types // Gemini types
export * from './gemini-models.js'; export * from './gemini-models.js';
// Copilot types
export * from './copilot-models.js';
// Provider utilities // Provider utilities
export { export {
PROVIDER_PREFIXES, PROVIDER_PREFIXES,
@@ -261,6 +264,7 @@ export {
isCodexModel, isCodexModel,
isOpencodeModel, isOpencodeModel,
isGeminiModel, isGeminiModel,
isCopilotModel,
getModelProvider, getModelProvider,
stripProviderPrefix, stripProviderPrefix,
addProviderPrefix, addProviderPrefix,

View File

@@ -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 { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js';
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js'; import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
import { GEMINI_MODEL_MAP } from './gemini-models.js'; import { GEMINI_MODEL_MAP } from './gemini-models.js';
import { COPILOT_MODEL_MAP } from './copilot-models.js';
/** Provider prefix constants */ /** Provider prefix constants */
export const PROVIDER_PREFIXES = { export const PROVIDER_PREFIXES = {
@@ -18,6 +19,7 @@ export const PROVIDER_PREFIXES = {
codex: 'codex-', codex: 'codex-',
opencode: 'opencode-', opencode: 'opencode-',
gemini: 'gemini-', gemini: 'gemini-',
copilot: 'copilot-',
} as const; } as const;
/** /**
@@ -114,6 +116,28 @@ export function isGeminiModel(model: string | undefined | null): boolean {
return false; 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 * 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 * @returns The provider type, defaults to 'claude' for unknown models
*/ */
export function getModelProvider(model: string | undefined | null): ModelProvider { 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)) { if (isGeminiModel(model)) {
return 'gemini'; return 'gemini';
} }
@@ -248,6 +276,10 @@ export function addProviderPrefix(model: string, provider: ModelProvider): strin
if (!model.startsWith(PROVIDER_PREFIXES.gemini)) { if (!model.startsWith(PROVIDER_PREFIXES.gemini)) {
return `${PROVIDER_PREFIXES.gemini}${model}`; 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 // Claude models don't use prefixes
return model; return model;
@@ -284,6 +316,7 @@ export function normalizeModelString(model: string | undefined | null): string {
model.startsWith(PROVIDER_PREFIXES.codex) || model.startsWith(PROVIDER_PREFIXES.codex) ||
model.startsWith(PROVIDER_PREFIXES.opencode) || model.startsWith(PROVIDER_PREFIXES.opencode) ||
model.startsWith(PROVIDER_PREFIXES.gemini) || model.startsWith(PROVIDER_PREFIXES.gemini) ||
model.startsWith(PROVIDER_PREFIXES.copilot) ||
model.startsWith('claude-') model.startsWith('claude-')
) { ) {
return model; return model;

View File

@@ -11,6 +11,10 @@ import type { CursorModelId } from './cursor-models.js';
import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js'; import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js';
import type { OpencodeModelId } from './opencode-models.js'; import type { OpencodeModelId } from './opencode-models.js';
import { getAllOpencodeModelIds, DEFAULT_OPENCODE_MODEL } 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 { PromptCustomization } from './prompts.js';
import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js';
import type { ReasoningEffort } from './provider.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 */ /** 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 // 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) */ /** Which dynamic OpenCode models are enabled (empty = all discovered) */
enabledDynamicModelIds?: string[]; 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 // Provider Visibility Settings
/** Providers that are disabled and should not appear in model dropdowns */ /** Providers that are disabled and should not appear in model dropdowns */
disabledProviders?: ModelProvider[]; disabledProviders?: ModelProvider[];
@@ -1316,6 +1332,10 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
enabledOpencodeModels: getAllOpencodeModelIds(), // Returns prefixed IDs enabledOpencodeModels: getAllOpencodeModelIds(), // Returns prefixed IDs
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Already prefixed opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Already prefixed
enabledDynamicModelIds: [], enabledDynamicModelIds: [],
enabledGeminiModels: getAllGeminiModelIds(), // Returns prefixed IDs
geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Already prefixed
enabledCopilotModels: getAllCopilotModelIds(), // Returns prefixed IDs
copilotDefaultModel: DEFAULT_COPILOT_MODEL, // Already prefixed
disabledProviders: [], disabledProviders: [],
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
projects: [], projects: [],

143
package-lock.json generated
View File

@@ -43,6 +43,7 @@
"@automaker/prompts": "1.0.0", "@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0", "@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0", "@automaker/utils": "1.0.0",
"@github/copilot-sdk": "^0.1.16",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.25.2",
"@openai/codex-sdk": "^0.77.0", "@openai/codex-sdk": "^0.77.0",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
@@ -3032,6 +3033,133 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@hono/node-server": {
"version": "1.19.7", "version": "1.19.7",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
@@ -16410,6 +16538,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/w3c-keyname": {
"version": "2.2.8", "version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
@@ -16646,9 +16783,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.2.1", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"