Skip to content
6 changes: 3 additions & 3 deletions apps/cli/ai/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
getAiProviderDefinition,
type AiProviderId,
} from 'cli/ai/providers';
import { getAiProvider, saveAiProvider } from 'cli/lib/appdata';
import { readCliConfig, updateCliConfig } from 'cli/lib/cli-config/core';

async function getPreferredReadyProvider(
exclude?: AiProviderId
Expand Down Expand Up @@ -49,7 +49,7 @@ export async function resolveUnavailableAiProvider(
}

export async function resolveInitialAiProvider(): Promise< AiProviderId > {
const savedProvider = await getAiProvider();
const { aiProvider: savedProvider } = await readCliConfig();
if ( savedProvider ) {
const definition = getAiProviderDefinition( savedProvider );
if (
Expand All @@ -73,7 +73,7 @@ export async function resolveInitialAiProvider(): Promise< AiProviderId > {
}

export async function saveSelectedAiProvider( provider: AiProviderId ): Promise< void > {
await saveAiProvider( provider );
await updateCliConfig( { aiProvider: provider } );
}

export async function prepareAiProvider(
Expand Down
27 changes: 15 additions & 12 deletions apps/cli/ai/providers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import childProcess from 'child_process';
import { password } from '@inquirer/prompts';
import { readAuthToken } from '@studio/common/lib/shared-config';
import { __ } from '@wordpress/i18n';
import { z } from 'zod';
import { getAnthropicApiKey, getAuthToken, saveAnthropicApiKey } from 'cli/lib/appdata';
import { readCliConfig, updateCliConfig } from 'cli/lib/cli-config/core';
import { LoggerError } from 'cli/logger';

export const AI_PROVIDERS = {
Expand Down Expand Up @@ -51,7 +52,7 @@ export function hasClaudeCodeAuth(): boolean {
async function resolveAnthropicApiKey( options?: {
force?: boolean;
} ): Promise< string | undefined > {
const savedKey = await getAnthropicApiKey();
const { anthropicApiKey: savedKey } = await readCliConfig();
if ( savedKey && ! options?.force ) {
return savedKey;
}
Expand All @@ -67,7 +68,7 @@ async function resolveAnthropicApiKey( options?: {
},
} );

await saveAnthropicApiKey( apiKey );
await updateCliConfig( { anthropicApiKey: apiKey } );
return apiKey;
}

Expand All @@ -83,12 +84,8 @@ function getWpcomAiGatewayBaseUrl(): string {
}

async function hasValidWpcomAuth(): Promise< boolean > {
try {
await getAuthToken();
return true;
} catch {
return false;
}
const token = await readAuthToken();
return token !== null;
}

function createBaseEnvironment(): Record< string, string > {
Expand Down Expand Up @@ -117,7 +114,10 @@ const AI_PROVIDER_DEFINITIONS: Record< AiProviderId, AiProviderDefinition > = {
throw new LoggerError( __( 'WordPress.com login required. Use /login to authenticate.' ) );
},
resolveEnv: async () => {
const token = await getAuthToken();
const token = await readAuthToken();
if ( ! token ) {
throw new LoggerError( __( 'WordPress.com login required. Use /login to authenticate.' ) );
}
const env = createBaseEnvironment();
env.ANTHROPIC_BASE_URL = getWpcomAiGatewayBaseUrl();
env.ANTHROPIC_AUTH_TOKEN = token.accessToken;
Expand Down Expand Up @@ -156,12 +156,15 @@ const AI_PROVIDER_DEFINITIONS: Record< AiProviderId, AiProviderDefinition > = {
id: 'anthropic-api-key',
autoFallbackWhenUnavailable: false,
isVisible: async () => true,
isReady: async () => Boolean( await getAnthropicApiKey() ),
isReady: async () => {
const { anthropicApiKey } = await readCliConfig();
return Boolean( anthropicApiKey );
},
prepare: async ( options ) => {
await resolveAnthropicApiKey( options );
},
resolveEnv: async () => {
const apiKey = await getAnthropicApiKey();
const { anthropicApiKey: apiKey } = await readCliConfig();
if ( ! apiKey ) {
throw new LoggerError(
__(
Expand Down
101 changes: 65 additions & 36 deletions apps/cli/ai/tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import childProcess from 'child_process';
import { password } from '@inquirer/prompts';
import { readAuthToken } from '@studio/common/lib/shared-config';
import { vi } from 'vitest';
import {
getAvailableAiProviders,
Expand All @@ -9,12 +10,7 @@ import {
resolveInitialAiProvider,
resolveUnavailableAiProvider,
} from 'cli/ai/auth';
import {
getAiProvider,
getAnthropicApiKey,
getAuthToken,
saveAnthropicApiKey,
} from 'cli/lib/appdata';
import { readCliConfig, updateCliConfig } from 'cli/lib/cli-config/core';
import { LoggerError } from 'cli/logger';

vi.mock( 'child_process', () => ( {
Expand All @@ -28,33 +24,40 @@ vi.mock( '@inquirer/prompts', () => ( {
password: vi.fn(),
} ) );

vi.mock( 'cli/lib/appdata', () => ( {
getAiProvider: vi.fn(),
getAnthropicApiKey: vi.fn(),
getAuthToken: vi.fn(),
saveAnthropicApiKey: vi.fn(),
saveAiProvider: vi.fn(),
vi.mock( '@studio/common/lib/shared-config', () => ( {
readAuthToken: vi.fn(),
} ) );

vi.mock( 'cli/lib/cli-config/core', () => ( {
readCliConfig: vi.fn().mockResolvedValue( { version: 1, sites: [] } ),
updateCliConfig: vi.fn(),
} ) );

describe( 'AI auth helpers', () => {
beforeEach( () => {
vi.resetAllMocks();
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
delete process.env.WPCOM_AI_PROXY_BASE_URL;
} );

it( 'uses the saved Anthropic API key when provider is Anthropic API key', async () => {
vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'saved-key' );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
anthropicApiKey: 'saved-key',
} );

const env = await resolveAiEnvironment( 'anthropic-api-key' );

expect( env.ANTHROPIC_API_KEY ).toBe( 'saved-key' );
expect( env.ANTHROPIC_BASE_URL ).toBeUndefined();
expect( env.ANTHROPIC_AUTH_TOKEN ).toBeUndefined();
expect( saveAnthropicApiKey ).not.toHaveBeenCalled();
expect( updateCliConfig ).not.toHaveBeenCalled();
} );

it( 'requires a saved Anthropic API key in API key mode', async () => {
vi.mocked( getAnthropicApiKey ).mockResolvedValue( undefined );
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );

await expect( resolveAiEnvironment( 'anthropic-api-key' ) ).rejects.toBeInstanceOf(
LoggerError
Expand All @@ -72,23 +75,28 @@ describe( 'AI auth helpers', () => {
} );

it( 'prompts for the API key immediately when preparing the API key provider', async () => {
vi.mocked( getAnthropicApiKey ).mockResolvedValue( undefined );
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( password ).mockResolvedValue( 'prompted-key' );

await prepareAiProvider( 'anthropic-api-key' );

expect( password ).toHaveBeenCalledOnce();
expect( saveAnthropicApiKey ).toHaveBeenCalledWith( 'prompted-key' );
expect( updateCliConfig ).toHaveBeenCalledWith( { anthropicApiKey: 'prompted-key' } );
} );

it( 'can force re-entering the API key even when one is already saved', async () => {
vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'saved-key' );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
anthropicApiKey: 'saved-key',
} );
vi.mocked( password ).mockResolvedValue( 'updated-key' );

await prepareAiProvider( 'anthropic-api-key', { force: true } );

expect( password ).toHaveBeenCalledOnce();
expect( saveAnthropicApiKey ).toHaveBeenCalledWith( 'updated-key' );
expect( updateCliConfig ).toHaveBeenCalledWith( { anthropicApiKey: 'updated-key' } );
} );

it( 'lists Claude auth only when it is available', async () => {
Expand All @@ -106,7 +114,7 @@ describe( 'AI auth helpers', () => {
} );

it( 'configures the WP.com gateway environment', async () => {
vi.mocked( getAuthToken ).mockResolvedValue( {
vi.mocked( readAuthToken ).mockResolvedValue( {
accessToken: 'wpcom-token',
displayName: 'User',
email: 'user@example.com',
Expand All @@ -127,15 +135,26 @@ describe( 'AI auth helpers', () => {
} );

it( 'prefers the saved provider', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( 'anthropic-api-key' );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
aiProvider: 'anthropic-api-key',
anthropicApiKey: 'key',
} );

await expect( resolveInitialAiProvider() ).resolves.toBe( 'anthropic-api-key' );
expect( getAuthToken ).not.toHaveBeenCalled();
expect( readAuthToken ).not.toHaveBeenCalled();
} );

it( 'falls back to API key mode when saved Claude auth is no longer available', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( 'anthropic-claude' );
vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
aiProvider: 'anthropic-claude',
} );
vi.mocked( readAuthToken ).mockResolvedValue( null );
vi.mocked( childProcess.execFileSync ).mockImplementation( () => {
throw new Error( 'not authenticated' );
} );
Expand All @@ -144,16 +163,21 @@ describe( 'AI auth helpers', () => {
} );

it( 'falls back from saved WP.com provider when WordPress.com auth is unavailable and Claude auth is ready', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( 'wpcom' );
vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
aiProvider: 'wpcom',
} );
vi.mocked( readAuthToken ).mockResolvedValue( null );
vi.mocked( childProcess.execFileSync ).mockReturnValue( 'Authenticated' as never );

await expect( resolveInitialAiProvider() ).resolves.toBe( 'anthropic-claude' );
} );

it( 'defaults to WP.com when no provider is saved and a valid WP.com token exists', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( undefined );
vi.mocked( getAuthToken ).mockResolvedValue( {
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( readAuthToken ).mockResolvedValue( {
accessToken: 'wpcom-token',
displayName: 'User',
email: 'user@example.com',
Expand All @@ -166,8 +190,8 @@ describe( 'AI auth helpers', () => {
} );

it( 'falls back to Anthropic API key when no other auth is available', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( undefined );
vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( readAuthToken ).mockResolvedValue( null );
vi.mocked( childProcess.execFileSync ).mockImplementation( () => {
throw new Error( 'not authenticated' );
} );
Expand All @@ -176,15 +200,15 @@ describe( 'AI auth helpers', () => {
} );

it( 'defaults to Claude auth when no provider is saved and Claude auth is available', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( undefined );
vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( readAuthToken ).mockResolvedValue( null );
vi.mocked( childProcess.execFileSync ).mockReturnValue( 'Authenticated' as never );

await expect( resolveInitialAiProvider() ).resolves.toBe( 'anthropic-claude' );
} );

it( 'reports WordPress.com readiness based on WP.com auth state', async () => {
vi.mocked( getAuthToken ).mockResolvedValue( {
vi.mocked( readAuthToken ).mockResolvedValue( {
accessToken: 'wpcom-token',
displayName: 'User',
email: 'user@example.com',
Expand All @@ -195,13 +219,18 @@ describe( 'AI auth helpers', () => {

await expect( isAiProviderReady( 'wpcom' ) ).resolves.toBe( true );

vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( readAuthToken ).mockResolvedValue( null );
await expect( isAiProviderReady( 'wpcom' ) ).resolves.toBe( false );
} );

it( 'resolves a fallback provider only for providers that auto-fallback', async () => {
vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'saved-key' );
vi.mocked( readAuthToken ).mockResolvedValue( null );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
anthropicApiKey: 'saved-key',
} );

await expect( resolveUnavailableAiProvider( 'wpcom' ) ).resolves.toBe( 'anthropic-api-key' );
await expect( resolveUnavailableAiProvider( 'anthropic-api-key' ) ).resolves.toBeUndefined();
Expand Down
8 changes: 3 additions & 5 deletions apps/cli/ai/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ import {
truncateToWidth,
visibleWidth,
} from '@mariozechner/pi-tui';
import { readAuthToken } from '@studio/common/lib/shared-config';
import chalk from 'chalk';
import { AI_MODELS, DEFAULT_MODEL, type AiModelId, type AskUserQuestion } from 'cli/ai/agent';
import { AI_PROVIDERS, DEFAULT_AI_PROVIDER, type AiProviderId } from 'cli/ai/providers';
import { AI_CHAT_SLASH_COMMANDS, type SlashCommandDef } from 'cli/ai/slash-commands';
import { buildTodoUpdateLines, type TodoRenderLine } from 'cli/ai/todo-render';
import { diffTodoSnapshot, type TodoDiff, type TodoEntry } from 'cli/ai/todo-stream';
import { getWpComSites } from 'cli/lib/api';
import { getAuthToken } from 'cli/lib/appdata';
import { openBrowser } from 'cli/lib/browser';
import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core';
import { getSiteUrl } from 'cli/lib/cli-config/sites';
Expand Down Expand Up @@ -787,10 +787,8 @@ export class AiChatUI {
}

private async switchToRemoteSites(): Promise< void > {
let token: Awaited< ReturnType< typeof getAuthToken > >;
try {
token = await getAuthToken();
} catch {
const token = await readAuthToken();
if ( ! token ) {
this.showSitePickerError( 'Not logged in. Use /login first.' );
return;
}
Expand Down
18 changes: 11 additions & 7 deletions apps/cli/commands/ai.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readAuthToken } from '@studio/common/lib/shared-config';
import { __ } from '@wordpress/i18n';
import { AI_MODELS, DEFAULT_MODEL, startAiAgent, type AiModelId } from 'cli/ai/agent';
import {
Expand All @@ -22,7 +23,7 @@ import {
import { AiChatUI } from 'cli/ai/ui';
import { runCommand as runLoginCommand } from 'cli/commands/auth/login';
import { runCommand as runLogoutCommand } from 'cli/commands/auth/logout';
import { getAnthropicApiKey, getAuthToken } from 'cli/lib/appdata';
import { readCliConfig } from 'cli/lib/cli-config/core';
import { Logger, LoggerError, setProgressCallback } from 'cli/logger';
import { StudioArgv } from 'cli/types';

Expand Down Expand Up @@ -98,15 +99,16 @@ export async function runCommand(): Promise< void > {
}

if ( currentProvider === 'wpcom' ) {
try {
const token = await getAuthToken();
const token = await readAuthToken();
if ( token ) {
ui.setStatusMessage( `Logged in as ${ token.displayName } (${ token.email })` );
} catch {
} else {
ui.setStatusMessage( 'Use /login to authenticate to WordPress.com' );
}
}

if ( currentProvider === 'anthropic-api-key' && ! ( await getAnthropicApiKey() ) ) {
const { anthropicApiKey } = await readCliConfig();
if ( currentProvider === 'anthropic-api-key' && ! anthropicApiKey ) {
ui.showInfo( 'No Anthropic API key saved. Use /provider to enter one.' );
}

Expand Down Expand Up @@ -216,8 +218,10 @@ export async function runCommand(): Promise< void > {
await runLoginCommand();
ui.start();
if ( await isAiProviderReady( 'wpcom' ) ) {
const token = await getAuthToken();
ui.setStatusMessage( `Logged in as ${ token.displayName } (${ token.email })` );
const token = await readAuthToken();
if ( token ) {
ui.setStatusMessage( `Logged in as ${ token.displayName } (${ token.email })` );
}
} else {
ui.setStatusMessage( 'Login failed or canceled' );
}
Expand Down
Loading