Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2f57a91
feat: add Anthropic API key form to agentic UI AI settings
sejas Aug 11, 2026
3ceed62
feat: support Anthropic API key settings in desktop agentic UI
sejas Aug 11, 2026
bd2b7e8
feat: validate Anthropic API key in CLI api-key and provider commands
sejas Aug 11, 2026
92af746
Merge branch 'validate-anthropic-api-key-cli' into add-anthropic-api-…
sejas Aug 11, 2026
94ab5f2
feat: validate Anthropic key on save; move input below description
sejas Aug 11, 2026
d275ea5
feat: switch Anthropic key UI to a provider toggle with autosaved key
sejas Aug 11, 2026
918790b
feat: validate the Anthropic key before saving it in AI settings
sejas Aug 11, 2026
4fb0064
refactor: dedupe cli.json primitives, debounce hook, error handling
sejas Aug 11, 2026
3213240
update: clarify Anthropic billing in AI settings copy
sejas Aug 11, 2026
81e3211
update: shorten Anthropic key description
sejas Aug 11, 2026
464e3cb
refactor: move AI provider settings from cli.json to shared.json
sejas Aug 11, 2026
7c2ac48
Merge remote-tracking branch 'origin/trunk' into add-anthropic-api-ke…
sejas Aug 12, 2026
859d500
fix: keep provider model ids narrowed to AiModelId
sejas Aug 12, 2026
bfca7df
fix: show a red border on the Anthropic API key field when saving fails
sejas Aug 12, 2026
8425c52
Merge remote-tracking branch 'origin/trunk' into add-anthropic-api-ke…
sejas Aug 13, 2026
ef9b3ca
test: remove a duplicate AiPanel case and rename a misleading one
sejas Aug 13, 2026
75312ff
fix: move AI settings from cli.json to shared.json in a CLI migration
sejas Aug 13, 2026
b26ecb3
add: studio_setting_ai_provider_change Tracks event
sejas Aug 13, 2026
67c359c
Merge remote-tracking branch 'origin/trunk' into add-anthropic-api-ke…
sejas Aug 13, 2026
2fd553f
Merge remote-tracking branch 'origin/trunk' into add-anthropic-api-ke…
sejas Aug 13, 2026
eaab3b8
Merge remote-tracking branch 'origin/trunk' into add-anthropic-api-ke…
sejas Aug 13, 2026
a5f9498
fix: preview short Anthropic keys by tail only and tighten comments
sejas Aug 13, 2026
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
69 changes: 34 additions & 35 deletions apps/cli/ai/providers.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import { password } from '@inquirer/prompts';
import { validateAnthropicApiKey } from '@studio/common/ai/anthropic-key';
import { DEFAULT_MODEL, type AiModelId } from '@studio/common/ai/models';
import {
AI_MODELS,
DEFAULT_MODEL,
type AiModelFamily,
type AiModelId,
} from '@studio/common/ai/models';
AI_PROVIDER_IDS,
DEFAULT_AI_PROVIDER,
getAiProviderModels,
type AiProviderId,
} from '@studio/common/ai/providers';
import { readAuthToken } from '@studio/common/lib/shared-config';
import { __ } from '@wordpress/i18n';
import { readCliConfig, updateCliConfigWithPartial } from 'cli/lib/cli-config/core';
import { LoggerError } from 'cli/logger';

export const AI_PROVIDERS = {
export const AI_PROVIDERS: Record< AiProviderId, string > = {
wpcom: 'WordPress.com',
'anthropic-api-key': 'Anthropic · API key',
} as const;

export type AiProviderId = keyof typeof AI_PROVIDERS;
};

export const DEFAULT_AI_PROVIDER: AiProviderId = 'wpcom';
export const AI_PROVIDER_PRIORITY: AiProviderId[] = [ 'wpcom', 'anthropic-api-key' ];
export type { AiProviderId };
export { DEFAULT_AI_PROVIDER };
// Fallback order when the configured provider is unavailable; declaration
// order of the canonical id list.
export const AI_PROVIDER_PRIORITY: readonly AiProviderId[] = AI_PROVIDER_IDS;

