mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +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
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 { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { spawnJSONLProcess } from '@automaker/platform';
|
||||
import { normalizeTodos } from './tool-normalization.js';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('GeminiProvider');
|
||||
@@ -150,6 +151,8 @@ function normalizeGeminiToolName(geminiToolName: string): string {
|
||||
/**
|
||||
* Normalize Gemini tool input parameters to standard format
|
||||
*
|
||||
* Uses shared normalizeTodos utility for consistent todo normalization.
|
||||
*
|
||||
* Gemini `write_todos` format:
|
||||
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
|
||||
*
|
||||
@@ -160,17 +163,9 @@ function normalizeGeminiToolInput(
|
||||
toolName: string,
|
||||
input: Record<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)) {
|
||||
return {
|
||||
todos: input.todos.map((todo: { description?: string; status?: string }) => ({
|
||||
content: todo.description || '',
|
||||
// Map 'cancelled' to 'completed' since Claude doesn't have cancelled status
|
||||
status: todo.status === 'cancelled' ? 'completed' : todo.status,
|
||||
// Use description as activeForm since Gemini doesn't have it
|
||||
activeForm: todo.description || '',
|
||||
})),
|
||||
};
|
||||
return { todos: normalizeTodos(input.todos) };
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ export { CursorConfigManager } from './cursor-config-manager.js';
|
||||
// OpenCode provider
|
||||
export { OpencodeProvider } from './opencode-provider.js';
|
||||
|
||||
// Gemini provider
|
||||
export { GeminiProvider, GeminiErrorCode } from './gemini-provider.js';
|
||||
|
||||
// Copilot provider (GitHub Copilot SDK)
|
||||
export { CopilotProvider, CopilotErrorCode } from './copilot-provider.js';
|
||||
|
||||
// Provider factory
|
||||
export { ProviderFactory } from './provider-factory.js';
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isCodexModel,
|
||||
isOpencodeModel,
|
||||
isGeminiModel,
|
||||
isCopilotModel,
|
||||
type ModelProvider,
|
||||
} from '@automaker/types';
|
||||
import * as fs from 'fs';
|
||||
@@ -23,6 +24,7 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
|
||||
cursor: '.cursor-disconnected',
|
||||
opencode: '.opencode-disconnected',
|
||||
gemini: '.gemini-disconnected',
|
||||
copilot: '.copilot-disconnected',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -275,6 +277,7 @@ import { CursorProvider } from './cursor-provider.js';
|
||||
import { CodexProvider } from './codex-provider.js';
|
||||
import { OpencodeProvider } from './opencode-provider.js';
|
||||
import { GeminiProvider } from './gemini-provider.js';
|
||||
import { CopilotProvider } from './copilot-provider.js';
|
||||
|
||||
// Register Claude provider
|
||||
registerProvider('claude', {
|
||||
@@ -317,3 +320,11 @@ registerProvider('gemini', {
|
||||
canHandleModel: (model: string) => isGeminiModel(model),
|
||||
priority: 4, // Between opencode (3) and codex (5)
|
||||
});
|
||||
|
||||
// Register Copilot provider (GitHub Copilot SDK)
|
||||
registerProvider('copilot', {
|
||||
factory: () => new CopilotProvider(),
|
||||
aliases: ['github-copilot', 'github'],
|
||||
canHandleModel: (model: string) => isCopilotModel(model),
|
||||
priority: 6, // High priority - check before Codex since both can handle GPT models
|
||||
});
|
||||
|
||||
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
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
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 { createAuthGeminiHandler } from './routes/auth-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 {
|
||||
createGetOpencodeModelsHandler,
|
||||
createRefreshOpencodeModelsHandler,
|
||||
@@ -80,6 +88,16 @@ export function createSetupRoutes(): Router {
|
||||
router.post('/auth-gemini', createAuthGeminiHandler());
|
||||
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
|
||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user