mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
committed by
GitHub
parent
51a75ae589
commit
0b92349890
@@ -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",
|
||||||
|
|||||||
942
apps/server/src/providers/copilot-provider.ts
Normal file
942
apps/server/src/providers/copilot-provider.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
|||||||
112
apps/server/src/providers/tool-normalization.ts
Normal file
112
apps/server/src/providers/tool-normalization.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
30
apps/server/src/routes/setup/routes/auth-copilot.ts
Normal file
30
apps/server/src/routes/setup/routes/auth-copilot.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
139
apps/server/src/routes/setup/routes/copilot-models.ts
Normal file
139
apps/server/src/routes/setup/routes/copilot-models.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
78
apps/server/src/routes/setup/routes/copilot-status.ts
Normal file
78
apps/server/src/routes/setup/routes/copilot-status.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
30
apps/server/src/routes/setup/routes/deauth-copilot.ts
Normal file
30
apps/server/src/routes/setup/routes/deauth-copilot.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
80
apps/server/src/services/copilot-connection-service.ts
Normal file
80
apps/server/src/services/copilot-connection-service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
517
apps/server/tests/unit/providers/copilot-provider.test.ts
Normal file
517
apps/server/tests/unit/providers/copilot-provider.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export {
|
|||||||
useCodexCliStatus,
|
useCodexCliStatus,
|
||||||
useOpencodeCliStatus,
|
useOpencodeCliStatus,
|
||||||
useGeminiCliStatus,
|
useGeminiCliStatus,
|
||||||
|
useCopilotCliStatus,
|
||||||
useGitHubCliStatus,
|
useGitHubCliStatus,
|
||||||
useApiKeysStatus,
|
useApiKeysStatus,
|
||||||
usePlatformInfo,
|
usePlatformInfo,
|
||||||
|
|||||||
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
194
libs/types/src/copilot-models.ts
Normal file
194
libs/types/src/copilot-models.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
143
package-lock.json
generated
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user