Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 80 additions & 3 deletions packages/cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ interface SetupConfig {
claudeOauthToken?: string;
codex: boolean;
codexTokens?: CodexTokens;
copilot: boolean;
copilotToken?: string;
defaultAssistant: string;
};
platforms: {
Expand Down Expand Up @@ -95,6 +97,7 @@ interface ExistingConfig {
hasDatabase: boolean;
hasClaude: boolean;
hasCodex: boolean;
hasCopilot: boolean;
platforms: {
github: boolean;
telegram: boolean;
Expand Down Expand Up @@ -249,6 +252,7 @@ export function checkExistingConfig(): ExistingConfig | null {
hasEnvValue(content, 'CODEX_ACCESS_TOKEN') &&
hasEnvValue(content, 'CODEX_REFRESH_TOKEN') &&
hasEnvValue(content, 'CODEX_ACCOUNT_ID'),
hasCopilot: hasEnvValue(content, 'COPILOT_ENABLED'),
platforms: {
github: hasEnvValue(content, 'GITHUB_TOKEN') || hasEnvValue(content, 'GH_TOKEN'),
telegram: hasEnvValue(content, 'TELEGRAM_BOT_TOKEN'),
Expand Down Expand Up @@ -530,6 +534,48 @@ async function collectCodexAuth(): Promise<CodexTokens | null> {
};
}

/**
* Collect GitHub Copilot authentication
*/
async function collectCopilotAuth(): Promise<string | null> {
note(
'GitHub Copilot Authentication\n\n' +
'GitHub Copilot uses your existing GitHub CLI authentication.\n\n' +
'To authenticate:\n' +
'1. Run `gh auth login` in your terminal\n' +
'2. Complete the authentication flow\n\n' +
'The Copilot SDK will use your existing gh CLI credentials.\n' +
'No additional tokens are needed.',
'GitHub Copilot Setup'
);

const isAuthenticated = await confirm({
message: 'Have you authenticated with GitHub CLI (gh auth login)?',
});

if (isCancel(isAuthenticated)) {
cancel('Setup cancelled.');
process.exit(0);
}

if (!isAuthenticated) {
return null;
}

// Verify gh CLI is available
if (!isCommandAvailable('gh')) {
note(
'GitHub CLI (gh) is not installed.\n\n' +
'Install it from: https://cli.github.com/\n\n' +
'After installation, run `gh auth login` to authenticate.',
'GitHub CLI Not Found'
);
return null;
}

return 'gh-cli';
}

/**
* Collect AI assistant configuration
*/
Expand All @@ -540,6 +586,7 @@ async function collectAIConfig(): Promise<SetupConfig['ai']> {
options: [
{ value: 'claude', label: 'Claude (Recommended)', hint: 'Anthropic Claude Code SDK' },
{ value: 'codex', label: 'Codex', hint: 'OpenAI Codex SDK' },
{ value: 'copilot', label: 'GitHub Copilot', hint: 'GitHub Copilot SDK' },
],
required: false,
});
Expand All @@ -551,6 +598,7 @@ async function collectAIConfig(): Promise<SetupConfig['ai']> {

let hasClaude = assistants.includes('claude');
let hasCodex = assistants.includes('codex');
let hasCopilot = assistants.includes('copilot');

// Check if selected CLI tools are installed
if (hasClaude && !isCommandAvailable('claude')) {
Expand Down Expand Up @@ -650,11 +698,12 @@ After upgrading, run 'archon setup' again.`,
}
}

if (!hasClaude && !hasCodex) {
if (!hasClaude && !hasCodex && !hasCopilot) {
log.warning('No AI assistant selected. You can add one later by running `archon setup` again.');
return {
claude: false,
codex: false,
copilot: false,
defaultAssistant: getRegisteredProviders().find(p => p.builtIn)?.id ?? 'claude',
};
}
Expand All @@ -663,6 +712,7 @@ After upgrading, run 'archon setup' again.`,
let claudeApiKey: string | undefined;
let claudeOauthToken: string | undefined;
let codexTokens: CodexTokens | undefined;
let copilotToken: string | undefined;

// Collect Claude auth if selected
if (hasClaude) {
Expand All @@ -678,11 +728,22 @@ After upgrading, run 'archon setup' again.`,
codexTokens = tokens ?? undefined;
}

// Collect Copilot auth if selected
if (hasCopilot) {
const token = await collectCopilotAuth();
if (token === null) {
hasCopilot = false;
} else {
copilotToken = token;
}
}

// Determine default assistant — use the registry, but keep setup/auth flows built-in only.
// Default to first registered built-in provider rather than hardcoding 'claude'.
let defaultAssistant = getRegisteredProviders().find(p => p.builtIn)?.id ?? 'claude';

if (hasClaude && hasCodex) {
const selectedCount = [hasClaude, hasCodex, hasCopilot].filter(Boolean).length;
if (selectedCount > 1) {
const providerChoices = getRegisteredProviders()
.filter(p => p.builtIn)
.map(p => ({
Expand All @@ -701,8 +762,10 @@ After upgrading, run 'archon setup' again.`,
}

defaultAssistant = defaultChoice;
} else if (hasCodex && !hasClaude) {
} else if (hasCodex && !hasClaude && !hasCopilot) {
defaultAssistant = 'codex';
} else if (hasCopilot && !hasClaude && !hasCodex) {
defaultAssistant = 'copilot';
}

return {
Expand All @@ -712,6 +775,8 @@ After upgrading, run 'archon setup' again.`,
claudeOauthToken,
codex: hasCodex,
codexTokens,
copilot: hasCopilot,
copilotToken,
defaultAssistant,
};
}
Expand Down Expand Up @@ -1084,6 +1149,13 @@ export function generateEnvContent(config: SetupConfig): string {
lines.push('');
}

if (config.ai.copilot) {
lines.push('# GitHub Copilot');
lines.push('# Uses GitHub CLI authentication (gh auth login)');
lines.push('COPILOT_ENABLED=true');
lines.push('');
}

// Default AI Assistant
lines.push('# Default AI Assistant');
lines.push(`DEFAULT_AI_ASSISTANT=${config.ai.defaultAssistant}`);
Expand Down Expand Up @@ -1390,6 +1462,7 @@ export async function setupCommand(options: SetupOptions): Promise<void> {
`Database: ${existing.hasDatabase ? 'PostgreSQL' : 'SQLite'}`,
`Claude: ${existing.hasClaude ? 'Configured' : 'Not configured'}`,
`Codex: ${existing.hasCodex ? 'Configured' : 'Not configured'}`,
`Copilot: ${existing.hasCopilot ? 'Configured' : 'Not configured'}`,
`Platforms: ${configuredPlatforms.length > 0 ? configuredPlatforms.join(', ') : 'None'}`,
].join('\n');

Expand Down Expand Up @@ -1427,6 +1500,7 @@ export async function setupCommand(options: SetupOptions): Promise<void> {
ai: {
claude: existing?.hasClaude ?? false,
codex: existing?.hasCodex ?? false,
copilot: existing?.hasCopilot ?? false,
defaultAssistant: getRegisteredProviders().find(p => p.builtIn)?.id ?? 'claude',
},
platforms: {
Expand Down Expand Up @@ -1596,6 +1670,9 @@ export async function setupCommand(options: SetupOptions): Promise<void> {
if (config.ai.codex && config.ai.codexTokens) {
aiConfigured.push('Codex');
}
if (config.ai.copilot) {
aiConfigured.push('GitHub Copilot');
}

const summaryLines = [
`Database: ${config.database.type === 'postgresql' ? 'PostgreSQL' : 'SQLite (default)'}`,
Expand Down
23 changes: 13 additions & 10 deletions packages/core/src/config/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ concurrency:
const config = await loadConfig();

expect(config.assistant).toBe('claude');
expect(config.assistants).toEqual({ claude: {}, codex: {} });
expect(config.assistants).toEqual({ claude: {}, codex: {}, copilot: {} });
expect(config.streaming.telegram).toBe('stream');
expect(config.concurrency.maxConversations).toBe(10);
});
Expand All @@ -245,29 +245,32 @@ streaming:
expect(config.streaming.telegram).toBe('batch');
});

test('throws on unknown DEFAULT_AI_ASSISTANT env var', async () => {
test('accepts copilot as DEFAULT_AI_ASSISTANT env var', async () => {
mockReadConfigFile.mockResolvedValue('');
process.env.DEFAULT_AI_ASSISTANT = 'nonexistent-provider';
process.env.DEFAULT_AI_ASSISTANT = 'copilot';

await expect(loadConfig()).rejects.toThrow(/not a registered provider/);
const config = await loadConfig();
expect(config.assistant).toBe('copilot');
});

test('throws on unknown defaultAssistant in global config', async () => {
mockReadConfigFile.mockResolvedValue('defaultAssistant: nonexistent-provider');
test('accepts copilot in global config', async () => {
mockReadConfigFile.mockResolvedValue('defaultAssistant: copilot');

await expect(loadConfig()).rejects.toThrow(/not a registered provider/);
const config = await loadConfig();
expect(config.assistant).toBe('copilot');
});

test('throws on unknown assistant in repo config', async () => {
test('accepts copilot in repo config', async () => {
mockReadConfigFile.mockImplementation(async (path: string) => {
const normalized = path.replace(/\\/g, '/');
if (normalized.includes('/tmp/test-repo/.archon/config.yaml')) {
return 'assistant: nonexistent-provider';
return 'assistant: copilot';
}
return '';
});

await expect(loadConfig('/tmp/test-repo')).rejects.toThrow(/not a registered provider/);
const config = await loadConfig('/tmp/test-repo');
expect(config.assistant).toBe('copilot');
});

test('repo config overrides global config', async () => {
Expand Down
Loading