const DEFAULT_WPCOM_AI_GATEWAY_BASE_URL = 'https://public-api.wordpress.com/wpcom/v2/ai-api-proxy';
// The wpcom AI proxy maps feature slugs to upstream providers. Historically
Expand All @@ -35,14 +38,9 @@ export interface ResolveAiEnvironmentOptions {
export interface AiProviderDefinition {
id: AiProviderId;
autoFallbackWhenUnavailable: boolean;
/**
* Which model families this provider can service. `wpcom` relays both
* Anthropic and OpenAI wire formats through the same proxy; direct-API
* providers are restricted to their own family. `availableModels` and
* `defaultModel` are derived from this and kept on the definition so
* callers don't have to filter AI_MODELS themselves.
*/
readonly supportedModelFamilies: readonly AiModelFamily[];
// Derived from the provider's model families (see
// `@studio/common/ai/providers`), kept on the definition so callers don't
// have to filter AI_MODELS themselves.
readonly availableModels: readonly AiModelId[];
readonly defaultModel: AiModelId;
supportsModel( model: AiModelId ): boolean;
Expand All @@ -52,17 +50,12 @@ export interface AiProviderDefinition {
resolveEnv: ( options?: ResolveAiEnvironmentOptions ) => Promise< Record< string, string > >;
}

/**
* Fills in `availableModels`, `defaultModel`, and `supportsModel` from the
* declared `supportedModelFamilies` so each provider literal below only has to
* state its family allowlist.
*/
// Fills in `availableModels`, `defaultModel`, and `supportsModel` from the
// provider id's model families.
function defineProvider(
partial: Omit< AiProviderDefinition, 'availableModels' | 'defaultModel' | 'supportsModel' >
): AiProviderDefinition {
const availableModels: AiModelId[] = AI_MODELS.filter( ( model ) =>
partial.supportedModelFamilies.includes( model.family )
).map( ( model ) => model.id );
const availableModels = getAiProviderModels( partial.id ).map( ( model ) => model.id );
return {
...partial,
availableModels,
Expand All @@ -78,22 +71,30 @@ async function resolveAnthropicApiKey( options?: {
} ): Promise< string | undefined > {
const { anthropicApiKey: savedKey } = await readCliConfig();
if ( savedKey && ! options?.force ) {
return savedKey;
// Re-prompt only when Anthropic definitively rejects the saved key;
// an unreachable API must not lock the user out of their provider.
const validation = await validateAnthropicApiKey( savedKey );
if ( validation.status !== 'invalid' ) {
return savedKey;
}
}

const apiKey = await password( {
message: __( 'Enter your Anthropic API key (will be saved for future use):' ),
mask: '*',
validate: ( value ) => {
if ( ! value.trim() ) {
validate: async ( value ) => {
const trimmed = value.trim();
if ( ! trimmed ) {
return __( 'API key is required' );
}
return true;
const validation = await validateAnthropicApiKey( trimmed );
return validation.status === 'invalid' ? validation.message : true;
},
} );

await updateCliConfigWithPartial( { anthropicApiKey: apiKey } );
return apiKey;
const trimmedKey = apiKey.trim();
await updateCliConfigWithPartial( { anthropicApiKey: trimmedKey } );
return trimmedKey;
}

function getStudioUserAgent(): string {
Expand Down Expand Up @@ -143,7 +144,6 @@ const AI_PROVIDER_DEFINITIONS: Record< AiProviderId, AiProviderDefinition > = {
wpcom: defineProvider( {
id: 'wpcom',
autoFallbackWhenUnavailable: true,
supportedModelFamilies: [ 'anthropic', 'openai' ],
isVisible: async () => true,
isReady: async () => hasInlineWpcomAuth() || ( await hasValidWpcomAuth() ),
prepare: async () => {
Expand Down Expand Up @@ -196,7 +196,6 @@ const AI_PROVIDER_DEFINITIONS: Record< AiProviderId, AiProviderDefinition > = {
'anthropic-api-key': defineProvider( {
id: 'anthropic-api-key',
autoFallbackWhenUnavailable: false,
supportedModelFamilies: [ 'anthropic' ],
isVisible: async () => true,
isReady: async () => {
const { anthropicApiKey } = await readCliConfig();
Expand Down
7 changes: 2 additions & 5 deletions apps/cli/ai/sessions/context.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { resolveSessionModel, type AiModelId } from '@studio/common/ai/models';
import { isAiProviderId } from '@studio/common/ai/providers';
import { isStudioCustomEntryOfType } from '@studio/common/ai/sessions/entry-types';
import { AI_PROVIDERS, type AiProviderId } from 'cli/ai/providers';
import type { LoadedAiSession } from '@studio/common/ai/sessions/types';

function isAiProviderId( value: string ): value is AiProviderId {
return Object.prototype.hasOwnProperty.call( AI_PROVIDERS, value );
}
import type { AiProviderId } from 'cli/ai/providers';

export interface ResumeSessionContext {
sessionId?: string;
Expand Down
63 changes: 18 additions & 45 deletions apps/cli/lib/cli-config/core.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import fs from 'fs';
import path from 'path';
import { AI_PROVIDER_IDS } from '@studio/common/ai/providers';
import {
CLI_CONFIG_LOCKFILE_NAME,
LOCKFILE_STALE_TIME,
LOCKFILE_WAIT_TIME,
} from '@studio/common/constants';
CLI_CONFIG_VERSION,
ensureCliConfigDirectory,
lockCliConfigFile,
readCliConfigFileRaw,
unlockCliConfigFile,
writeCliConfigFileRaw,
} from '@studio/common/lib/cli-config-file';
import { siteDetailsSchema } from '@studio/common/lib/cli-events';
import { hideDirectoryOnWindows } from '@studio/common/lib/hide-dir-windows';
import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile';
import { getCliConfigPath, getConfigDirectory } from '@studio/common/lib/well-known-paths';
import { getCliConfigPath } from '@studio/common/lib/well-known-paths';
import { snapshotSchema } from '@studio/common/types/snapshot';
import { __ } from '@wordpress/i18n';
import { readFile, writeFile } from 'atomically';
import { z } from 'zod';
import { StatsMetric } from 'cli/lib/types/bump-stats';
import { LoggerError } from 'cli/logger';
Expand Down Expand Up @@ -59,12 +59,12 @@ const siteSchema = siteDetailsSchema
.loose();

// Schema updates must maintain backwards compatibility. If a breaking change is needed,
// increment CLI_CONFIG_VERSION and add a data migration function.
const CLI_CONFIG_VERSION = 1;
// increment CLI_CONFIG_VERSION (in @studio/common/lib/cli-config-file) and add a data migration
// function.

// IMPORTANT: Always consider that independently installed versions of the CLI (from npm) may also
// read this file, and any updates to this schema may require updating the `version` field.
export const aiProviderSchema = z.enum( [ 'wpcom', 'anthropic-api-key' ] );
export const aiProviderSchema = z.enum( AI_PROVIDER_IDS );

export const updateCheckSchema = z.object( {
lastChecked: z.number(),
Expand Down Expand Up @@ -102,16 +102,13 @@ const DEFAULT_CLI_CONFIG: CliConfig = {
};

export async function readCliConfig(): Promise< CliConfig > {
const configPath = getCliConfigPath();

if ( ! fs.existsSync( configPath ) ) {
if ( ! fs.existsSync( getCliConfigPath() ) ) {
return structuredClone( DEFAULT_CLI_CONFIG );
}

let data: Record< string, unknown >;
try {
const fileContent = await readFile( configPath, { encoding: 'utf8' } );
data = JSON.parse( fileContent );
data = await readCliConfigFileRaw();
} catch ( error ) {
throw new LoggerError( __( 'Failed to read CLI config file.' ), error );
}
Expand Down Expand Up @@ -140,24 +137,11 @@ export async function readCliConfig(): Promise< CliConfig > {
}
}

async function ensureConfigDirectory(): Promise< void > {
const configDir = getConfigDirectory();
if ( ! fs.existsSync( configDir ) ) {
fs.mkdirSync( configDir, { recursive: true } );
await hideDirectoryOnWindows( configDir );
}
}

export async function saveCliConfig( config: CliConfig ): Promise< void > {
try {
config.version = CLI_CONFIG_VERSION;

await ensureConfigDirectory();

const configPath = getCliConfigPath();
const fileContent = JSON.stringify( config, null, 2 ) + '\n';

await writeFile( configPath, fileContent, { encoding: 'utf8' } );
await ensureCliConfigDirectory();
await writeCliConfigFileRaw( config );
} catch ( error ) {
if ( error instanceof LoggerError ) {
throw error;
Expand All @@ -166,19 +150,8 @@ export async function saveCliConfig( config: CliConfig ): Promise< void > {
}
}

const LOCKFILE_PATH = path.join( getConfigDirectory(), CLI_CONFIG_LOCKFILE_NAME );

export async function lockCliConfig(): Promise< void > {
// The lockfile lives inside the config directory. On a first run that directory may not exist
// yet (e.g. telemetry bumps fire before `setupServerFiles()` creates it), and `lockfile.lock`
// would reject with ENOENT instead of waiting. Ensure the directory exists before locking.
await ensureConfigDirectory();
await lockFileAsync( LOCKFILE_PATH, { wait: LOCKFILE_WAIT_TIME, stale: LOCKFILE_STALE_TIME } );
}

export async function unlockCliConfig(): Promise< void > {
await unlockFileAsync( LOCKFILE_PATH );
}
export const lockCliConfig = lockCliConfigFile;
export const unlockCliConfig = unlockCliConfigFile;

export async function updateCliConfigWithPartial(
update: Partial< Omit< CliConfig, 'version' | 'sites' > >
Expand Down
Loading