diff --git a/README.md b/README.md index 9c173863..3d1fda3a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ ccs config # Opens http://localhost:3000 ``` +Dashboard updates hub: `http://localhost:3000/updates` + Want to run the dashboard in Docker? See `docker/README.md`. ### 3. Configure Your Accounts @@ -62,6 +64,7 @@ The dashboard provides visual management for all account types: - **Claude Accounts**: Create isolated instances (work, personal, client) - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot - **API Profiles**: Configure GLM, Kimi with your keys +- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles **Analytics Dashboard** @@ -154,6 +157,30 @@ ccsd glm Need additional alias names? Set `CCS_DROID_ALIASES` as a comma-separated list (for example: `CCS_DROID_ALIASES=ccs-droid,mydroid`). +### Per-Profile Target Defaults + +You can pin a default target (`claude` or `droid`) per profile: + +```bash +# API profile defaults to Droid +ccs api create myglm --preset glm --target droid + +# CLIProxy variant defaults to Droid +ccs cliproxy create mycodex --provider codex --target droid +``` + +Built-in CLIProxy providers also work with Droid alias/target override: + +```bash +ccsd codex +ccsd agy +ccs codex --target droid +``` + +Dashboard parity: +- `ccs config` -> `API Profiles` -> set **Default Target** +- `ccs config` -> `CLIProxy` -> create/edit variant -> set **Default Target** + ### Kiro Auth Methods `ccs kiro --auth` defaults to AWS Builder ID Device OAuth (best support for AWS org accounts). diff --git a/src/api/services/index.ts b/src/api/services/index.ts index bdb07b20..57cceba4 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -15,6 +15,7 @@ export { type ApiListResult, type CreateApiProfileResult, type RemoveApiProfileResult, + type UpdateApiProfileTargetResult, } from './profile-types'; // Profile read operations @@ -27,7 +28,7 @@ export { } from './profile-reader'; // Profile write operations -export { createApiProfile, removeApiProfile } from './profile-writer'; +export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer'; // OpenRouter catalog and picker export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog'; diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index f31d4986..190426da 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -9,8 +9,18 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; +import type { TargetType } from '../../targets/target-adapter'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; +const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); + +function sanitizeTarget(target: unknown): TargetType { + if (typeof target === 'string' && VALID_TARGETS.has(target as TargetType)) { + return target as TargetType; + } + return 'claude'; +} + /** * Check if API profile exists in config */ @@ -68,6 +78,7 @@ export function listApiProfiles(): ApiListResult { settingsPath: profile.settings || 'config.yaml', isConfigured: isApiProfileConfigured(name), configSource: 'unified', + target: sanitizeTarget(profile.target), }); } // CLIProxy variants @@ -80,10 +91,13 @@ export function listApiProfiles(): ApiListResult { name, provider, settings: variant?.settings || '-', + target: sanitizeTarget(variant?.target), }); } } else { const config = loadConfigSafe(); + const legacyTargetMap = (config as { profile_targets?: Record }) + .profile_targets; for (const [name, settingsPath] of Object.entries(config.profiles)) { // Skip 'default' profile - it's the user's native Claude settings if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) { @@ -94,16 +108,18 @@ export function listApiProfiles(): ApiListResult { settingsPath: settingsPath as string, isConfigured: isApiProfileConfigured(name), configSource: 'legacy', + target: sanitizeTarget(legacyTargetMap?.[name]), }); } // CLIProxy variants if (config.cliproxy) { for (const [name, v] of Object.entries(config.cliproxy)) { - const variant = v as { provider: string; settings: string }; + const variant = v as { provider: string; settings: string; target?: unknown }; variants.push({ name, provider: variant.provider, settings: variant.settings, + target: sanitizeTarget(variant.target), }); } } diff --git a/src/api/services/profile-types.ts b/src/api/services/profile-types.ts index 75a0b76b..62da4667 100644 --- a/src/api/services/profile-types.ts +++ b/src/api/services/profile-types.ts @@ -4,6 +4,8 @@ * Shared type definitions for API profile services. */ +import type { TargetType } from '../../targets/target-adapter'; + /** Model mapping for API profiles */ export interface ModelMapping { default: string; @@ -18,6 +20,7 @@ export interface ApiProfileInfo { settingsPath: string; isConfigured: boolean; configSource: 'unified' | 'legacy'; + target: TargetType; } /** CLIProxy variant info */ @@ -25,6 +28,7 @@ export interface CliproxyVariantInfo { name: string; provider: string; settings: string; + target: TargetType; } /** Result from list operation */ @@ -45,3 +49,10 @@ export interface RemoveApiProfileResult { success: boolean; error?: string; } + +/** Result from updating API profile target */ +export interface UpdateApiProfileTargetResult { + success: boolean; + target?: TargetType; + error?: string; +} diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index ebac9446..f90455c2 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -12,7 +12,13 @@ import { isUnifiedMode, } from '../../config/unified-config-loader'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; -import type { ModelMapping, CreateApiProfileResult, RemoveApiProfileResult } from './profile-types'; +import type { TargetType } from '../../targets/target-adapter'; +import type { + ModelMapping, + CreateApiProfileResult, + RemoveApiProfileResult, + UpdateApiProfileTargetResult, +} from './profile-types'; /** Check if URL is an OpenRouter endpoint */ function isOpenRouterUrl(baseUrl: string): boolean { @@ -51,11 +57,15 @@ function createSettingsFile( } /** Update config.json with new API profile (legacy format) */ -function updateLegacyConfig(name: string): void { +function updateLegacyConfig(name: string, target: TargetType = 'claude'): void { const configPath = getConfigPath(); const ccsDir = getCcsDir(); - let config: { profiles: Record; cliproxy?: Record }; + let config: { + profiles: Record; + cliproxy?: Record; + profile_targets?: Record; + }; try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { @@ -64,6 +74,12 @@ function updateLegacyConfig(name: string): void { const relativePath = `~/.ccs/${name}.settings.json`; config.profiles[name] = relativePath; + config.profile_targets = config.profile_targets || {}; + if (target === 'claude') { + delete config.profile_targets[name]; + } else { + config.profile_targets[name] = target; + } if (!fs.existsSync(ccsDir)) { fs.mkdirSync(ccsDir, { recursive: true }); @@ -80,7 +96,8 @@ function createApiProfileUnified( name: string, baseUrl: string, apiKey: string, - models: ModelMapping + models: ModelMapping, + target: TargetType = 'claude' ): void { const ccsDir = getCcsDir(); const settingsFile = `${name}.settings.json`; @@ -112,6 +129,7 @@ function createApiProfileUnified( config.profiles[name] = { type: 'api', settings: `~/.ccs/${settingsFile}`, + ...(target !== 'claude' && { target }), }; saveUnifiedConfig(config); } @@ -121,16 +139,17 @@ export function createApiProfile( name: string, baseUrl: string, apiKey: string, - models: ModelMapping + models: ModelMapping, + target: TargetType = 'claude' ): CreateApiProfileResult { try { const settingsFile = `~/.ccs/${name}.settings.json`; if (isUnifiedMode()) { - createApiProfileUnified(name, baseUrl, apiKey, models); + createApiProfileUnified(name, baseUrl, apiKey, models, target); } else { createSettingsFile(name, baseUrl, apiKey, models); - updateLegacyConfig(name); + updateLegacyConfig(name, target); } return { success: true, settingsFile }; @@ -143,6 +162,63 @@ export function createApiProfile( } } +/** + * Update API profile target (claude/droid). + * Persists to config.yaml in unified mode and config.json profile_targets in legacy mode. + */ +export function updateApiProfileTarget( + name: string, + target: TargetType +): UpdateApiProfileTargetResult { + try { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + if (!config.profiles[name]) { + return { success: false, error: `API profile not found: ${name}` }; + } + + if (target === 'claude') { + delete config.profiles[name].target; + } else { + config.profiles[name].target = target; + } + saveUnifiedConfig(config); + return { success: true, target }; + } + + const configPath = getConfigPath(); + let config: { + profiles: Record; + cliproxy?: Record; + profile_targets?: Record; + }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + config = { profiles: {} }; + } + + if (!config.profiles[name]) { + return { success: false, error: `API profile not found: ${name}` }; + } + + config.profile_targets = config.profile_targets || {}; + if (target === 'claude') { + delete config.profile_targets[name]; + } else { + config.profile_targets[name] = target; + } + + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); + + return { success: true, target }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} + /** Remove API profile from unified config */ function removeApiProfileUnified(name: string): void { const config = loadOrCreateUnifiedConfig(); @@ -175,6 +251,12 @@ function removeApiProfileUnified(name: string): void { function removeApiProfileLegacy(name: string): void { const config = loadConfigSafe(); delete config.profiles[name]; + if (config.profile_targets) { + delete config.profile_targets[name]; + if (Object.keys(config.profile_targets).length === 0) { + delete config.profile_targets; + } + } const configPath = getConfigPath(); const tempPath = configPath + '.tmp'; diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index a705200b..06bdb788 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -309,12 +309,15 @@ class ProfileDetector { // Priority 2: Check user-defined CLIProxy variants (config.cliproxy section) const config = this.readConfig(); + const legacyTargetMap = (config as { profile_targets?: Record }) + .profile_targets; if (config.cliproxy && config.cliproxy[profileName]) { const variant = config.cliproxy[profileName]; return { type: 'cliproxy', name: profileName, + target: variant.target, provider: variant.provider as CLIProxyProfileName, settingsPath: variant.settings, port: variant.port, @@ -333,6 +336,7 @@ class ProfileDetector { type: 'settings', name: profileName, settingsPath: config.profiles[candidate], + target: legacyTargetMap?.[candidate], message: viaLegacyAlias ? `Using legacy API profile "${candidate}" for "${profileName}".` : undefined, @@ -392,6 +396,8 @@ class ProfileDetector { // Check if settings-based default exists const config = this.readConfig(); + const legacyTargetMap = (config as { profile_targets?: Record }) + .profile_targets; if (config.profiles && config.profiles['default']) { const settingsPath = config.profiles['default']; @@ -409,6 +415,7 @@ class ProfileDetector { type: 'settings', name: 'default', settingsPath, + target: legacyTargetMap?.['default'], }; } diff --git a/src/ccs.ts b/src/ccs.ts index 20f69d54..94e096e6 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -12,7 +12,14 @@ import { import { expandPath } from './utils/helpers'; import { validateGlmKey, validateMiniMaxKey } from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; -import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; +import { + execClaudeWithCLIProxy, + CLIProxyProvider, + ensureCliproxyService, + isAuthenticated, +} from './cliproxy'; +import { getEffectiveEnvVars, getCompositeEnvVars } from './cliproxy/config/env-builder'; +import { CLIPROXY_DEFAULT_PORT } from './cliproxy/config/port-manager'; import { ensureMcpWebSearch, displayWebSearchStatus, @@ -694,7 +701,9 @@ async function main(): Promise { if (resolvedTarget === 'droid') { try { const allProfiles = detector.getAllProfiles(); - const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9_-]+$/.test(name)); + const activeProfiles = allProfiles.settings.filter((name) => + /^[a-zA-Z0-9._-]+$/.test(name) + ); await pruneOrphanedModels(activeProfiles); } catch (error) { console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); @@ -724,9 +733,141 @@ async function main(): Promise { const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles const variantPort = profileInfo.port; // variant-specific port for isolation + const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT; + + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exitCode = 1; + return; + } + if (!adapter.supportsProfileType('cliproxy')) { + console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`)); + process.exitCode = 1; + return; + } + + // Keep CLIProxy management/auth flags on Claude flow only. + const unsupportedCliproxyFlags = [ + '--auth', + '--logout', + '--accounts', + '--add', + '--use', + '--config', + '--headless', + '--paste-callback', + '--port-forward', + '--nickname', + '--kiro-auth-method', + '--backend', + '--proxy-host', + '--proxy-port', + '--proxy-protocol', + '--proxy-auth-token', + '--proxy-timeout', + '--local-proxy', + '--remote-only', + '--no-fallback', + '--allow-self-signed', + '--thinking', + '--effort', + '--1m', + '--no-1m', + ]; + const providedUnsupportedFlag = unsupportedCliproxyFlags.find( + (flag) => + remainingArgs.includes(flag) || remainingArgs.some((arg) => arg.startsWith(`${flag}=`)) + ); + if (providedUnsupportedFlag) { + console.error( + fail( + `${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target` + ) + ); + console.error( + info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`) + ); + process.exitCode = 1; + return; + } + + // For Droid execution path, require existing OAuth auth and running local proxy. + if (profileInfo.isComposite && profileInfo.compositeTiers) { + const compositeProviders = [ + ...new Set(Object.values(profileInfo.compositeTiers).map((tier) => tier.provider)), + ] as CLIProxyProvider[]; + const missingProvider = compositeProviders.find((p) => !isAuthenticated(p)); + if (missingProvider) { + console.error( + fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`) + ); + console.error(info(`Authenticate first: ccs ${missingProvider} --auth`)); + process.exitCode = 1; + return; + } + } else if (!isAuthenticated(provider)) { + console.error(fail(`No OAuth authentication found for provider: ${provider}`)); + console.error(info(`Authenticate first: ccs ${provider} --auth`)); + process.exitCode = 1; + return; + } + + const ensureServiceResult = await ensureCliproxyService( + cliproxyPort, + remainingArgs.includes('--verbose') || remainingArgs.includes('-v') + ); + if (!ensureServiceResult.started) { + console.error( + fail(ensureServiceResult.error || 'Failed to start local CLIProxy service') + ); + process.exitCode = 1; + return; + } + + const envVars = + profileInfo.isComposite && profileInfo.compositeTiers && profileInfo.compositeDefaultTier + ? getCompositeEnvVars( + profileInfo.compositeTiers, + profileInfo.compositeDefaultTier, + cliproxyPort, + customSettingsPath + ) + : getEffectiveEnvVars(provider, cliproxyPort, customSettingsPath); + + const creds: TargetCredentials = { + profile: profileInfo.name, + baseUrl: envVars['ANTHROPIC_BASE_URL'] || '', + apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '', + model: envVars['ANTHROPIC_MODEL'] || undefined, + provider: 'anthropic', + envVars, + }; + + if (!creds.baseUrl || !creds.apiKey) { + console.error( + fail( + `Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)` + ) + ); + console.error( + info('Reconfigure with: ccs config > CLIProxy, or run ccs --config') + ); + process.exitCode = 1; + return; + } + + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs); + const targetEnv = adapter.buildEnv(creds, profileInfo.type); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); + return; + } + await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath, - port: variantPort, + port: cliproxyPort, isComposite: profileInfo.isComposite, compositeTiers: profileInfo.compositeTiers, compositeDefaultTier: profileInfo.compositeDefaultTier, diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts index 1b1b3f15..e8179b51 100644 --- a/src/cliproxy/services/variant-config-adapter.ts +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -1,12 +1,7 @@ -/** - * CLIProxy Variant Config Adapters - * - * Handles reading/writing variant config in both unified and legacy formats. - */ - import * as fs from 'fs'; import { getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { CLIProxyProvider } from '../types'; +import type { TargetType } from '../../targets/target-adapter'; import { CLIProxyVariantConfig, CompositeVariantConfig, @@ -20,19 +15,15 @@ import { } from '../../config/unified-config-loader'; import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; -/** First port for variant profiles (8318 = default + 1) */ export const VARIANT_PORT_BASE = CLIPROXY_DEFAULT_PORT + 1; - -/** Maximum port offset for variants (100 ports: 8318-8417) */ export const VARIANT_PORT_MAX_OFFSET = 100; - -/** Variant configuration structure */ export interface VariantConfig { provider: string; settings?: string; account?: string; model?: string; port?: number; + target?: TargetType; /** Composite variant fields */ type?: 'composite'; default_tier?: 'opus' | 'sonnet' | 'haiku'; @@ -45,9 +36,6 @@ export interface VariantConfig { hasFallback?: boolean; } -/** - * Check if variant exists in config - */ export function variantExistsInConfig(name: string): boolean { try { if (isUnifiedMode()) { @@ -61,10 +49,6 @@ export function variantExistsInConfig(name: string): boolean { } } -/** - * Get next available port for a new variant. - * Scans existing variants, returns first unused port starting from VARIANT_PORT_BASE. - */ export function getNextAvailablePort(): number { const variants = listVariantsFromConfig(); const usedPorts = new Set(); @@ -89,9 +73,6 @@ export function getNextAvailablePort(): number { ); } -/** - * List variants from config - */ export function listVariantsFromConfig(): Record { try { if (isUnifiedMode()) { @@ -139,6 +120,7 @@ export function listVariantsFromConfig(): Record { provider: defaultTierConfig.provider, settings: composite.settings, port: composite.port, + target: composite.target || 'claude', type: 'composite', default_tier: composite.default_tier, tiers: normalizedTiers, @@ -151,6 +133,7 @@ export function listVariantsFromConfig(): Record { settings: single.settings, account: single.account, port: single.port, + target: single.target || 'claude', }; } } catch (error) { @@ -171,12 +154,14 @@ export function listVariantsFromConfig(): Record { settings: string; account?: string; port?: number; + target?: TargetType; }; result[name] = { provider: v.provider, settings: v.settings, account: v.account, port: v.port, + target: v.target || 'claude', }; } return result; @@ -185,9 +170,6 @@ export function listVariantsFromConfig(): Record { } } -/** - * Save composite variant to unified config - */ export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void { const unifiedConfig = loadOrCreateUnifiedConfig(); @@ -206,15 +188,13 @@ export function saveCompositeVariantUnified(name: string, config: CompositeVaria saveUnifiedConfig(unifiedConfig); } -/** - * Save variant to unified config - */ export function saveVariantUnified( name: string, provider: CLIProxyProvider, settingsPath: string, account?: string, - port?: number + port?: number, + target: TargetType = 'claude' ): void { const config = loadOrCreateUnifiedConfig(); @@ -234,20 +214,19 @@ export function saveVariantUnified( account, settings: settingsPath, port, + ...(target !== 'claude' && { target }), }; saveUnifiedConfig(config); } -/** - * Save variant to legacy JSON config - */ export function saveVariantLegacy( name: string, provider: string, settingsPath: string, account?: string, - port?: number + port?: number, + target: TargetType = 'claude' ): void { const configPath = getConfigPath(); @@ -262,7 +241,13 @@ export function saveVariantLegacy( config.cliproxy = {}; } - const variantConfig: { provider: string; settings: string; account?: string; port?: number } = { + const variantConfig: { + provider: string; + settings: string; + account?: string; + port?: number; + target?: TargetType; + } = { provider, settings: settingsPath, }; @@ -272,6 +257,9 @@ export function saveVariantLegacy( if (port) { variantConfig.port = port; } + if (target !== 'claude') { + variantConfig.target = target; + } config.cliproxy[name] = variantConfig; const tempPath = configPath + '.tmp'; @@ -279,9 +267,6 @@ export function saveVariantLegacy( fs.renameSync(tempPath, configPath); } -/** - * Remove variant from unified config - */ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null { const config = loadOrCreateUnifiedConfig(); @@ -299,6 +284,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu provider: composite.tiers[composite.default_tier].provider, settings: composite.settings, port: composite.port, + target: composite.target || 'claude', type: 'composite', default_tier: composite.default_tier, tiers: composite.tiers, @@ -309,12 +295,10 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu provider: singleVariant.provider, settings: singleVariant.settings, port: singleVariant.port, + target: singleVariant.target || 'claude', }; } -/** - * Remove variant from legacy JSON config - */ export function removeVariantFromLegacyConfig(name: string): VariantConfig | null { const configPath = getConfigPath(); @@ -329,7 +313,12 @@ export function removeVariantFromLegacyConfig(name: string): VariantConfig | nul return null; } - const variant = config.cliproxy[name] as { provider: string; settings: string; port?: number }; + const variant = config.cliproxy[name] as { + provider: string; + settings: string; + port?: number; + target?: TargetType; + }; delete config.cliproxy[name]; if (Object.keys(config.cliproxy).length === 0) { diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index a3471c13..fbb917a6 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types'; import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types'; +import type { TargetType } from '../../targets/target-adapter'; import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { DEFAULT_BACKEND } from '../platform-detector'; @@ -110,7 +111,8 @@ export function createVariant( name: string, provider: CLIProxyProfileName, model: string, - account?: string + account?: string, + target: TargetType = 'claude' ): VariantOperationResult { try { // Validate provider/backend compatibility (block kiro/ghcp on original backend) @@ -131,17 +133,25 @@ export function createVariant( provider as CLIProxyProvider, getRelativeSettingsPath(provider, name), account, - port + port, + target ); } else { settingsPath = createSettingsFile(name, provider, model, port); - saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account, port); + saveVariantLegacy( + name, + provider, + `~/.ccs/${path.basename(settingsPath)}`, + account, + port, + target + ); } return { success: true, settingsPath, - variant: { provider, model, account, port }, + variant: { provider, model, account, port, target }, }; } catch (error) { return { @@ -208,6 +218,7 @@ export interface UpdateVariantOptions { provider?: CLIProxyProfileName; account?: string; model?: string; + target?: TargetType; } /** @@ -233,6 +244,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari const providerChanged = updates.provider !== undefined && updates.provider !== existing.provider; + const existingTarget = existing.target || 'claude'; + const targetChanged = updates.target !== undefined && updates.target !== existingTarget; const hasModelUpdate = updates.model !== undefined && updates.model.trim().length > 0; if (providerChanged && !hasModelUpdate) { @@ -257,8 +270,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari } } - // Update config entry if provider or account changed - if (updates.provider !== undefined || updates.account !== undefined) { + // Update config entry if provider/account/target changed + if (updates.provider !== undefined || updates.account !== undefined || targetChanged) { const newProvider = updates.provider ?? existing.provider; // Validate provider/backend compatibility on provider change @@ -269,6 +282,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari } } const newAccount = updates.account !== undefined ? updates.account : existing.account; + const newTarget = updates.target ?? existingTarget; if (isUnifiedMode()) { saveVariantUnified( @@ -276,7 +290,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari newProvider as CLIProxyProvider, existing.settings || '', newAccount || undefined, - existing.port + existing.port, + newTarget ); } else { saveVariantLegacy( @@ -284,7 +299,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari newProvider, existing.settings || '', newAccount || undefined, - existing.port + existing.port, + newTarget ); } } @@ -297,6 +313,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari account: updates.account !== undefined ? updates.account : existing.account, port: existing.port, settings: existing.settings, + target: updates.target ?? existingTarget, }, }; } catch (error) { @@ -308,6 +325,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari export interface CreateCompositeVariantOptions { name: string; defaultTier: 'opus' | 'sonnet' | 'haiku'; + target?: TargetType; tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; @@ -329,7 +347,7 @@ export function createCompositeVariant( } try { - const { name, defaultTier, tiers } = options; + const { name, defaultTier, tiers, target = 'claude' } = options; const validationError = validateCompositeTiers(tiers, { defaultTier, @@ -361,6 +379,7 @@ export function createCompositeVariant( tiers, settings: settingsPath, port, + ...(target !== 'claude' && { target }), }; saveCompositeVariantUnified(name, compositeConfig); @@ -373,6 +392,7 @@ export function createCompositeVariant( default_tier: defaultTier, tiers, port, + target, }, }; } catch (error) { @@ -384,6 +404,7 @@ export function createCompositeVariant( export interface UpdateCompositeVariantOptions { defaultTier?: 'opus' | 'sonnet' | 'haiku'; tiers?: Partial>; + target?: TargetType; } /** @@ -418,6 +439,8 @@ export function updateCompositeVariant( }; const newDefaultTier = updates.defaultTier ?? existing.default_tier ?? 'sonnet'; + const existingTarget = existing.target || 'claude'; + const newTarget = updates.target ?? existingTarget; const validationError = validateCompositeTiers(mergedTiers, { defaultTier: newDefaultTier, requireAllTiers: true, @@ -455,6 +478,7 @@ export function updateCompositeVariant( tiers: mergedTiers, settings: settingsRef, port: existing.port, + ...(newTarget !== 'claude' && { target: newTarget }), }; saveCompositeVariantUnified(name, compositeConfig); @@ -468,6 +492,7 @@ export function updateCompositeVariant( tiers: mergedTiers, port: existing.port, settings: settingsRef, + target: newTarget, }, }; } catch (error) { diff --git a/src/cliproxy/sync/profile-mapper.ts b/src/cliproxy/sync/profile-mapper.ts index d9b8b995..40583fa4 100644 --- a/src/cliproxy/sync/profile-mapper.ts +++ b/src/cliproxy/sync/profile-mapper.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; import { listApiProfiles, isApiProfileConfigured } from '../../api/services/profile-reader'; import type { ClaudeKey } from '../management-api-types'; @@ -31,6 +32,15 @@ interface SettingsJson { env?: Record; } +function resolveProfileSettingsPath(settingsPath: string): string { + const normalized = settingsPath.replace(/\\/g, '/'); + if (normalized.startsWith('~/.ccs/')) { + return path.join(getCcsDir(), normalized.slice('~/.ccs/'.length)); + } + + return expandPath(settingsPath); +} + /** * Load syncable API profiles from CCS config. * Filters to only configured profiles (with real API keys). @@ -45,9 +55,14 @@ export function loadSyncableProfiles(): SyncableProfile[] { continue; } + // Local CLIProxy sync writes Claude-compatible entries only. + // Profiles pinned to non-claude targets are intentionally skipped. + if (profile.target !== 'claude') { + continue; + } + // Load settings.json for env vars - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile.name}.settings.json`); + const settingsPath = resolveProfileSettingsPath(profile.settingsPath); let env: Record | undefined; try { diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 495617d0..cdbb2f43 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -43,6 +43,7 @@ import { type ProviderPreset, } from '../api/services'; import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync'; +import type { TargetType } from '../targets/target-adapter'; import { extractOption, hasAnyFlag } from './arg-extractor'; interface ApiCommandArgs { @@ -51,13 +52,14 @@ interface ApiCommandArgs { apiKey?: string; model?: string; preset?: string; + target?: TargetType; force?: boolean; yes?: boolean; errors: string[]; } const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const; -const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset'] as const; +const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset', '--target'] as const; const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; const API_VALUE_FLAG_SET = new Set(API_VALUE_FLAGS); @@ -130,6 +132,14 @@ function extractPositionalArgs(args: string[]): string[] { return positionals; } +function parseTargetValue(value: string): TargetType | null { + const normalized = value.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + return null; +} + /** Parse command line arguments for api commands */ export function parseApiCommandArgs(args: string[]): ApiCommandArgs { const result: ApiCommandArgs = { @@ -184,6 +194,22 @@ export function parseApiCommandArgs(args: string[]): ApiCommandArgs { } ); + remaining = applyRepeatedOption( + remaining, + ['--target'], + (value) => { + const target = parseTargetValue(value); + if (!target) { + result.errors.push(`Invalid --target value "${value}". Use: claude or droid`); + return; + } + result.target = target; + }, + () => { + result.errors.push('Missing value for --target'); + } + ); + const positionalArgs = extractPositionalArgs(remaining); result.name = positionalArgs[0]; return result; @@ -383,12 +409,23 @@ async function handleCreate(args: string[]): Promise { sonnet: sonnetModel, haiku: haikuModel, }; + let resolvedTarget: TargetType = parsedArgs.target || 'claude'; + + if (!parsedArgs.target && !parsedArgs.yes) { + const useDroidByDefault = await InteractivePrompt.confirm( + 'Set default target to Factory Droid for this profile?', + { default: false } + ); + if (useDroidByDefault) { + resolvedTarget = 'droid'; + } + } // Create profile console.log(''); console.log(info('Creating API profile...')); - const result = createApiProfile(name, baseUrl, apiKey, models); + const result = createApiProfile(name, baseUrl, apiKey, models, resolvedTarget); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); @@ -411,7 +448,8 @@ async function handleCreate(args: string[]): Promise { `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + `Settings: ${result.settingsFile}\n` + `Base URL: ${baseUrl}\n` + - `Model: ${model}`; + `Model: ${model}\n` + + `Target: ${resolvedTarget}`; if (hasCustomMapping) { infoMsg += @@ -424,7 +462,24 @@ async function handleCreate(args: string[]): Promise { console.log(infoBox(infoMsg, 'API Profile Created')); console.log(''); console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } console.log(''); console.log(header('Edit Settings')); console.log(` ${dim('To modify env vars later:')}`); @@ -453,13 +508,13 @@ async function handleList(): Promise { // Build table data const rows: string[][] = profiles.map((p) => { const status = p.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning'); - return [p.name, p.settingsPath, status]; + return [p.name, p.target, p.settingsPath, status]; }); - const colWidths = isUsingUnifiedConfig() ? [15, 20, 10] : [15, 35, 10]; + const colWidths = isUsingUnifiedConfig() ? [15, 10, 20, 10] : [15, 10, 35, 10]; console.log( table(rows, { - head: ['API', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'], + head: ['API', 'Target', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'], colWidths, }) ); @@ -468,11 +523,11 @@ async function handleList(): Promise { // Show CLIProxy variants if any if (variants.length > 0) { console.log(subheader('CLIProxy Variants')); - const cliproxyRows = variants.map((v) => [v.name, v.provider, v.settings]); + const cliproxyRows = variants.map((v) => [v.name, v.provider, v.target, v.settings]); console.log( table(cliproxyRows, { - head: ['Variant', 'Provider', 'Settings'], - colWidths: [15, 15, 30], + head: ['Variant', 'Provider', 'Target', 'Settings'], + colWidths: [15, 12, 10, 28], }) ); console.log(''); @@ -582,6 +637,9 @@ async function showHelp(): Promise { console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); console.log(` ${color('--model ', 'command')} Default model (create)`); + console.log( + ` ${color('--target ', 'command')} Default target: claude or droid (create)` + ); console.log(` ${color('--force', 'command')} Overwrite existing (create)`); console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); console.log(''); @@ -605,6 +663,7 @@ async function showHelp(): Promise { console.log(''); console.log(` ${dim('# Create with name')}`); console.log(` ${color('ccs api create myapi', 'command')}`); + console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`); console.log(''); console.log(` ${dim('# Remove API profile')}`); console.log(` ${color('ccs api remove myapi', 'command')}`); diff --git a/src/commands/cliproxy/auth-subcommand.ts b/src/commands/cliproxy/auth-subcommand.ts index 76ab6730..b856f278 100644 --- a/src/commands/cliproxy/auth-subcommand.ts +++ b/src/commands/cliproxy/auth-subcommand.ts @@ -45,10 +45,13 @@ export async function handleList(): Promise { const variant = variants[name]; const providerDisplay = variant.type === 'composite' ? 'composite' : variant.provider; const portStr = variant.port ? String(variant.port) : '-'; - return [name, providerDisplay, portStr, variant.settings || '-']; + return [name, providerDisplay, variant.target || 'claude', portStr, variant.settings || '-']; }); console.log( - table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] }) + table(rows, { + head: ['Variant', 'Provider', 'Target', 'Port', 'Settings'], + colWidths: [15, 12, 10, 8, 24], + }) ); console.log(''); console.log(dim(`Total: ${variantNames.length} custom variant(s)`)); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 01d5bd13..cc3e60a2 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -81,6 +81,7 @@ export async function showHelp(): Promise { 'Options:', [ ['--backend ', 'Use specific backend: original | plus (default: from config)'], + ['--target ', 'Default target for created/edited variants: claude | droid'], ['--verbose, -v', 'Show detailed quota fetch diagnostics'], ], ], diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index fa395445..b3a00056 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -12,6 +12,7 @@ import { triggerOAuth } from '../../cliproxy/auth/oauth-handler'; import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog'; import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types'; +import type { TargetType } from '../../targets/target-adapter'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { initUI, header, color, ok, fail, warn, info, infoBox, dim } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; @@ -32,28 +33,66 @@ interface CliproxyProfileArgs { provider?: CLIProxyProfileName; model?: string; account?: string; + target?: TargetType; force?: boolean; yes?: boolean; composite?: boolean; + errors: string[]; } -function parseProfileArgs(args: string[]): CliproxyProfileArgs { - const result: CliproxyProfileArgs = {}; +function parseTargetValue(rawValue: string): TargetType | null { + const normalized = rawValue.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + return null; +} + +export function parseProfileArgs(args: string[]): CliproxyProfileArgs { + const result: CliproxyProfileArgs = { errors: [] }; + let parseOptions = true; + for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === '--provider' && args[i + 1]) { + if (parseOptions && arg === '--') { + parseOptions = false; + continue; + } + + if (parseOptions && arg === '--provider' && args[i + 1]) { result.provider = args[++i] as CLIProxyProfileName; - } else if (arg === '--model' && args[i + 1]) { + } else if (parseOptions && arg === '--model' && args[i + 1]) { result.model = args[++i]; - } else if (arg === '--account' && args[i + 1]) { + } else if (parseOptions && arg === '--account' && args[i + 1]) { result.account = args[++i]; - } else if (arg === '--force') { + } else if (parseOptions && arg === '--target') { + const rawValue = args[i + 1]; + if (!rawValue || rawValue.startsWith('-')) { + result.errors.push('Missing value for --target'); + } else { + i += 1; + const parsedTarget = parseTargetValue(rawValue); + if (!parsedTarget) { + result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`); + } else { + result.target = parsedTarget; + } + } + } else if (parseOptions && arg.startsWith('--target=')) { + const rawValue = arg.slice('--target='.length); + const parsedTarget = parseTargetValue(rawValue); + if (!parsedTarget) { + result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`); + } else { + result.target = parsedTarget; + } + } else if (parseOptions && arg === '--force') { result.force = true; - } else if (arg === '--yes' || arg === '-y') { + } else if (parseOptions && (arg === '--yes' || arg === '-y')) { result.yes = true; - } else if (arg === '--composite') { + } else if (parseOptions && arg === '--composite') { result.composite = true; - } else if (!arg.startsWith('-') && !result.name) { + } else if ((!parseOptions || !arg.startsWith('-')) && !result.name) { result.name = arg; } } @@ -145,6 +184,11 @@ export async function handleCreate( ): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exitCode = 1; + return; + } console.log(header(`Create ${getBackendLabel(backend)} Variant`)); console.log(''); @@ -168,6 +212,17 @@ export async function handleCreate( process.exit(1); } + let resolvedTarget: TargetType = parsedArgs.target || 'claude'; + if (!parsedArgs.target && !parsedArgs.yes) { + const useDroidByDefault = await InteractivePrompt.confirm( + 'Set default target to Factory Droid for this variant?', + { default: false } + ); + if (useDroidByDefault) { + resolvedTarget = 'droid'; + } + } + // Composite mode: select provider+model per tier if (parsedArgs.composite) { console.log(info('Composite variant — select provider and model for each tier')); @@ -203,6 +258,7 @@ export async function handleCreate( const result = createCompositeVariant({ name, defaultTier, + target: resolvedTarget, tiers: { opus, sonnet, haiku }, }); @@ -217,7 +273,8 @@ export async function handleCreate( ? `Opus: ${tiers.opus.provider} / ${tiers.opus.model}\n` + `Sonnet: ${tiers.sonnet.provider} / ${tiers.sonnet.model}\n` + `Haiku: ${tiers.haiku.provider} / ${tiers.haiku.model}\n` + - `Default: ${defaultTier}` + `Default: ${defaultTier}\n` + + `Target: ${resolvedTarget}` : ''; const portInfo = result.variant?.port ? `\nPort: ${result.variant.port}` : ''; console.log( @@ -228,7 +285,24 @@ export async function handleCreate( ); console.log(''); console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } console.log(''); return; } @@ -350,7 +424,7 @@ export async function handleCreate( // Create variant console.log(''); console.log(info(`Creating ${getBackendLabel(backend)} variant...`)); - const result = createVariant(name, provider, model, account); + const result = createVariant(name, provider, model, account, resolvedTarget); if (!result.success) { console.log(fail(`Failed to create variant: ${result.error}`)); @@ -367,13 +441,30 @@ export async function handleCreate( const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : ''; console.log( infoBox( - `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`, + `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`, configType ) ); console.log(''); console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } console.log(''); console.log(dim('To change model later:')); console.log(` ${color(`ccs ${name} --config`, 'command')}`); @@ -383,6 +474,11 @@ export async function handleCreate( export async function handleRemove(args: string[]): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exitCode = 1; + return; + } const variants = listVariants(); const variantNames = Object.keys(variants); @@ -435,6 +531,7 @@ export async function handleRemove(args: string[]): Promise { if (variant.port) { console.log(` Port: ${variant.port}`); } + console.log(` Target: ${variant.target || 'claude'}`); console.log(` Settings: ${variant.settings || '-'}`); console.log(''); @@ -461,6 +558,11 @@ export async function handleEdit( ): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exitCode = 1; + return; + } const variants = listVariants(); const variantNames = Object.keys(variants); @@ -501,12 +603,14 @@ export async function handleEdit( // If not composite, use existing updateVariant() flow (interactive prompts) if (variant.type !== 'composite') { + const currentTarget: TargetType = variant.target || 'claude'; console.log(header(`Edit Variant: ${name}`)); console.log(''); console.log(`Current provider: ${variant.provider}`); if (variant.model) { console.log(`Current model: ${variant.model}`); } + console.log(`Current target: ${currentTarget}`); console.log(''); const changeProvider = await InteractivePrompt.confirm('Change provider?', { default: false }); @@ -552,6 +656,22 @@ export async function handleEdit( } } + let newTarget: TargetType | undefined = parsedArgs.target; + if (!parsedArgs.target) { + const changeTarget = await InteractivePrompt.confirm('Change default target?', { + default: false, + }); + if (changeTarget) { + const targetOptions = [ + { id: 'claude', label: 'Claude Code' }, + { id: 'droid', label: 'Factory Droid' }, + ]; + newTarget = (await InteractivePrompt.selectFromList('Select target:', targetOptions, { + defaultIndex: currentTarget === 'droid' ? 1 : 0, + })) as TargetType; + } + } + console.log(''); console.log(info(`Updating ${getBackendLabel(backend)} variant...`)); // Use existing updateVariant from variant-service for single-provider variants @@ -559,6 +679,7 @@ export async function handleEdit( const result = updateVariant(name, { provider: newProvider, model: changeModel ? newModel : undefined, + target: newTarget, }); if (!result.success) { @@ -566,13 +687,35 @@ export async function handleEdit( process.exit(1); } + const resolvedTarget = result.variant?.target || currentTarget; console.log(''); console.log(ok(`Variant updated: ${name}`)); console.log(''); + console.log(header('Usage')); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } + console.log(''); return; } // Composite variant edit flow + const compositeCurrentTarget: TargetType = variant.target || 'claude'; console.log(header(`Edit Composite Variant: ${name}`)); console.log(''); if (!variant.tiers) { @@ -585,6 +728,7 @@ export async function handleEdit( console.log(` Sonnet: ${variant.tiers.sonnet.provider} / ${variant.tiers.sonnet.model}`); console.log(` Haiku: ${variant.tiers.haiku.provider} / ${variant.tiers.haiku.model}`); console.log(` Default: ${variant.default_tier}`); + console.log(` Target: ${compositeCurrentTarget}`); console.log(''); const verbose = args.includes('--verbose'); @@ -634,11 +778,32 @@ export async function handleEdit( )) as 'opus' | 'sonnet' | 'haiku'; } + let newCompositeTarget: TargetType | undefined = parsedArgs.target; + if (!parsedArgs.target) { + const changeTarget = await InteractivePrompt.confirm('Change default target?', { + default: false, + }); + if (changeTarget) { + const targetOptions = [ + { id: 'claude', label: 'Claude Code' }, + { id: 'droid', label: 'Factory Droid' }, + ]; + newCompositeTarget = (await InteractivePrompt.selectFromList( + 'Select target:', + targetOptions, + { + defaultIndex: compositeCurrentTarget === 'droid' ? 1 : 0, + } + )) as TargetType; + } + } + console.log(''); console.log(info(`Updating composite ${getBackendLabel(backend)} variant...`)); const result = updateCompositeVariant(name, { tiers: updatedTiers, defaultTier: changeDefault ? newDefaultTier : undefined, + target: newCompositeTarget, }); if (!result.success) { @@ -653,7 +818,8 @@ export async function handleEdit( `Opus: ${finalVariant.tiers.opus.provider} / ${finalVariant.tiers.opus.model}\n` + `Sonnet: ${finalVariant.tiers.sonnet.provider} / ${finalVariant.tiers.sonnet.model}\n` + `Haiku: ${finalVariant.tiers.haiku.provider} / ${finalVariant.tiers.haiku.model}\n` + - `Default: ${finalVariant.default_tier}`; + `Default: ${finalVariant.default_tier}\n` + + `Target: ${finalVariant.target || compositeCurrentTarget}`; const portInfo = finalVariant.port ? `\nPort: ${finalVariant.port}` : ''; console.log( infoBox( diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 1fdc3d63..4d028e97 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -326,6 +326,12 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); printSubSection('Multi-Target', [ ['ccs glm --target droid', 'Run GLM profile on Droid CLI'], ['ccsd glm', 'Same as above (alias)'], + ['ccsd codex', 'Run built-in CLIProxy Codex profile on Droid'], + ['ccsd agy', 'Run built-in CLIProxy Antigravity profile on Droid'], + [ + 'ccs cliproxy create my-codex --provider codex --target droid', + 'Create CLIProxy variant with Droid as default target', + ], ['ccs glm', 'Run GLM profile on Claude Code (default)'], ]); @@ -347,7 +353,9 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['', ''], // Spacer ['ccs cliproxy pause

', 'Pause account from rotation'], ['ccs cliproxy resume

', 'Resume paused account'], - ['ccs cliproxy status [provider]', 'Show quota/tier/pause status'], + ['ccs cliproxy status', 'Show CLIProxy process status'], + ['ccs cliproxy quota', 'Show quota/tier/pause status for all providers'], + ['ccs cliproxy quota --provider ', 'Show quota/tier/pause status for one provider'], ]); // CLI Proxy configuration flags (new) diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index bb3a8008..1a89ca20 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -53,9 +53,9 @@ export class DroidAdapter implements TargetAdapter { } buildArgs(profile: string, userArgs: string[]): string[] { - if (!/^[a-zA-Z0-9_-]+$/.test(profile)) { + if (!/^[a-zA-Z0-9._-]+$/.test(profile)) { throw new Error( - `Invalid profile name "${profile}" for Droid target: only alphanumeric, underscore, hyphen allowed` + `Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed` ); } return ['-m', `custom:ccs-${profile}`, ...userArgs]; @@ -154,10 +154,8 @@ export class DroidAdapter implements TargetAdapter { }); } - /** - * Droid currently supports direct settings-based and default flows only. - */ + /** Droid supports settings/default and CLIProxy-executed profile flows. */ supportsProfileType(profileType: ProfileType): boolean { - return profileType === 'settings' || profileType === 'default'; + return profileType === 'settings' || profileType === 'default' || profileType === 'cliproxy'; } } diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 3483a2d3..8e61eec4 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -20,16 +20,16 @@ const LOCK_RETRY_MAX_MS = 1000; /** * Validate profile name to prevent filesystem/security issues. - * Only alphanumeric, underscore, hyphen allowed. + * Only alphanumeric, dot, underscore, hyphen allowed. */ function isValidProfileName(profile: string): boolean { - return !!profile && /^[a-zA-Z0-9_-]+$/.test(profile); + return !!profile && /^[a-zA-Z0-9._-]+$/.test(profile); } function validateProfileName(profile: string): void { if (!isValidProfileName(profile)) { throw new Error( - `Invalid profile name "${profile}": must contain only alphanumeric characters, underscores, or hyphens` + `Invalid profile name "${profile}": must contain only alphanumeric characters, dots, underscores, or hyphens` ); } } diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index edc23481..b7a811f4 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -48,9 +48,13 @@ interface ParsedTargetFlags { cleanedArgs: string[]; } +function isValidTarget(target: unknown): target is TargetType { + return typeof target === 'string' && VALID_TARGETS.has(target as TargetType); +} + function normalizeTargetValue(value: string): TargetType { const normalized = value.toLowerCase(); - if (VALID_TARGETS.has(normalized)) { + if (isValidTarget(normalized)) { return normalized as TargetType; } @@ -120,8 +124,8 @@ export function resolveTargetType( } // 2. Check per-profile config - if (profileConfig?.target) { - return profileConfig.target; + if (profileConfig?.target !== undefined) { + return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude'; } // 3. Check argv[0] (busybox pattern) diff --git a/src/types/config.ts b/src/types/config.ts index 2565bd79..c385eef9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -4,6 +4,7 @@ */ import type { CLIProxyProvider } from '../cliproxy/types'; +import type { TargetType } from '../targets/target-adapter'; /** * Profile configuration mapping @@ -27,6 +28,8 @@ export interface CLIProxyVariantConfig { account?: string; /** Unique port for variant isolation (8318-8417) */ port?: number; + /** Target CLI to use for this variant (default: claude) */ + target?: TargetType; } /** @@ -44,6 +47,8 @@ export interface CLIProxyVariantsConfig { export interface Config { /** Settings-based profiles (GLM, Kimi, etc.) */ profiles: ProfilesConfig; + /** Per-profile CLI target overrides (legacy mode) */ + profile_targets?: Record; /** User-defined CLIProxy profile variants (optional) */ cliproxy?: CLIProxyVariantsConfig; } diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 37a895dc..1b1a209f 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -7,12 +7,34 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; -import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer'; +import { + createApiProfile, + removeApiProfile, + updateApiProfileTarget, +} from '../../api/services/profile-writer'; import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader'; +import type { TargetType } from '../../targets/target-adapter'; import { updateSettingsFile } from './route-helpers'; const router = Router(); +export function parseTarget(rawTarget: unknown): TargetType | null { + if (rawTarget === undefined || rawTarget === null || rawTarget === '') { + return null; + } + + if (typeof rawTarget !== 'string') { + return null; + } + + const normalized = rawTarget.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + + return null; +} + // ==================== Profile CRUD ==================== /** @@ -26,6 +48,7 @@ router.get('/', (_req: Request, res: Response): void => { name: p.name, settingsPath: p.settingsPath, configured: p.isConfigured, + target: p.target, })); res.json({ profiles }); } catch (error) { @@ -37,7 +60,13 @@ router.get('/', (_req: Request, res: Response): void => { * POST /api/profiles - Create new profile */ router.post('/', (req: Request, res: Response): void => { - const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; + const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + + const parsedTarget = parseTarget(target); + if (target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } if (!name || !baseUrl || !apiKey) { res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' }); @@ -60,19 +89,29 @@ router.post('/', (req: Request, res: Response): void => { } // Create profile using unified-config-aware service - const result = createApiProfile(name, baseUrl, apiKey, { - default: model || '', - opus: opusModel || model || '', - sonnet: sonnetModel || model || '', - haiku: haikuModel || model || '', - }); + const result = createApiProfile( + name, + baseUrl, + apiKey, + { + default: model || '', + opus: opusModel || model || '', + sonnet: sonnetModel || model || '', + haiku: haikuModel || model || '', + }, + parsedTarget || 'claude' + ); if (!result.success) { res.status(500).json({ error: result.error || 'Failed to create profile' }); return; } - res.status(201).json({ name, settingsPath: result.settingsFile }); + res.status(201).json({ + name, + settingsPath: result.settingsFile, + target: parsedTarget || 'claude', + }); }); /** @@ -80,7 +119,13 @@ router.post('/', (req: Request, res: Response): void => { */ router.put('/:name', (req: Request, res: Response): void => { const { name } = req.params; - const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; + const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + + const parsedTarget = parseTarget(target); + if (target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } // Check if profile exists (uses unified config when available) if (!apiProfileExists(name)) { @@ -99,8 +144,37 @@ router.put('/:name', (req: Request, res: Response): void => { } try { - updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel }); - res.json({ name, updated: true }); + const hasSettingsUpdates = + baseUrl !== undefined || + apiKey !== undefined || + model !== undefined || + opusModel !== undefined || + sonnetModel !== undefined || + haikuModel !== undefined; + const hasTargetUpdate = target !== undefined; + + if (!hasSettingsUpdates && !hasTargetUpdate) { + res.status(400).json({ error: 'No updates provided' }); + return; + } + + if (hasSettingsUpdates) { + updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel }); + } + + if (hasTargetUpdate && parsedTarget) { + const targetUpdate = updateApiProfileTarget(name, parsedTarget); + if (!targetUpdate.success) { + res.status(500).json({ error: targetUpdate.error || 'Failed to update target' }); + return; + } + } + + res.json({ + name, + updated: true, + ...(hasTargetUpdate && parsedTarget && { target: parsedTarget }), + }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index c21429ba..65a4c07b 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -7,6 +7,7 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import type { CLIProxyProvider } from '../../cliproxy/types'; +import type { TargetType } from '../../targets/target-adapter'; import { createVariant, removeVariant, @@ -23,6 +24,23 @@ import { const router = Router(); +export function parseTarget(rawTarget: unknown): TargetType | null { + if (rawTarget === undefined || rawTarget === null || rawTarget === '') { + return null; + } + + if (typeof rawTarget !== 'string') { + return null; + } + + const normalized = rawTarget.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + + return null; +} + /** * GET /api/cliproxy - List cliproxy variants * Uses variant-service for consistent behavior with CLI @@ -36,6 +54,7 @@ router.get('/', (_req: Request, res: Response) => { account: variant.account || 'default', port: variant.port, // Include port for port isolation model: variant.model, + target: variant.target || 'claude', type: variant.type, default_tier: variant.default_tier, tiers: variant.tiers, @@ -50,6 +69,12 @@ router.get('/', (_req: Request, res: Response) => { */ router.post('/', (req: Request, res: Response): void => { const { name, provider, model, account, type, default_tier, tiers } = req.body; + const parsedTarget = parseTarget(req.body.target); + + if (req.body.target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } if (!name) { res.status(400).json({ error: 'Missing required field: name' }); @@ -91,7 +116,12 @@ router.post('/', (req: Request, res: Response): void => { let result; try { - result = createCompositeVariant({ name, defaultTier: default_tier, tiers }); + result = createCompositeVariant({ + name, + defaultTier: default_tier, + target: parsedTarget || 'claude', + tiers, + }); } catch (error) { res.status(400).json({ error: (error as Error).message }); return; @@ -109,6 +139,7 @@ router.post('/', (req: Request, res: Response): void => { tiers, settings: result.settingsPath, port: result.variant?.port, + target: result.variant?.target || 'claude', }); return; } @@ -126,7 +157,13 @@ router.post('/', (req: Request, res: Response): void => { } // Use variant-service for proper port allocation - const result = createVariant(name, provider as CLIProxyProvider, model, account); + const result = createVariant( + name, + provider as CLIProxyProvider, + model, + account, + parsedTarget || 'claude' + ); if (!result.success) { res.status(409).json({ error: result.error }); @@ -140,6 +177,7 @@ router.post('/', (req: Request, res: Response): void => { account: account || 'default', port: result.variant?.port, model: result.variant?.model, + target: result.variant?.target || 'claude', }); }); @@ -154,6 +192,12 @@ router.put('/:name', (req: Request, res: Response): void => { try { const { name } = req.params; const { provider, account, model, default_tier, tiers } = req.body; + const parsedTarget = parseTarget(req.body.target); + + if (req.body.target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } // Check if variant is composite - use updateCompositeVariant if so const variants = listVariants(); @@ -165,8 +209,8 @@ router.put('/:name', (req: Request, res: Response): void => { } if (existing.type === 'composite') { - if (!default_tier && !tiers) { - res.status(400).json({ error: 'Must provide at least default_tier or tiers' }); + if (!default_tier && !tiers && req.body.target === undefined) { + res.status(400).json({ error: 'Must provide at least default_tier, tiers, or target' }); return; } @@ -189,7 +233,11 @@ router.put('/:name', (req: Request, res: Response): void => { } } - const result = updateCompositeVariant(name, { defaultTier: default_tier, tiers }); + const result = updateCompositeVariant(name, { + defaultTier: default_tier, + tiers, + target: req.body.target !== undefined && parsedTarget ? parsedTarget : undefined, + }); if (!result.success) { const status = result.error?.includes('not found') ? 404 : 400; @@ -207,13 +255,19 @@ router.put('/:name', (req: Request, res: Response): void => { tiers: persisted?.tiers, settings: persisted?.settings, port: persisted?.port, + target: persisted?.target || 'claude', updated: true, }); return; } // Use variant-service for proper update handling (single provider) - const result = updateVariant(name, { provider, account, model }); + const result = updateVariant(name, { + provider, + account, + model, + target: req.body.target !== undefined && parsedTarget ? parsedTarget : undefined, + }); if (!result.success) { const status = result.error?.includes('not found') ? 404 : 400; @@ -227,6 +281,7 @@ router.put('/:name', (req: Request, res: Response): void => { account: result.variant?.account || 'default', settings: result.variant?.settings, port: result.variant?.port, + target: result.variant?.target || 'claude', updated: true, }); } catch (error) { diff --git a/tests/unit/cliproxy/profile-mapper.test.ts b/tests/unit/cliproxy/profile-mapper.test.ts index 55ccb4e6..c95f2c71 100644 --- a/tests/unit/cliproxy/profile-mapper.test.ts +++ b/tests/unit/cliproxy/profile-mapper.test.ts @@ -4,9 +4,11 @@ */ import * as assert from 'assert'; +const fs = require('fs'); describe('Profile Mapper', () => { const profileMapper = require('../../../dist/cliproxy/sync/profile-mapper'); + const profileReader = require('../../../dist/api/services/profile-reader'); describe('mapProfileToClaudeKey', () => { it('returns null when env is missing', () => { @@ -86,6 +88,100 @@ describe('Profile Mapper', () => { const result = profileMapper.loadSyncableProfiles(); assert.ok(Array.isArray(result)); }); + + it('uses profile-provided settingsPath instead of reconstructing from profile name', () => { + const originalListApiProfiles = profileReader.listApiProfiles; + const originalExistsSync = fs.existsSync; + const originalReadFileSync = fs.readFileSync; + + const customSettingsPath = '/tmp/custom-sync-path.settings.json'; + const readPaths: string[] = []; + + try { + profileReader.listApiProfiles = () => ({ + profiles: [ + { + name: 'glm', + settingsPath: customSettingsPath, + isConfigured: true, + configSource: 'legacy', + target: 'claude', + }, + ], + variants: [], + }); + + fs.existsSync = (filePath: string) => filePath === customSettingsPath; + fs.readFileSync = (filePath: string) => { + readPaths.push(filePath); + return JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-test-key', + }, + }); + }; + + const result = profileMapper.loadSyncableProfiles(); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].settingsPath, customSettingsPath); + assert.deepStrictEqual(readPaths, [customSettingsPath]); + } finally { + profileReader.listApiProfiles = originalListApiProfiles; + fs.existsSync = originalExistsSync; + fs.readFileSync = originalReadFileSync; + } + }); + + it('skips profiles pinned to non-claude targets during local sync mapping', () => { + const originalListApiProfiles = profileReader.listApiProfiles; + const originalExistsSync = fs.existsSync; + const originalReadFileSync = fs.readFileSync; + + const claudePath = '/tmp/claude-target.settings.json'; + const droidPath = '/tmp/droid-target.settings.json'; + const readPaths: string[] = []; + + try { + profileReader.listApiProfiles = () => ({ + profiles: [ + { + name: 'claude-profile', + settingsPath: claudePath, + isConfigured: true, + configSource: 'legacy', + target: 'claude', + }, + { + name: 'droid-profile', + settingsPath: droidPath, + isConfigured: true, + configSource: 'legacy', + target: 'droid', + }, + ], + variants: [], + }); + + fs.existsSync = (filePath: string) => filePath === claudePath || filePath === droidPath; + fs.readFileSync = (filePath: string) => { + readPaths.push(filePath); + return JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-test-key', + }, + }); + }; + + const result = profileMapper.loadSyncableProfiles(); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'claude-profile'); + assert.deepStrictEqual(readPaths, [claudePath]); + } finally { + profileReader.listApiProfiles = originalListApiProfiles; + fs.existsSync = originalExistsSync; + fs.readFileSync = originalReadFileSync; + } + }); }); describe('generateSyncPayload', () => { diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index b8a0dc82..b73e1e08 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -35,4 +35,49 @@ describe('api-command arg parser', () => { expect(parsed.yes).toBe(true); expect(parsed.name).toBe('-my-api'); }); + + test('parses --target for default profile target', () => { + const parsed = parseApiCommandArgs(['my-api', '--target', 'droid']); + + expect(parsed.name).toBe('my-api'); + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); + + test('parses --target=value for default profile target', () => { + const parsed = parseApiCommandArgs(['my-api', '--target=droid']); + + expect(parsed.name).toBe('my-api'); + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); + + test('validates invalid --target values', () => { + const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Invalid --target value "invalid-target". Use: claude or droid']); + }); + + test('collects missing-value error for --target with no value', () => { + const parsed = parseApiCommandArgs(['my-api', '--target']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Missing value for --target']); + }); + + test('treats empty --target=value as missing value', () => { + const parsed = parseApiCommandArgs(['my-api', '--target=']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Missing value for --target']); + }); + + test('uses last --target value when repeated', () => { + const parsed = parseApiCommandArgs(['my-api', '--target', 'claude', '--target=droid']); + + expect(parsed.name).toBe('my-api'); + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); }); diff --git a/tests/unit/commands/cliproxy-variant-args.test.ts b/tests/unit/commands/cliproxy-variant-args.test.ts new file mode 100644 index 00000000..1d3ad82e --- /dev/null +++ b/tests/unit/commands/cliproxy-variant-args.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseProfileArgs } from '../../../src/commands/cliproxy/variant-subcommand'; + +describe('cliproxy variant arg parser', () => { + test('parses --target value form', () => { + const parsed = parseProfileArgs(['variant-a', '--target', 'droid']); + + expect(parsed.name).toBe('variant-a'); + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); + + test('parses --target=value form', () => { + const parsed = parseProfileArgs(['variant-a', '--target=droid']); + + expect(parsed.name).toBe('variant-a'); + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); + + test('collects missing value error for --target with no value', () => { + const parsed = parseProfileArgs(['variant-a', '--target']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Missing value for --target']); + }); + + test('uses last --target value when repeated', () => { + const parsed = parseProfileArgs(['variant-a', '--target', 'claude', '--target=droid']); + + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); + + test('supports option terminator for variant names that start with dash', () => { + const parsed = parseProfileArgs(['--yes', '--', '-variant-a']); + + expect(parsed.yes).toBe(true); + expect(parsed.name).toBe('-variant-a'); + expect(parsed.errors).toEqual([]); + }); + + test('does not parse flags after option terminator', () => { + const parsed = parseProfileArgs(['--', '--target', 'droid']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.name).toBe('--target'); + expect(parsed.errors).toEqual([]); + }); +}); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts new file mode 100644 index 00000000..2aebfc8b --- /dev/null +++ b/tests/unit/commands/help-command-parity.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test } from 'bun:test'; + +import { handleHelpCommand } from '../../../src/commands/help-command'; + +function stripAnsi(input: string): string { + return input.replace(/\u001b\[[0-9;]*m/g, ''); +} + +describe('help command parity', () => { + const originalLog = console.log; + + afterEach(() => { + console.log = originalLog; + }); + + test('root help documents cliproxy provider filter under quota command', async () => { + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((arg) => String(arg)).join(' ')); + }; + + await handleHelpCommand(); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('ccs cliproxy status [provider]')).toBe(false); + expect(rendered.includes('ccs cliproxy status')).toBe(true); + expect(rendered.includes('ccs cliproxy quota --provider ')).toBe(true); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index d6485d49..2a7e7924 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -120,8 +120,8 @@ describe('DroidAdapter', () => { expect(adapter.supportsProfileType('default')).toBe(true); }); - it('should NOT support cliproxy and copilot profile types', () => { - expect(adapter.supportsProfileType('cliproxy')).toBe(false); + it('should support cliproxy and NOT support copilot profile type', () => { + expect(adapter.supportsProfileType('cliproxy')).toBe(true); expect(adapter.supportsProfileType('copilot')).toBe(false); }); diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index a4082cdf..9841b9ce 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -37,6 +37,11 @@ describe('resolveTargetType', () => { expect(resolveTargetType([], { target: 'droid' })).toBe('droid'); }); + it('should fallback to claude when persisted profile target is invalid', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType([], { target: 'invalid-target' as never })).toBe('claude'); + }); + it('should prioritize --target flag over profile config', () => { process.argv = ['node', 'ccs']; expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude'); diff --git a/tests/unit/web-server/target-parse-routes.test.ts b/tests/unit/web-server/target-parse-routes.test.ts new file mode 100644 index 00000000..2852a749 --- /dev/null +++ b/tests/unit/web-server/target-parse-routes.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'bun:test'; + +import { parseTarget as parseProfileTarget } from '../../../src/web-server/routes/profile-routes'; +import { parseTarget as parseVariantTarget } from '../../../src/web-server/routes/variant-routes'; + +describe('route target parsing', () => { + it('accepts valid target values', () => { + expect(parseProfileTarget('claude')).toBe('claude'); + expect(parseProfileTarget('DROID')).toBe('droid'); + expect(parseVariantTarget(' claude ')).toBe('claude'); + expect(parseVariantTarget('droid')).toBe('droid'); + }); + + it('returns null for invalid target values', () => { + expect(parseProfileTarget('glm')).toBeNull(); + expect(parseProfileTarget('')).toBeNull(); + expect(parseVariantTarget('factory')).toBeNull(); + expect(parseVariantTarget(' ')).toBeNull(); + }); + + it('returns null for non-string values', () => { + expect(parseProfileTarget(undefined)).toBeNull(); + expect(parseProfileTarget(null)).toBeNull(); + expect(parseVariantTarget(123)).toBeNull(); + expect(parseVariantTarget({ target: 'claude' })).toBeNull(); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 66ff5a40..4a823ae4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -35,6 +35,7 @@ const SettingsPage = lazy(() => ); const HealthPage = lazy(() => import('@/pages/health').then((m) => ({ default: m.HealthPage }))); const SharedPage = lazy(() => import('@/pages/shared').then((m) => ({ default: m.SharedPage }))); +const UpdatesPage = lazy(() => import('@/pages/updates').then((m) => ({ default: m.UpdatesPage }))); // Loading fallback for lazy components function PageLoader() { @@ -68,6 +69,14 @@ export default function App() { } /> + }> + + + } + /> ({ resolver: zodResolver(singleProviderSchema), + defaultValues: { target: 'claude' }, }); const compositeForm = useForm({ resolver: zodResolver(compositeSchema), defaultValues: { default_tier: 'opus', + target: 'claude', tiers: { opus: { provider: 'gemini', model: '' }, sonnet: { provider: 'gemini', model: '' }, @@ -107,6 +111,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { await createMutation.mutateAsync({ name: data.name, provider: data.tiers[data.default_tier].provider, + target: data.target, type: 'composite', default_tier: data.default_tier, tiers: data.tiers, @@ -200,6 +205,18 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { +

+
+
+ + +
+
+
+ + +
+
+
+ + +
+
@@ -74,6 +88,18 @@ export function ProviderInfoTab({ provider, displayName, data, authStatus }: Pro

Quick Usage

+ + diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 0c154ca6..e470dcbf 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -2,7 +2,7 @@ * Type definitions for ProviderEditor components */ -import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; +import type { AuthStatus, OAuthAccount, CliTarget } from '@/lib/api-client'; import type { ProviderCatalog } from '../provider-model-selector'; export interface SettingsResponse { @@ -27,6 +27,8 @@ export interface ProviderEditorProps { isRemoteMode?: boolean; /** Port number for variant (for display in header) */ port?: number; + /** Default execution target for this profile/variant */ + defaultTarget?: CliTarget; onAddAccount: () => void; onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index aaa04a7d..47fcc1fa 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -170,38 +170,33 @@ export function AppSidebar() { defaultOpen={isParentActive(item.children) || isRouteActive(item.path)} className="group/collapsible" > - - {/* Click navigates to overview AND opens submenu */} - - navigate(item.path)} - > - {renderMenuIcon(item)} - - {getItemLabel(item)} - - - - - - - {item.children.map((child) => ( - - - - {child.label} - - - - ))} - - - + {/* Click navigates to overview AND opens submenu */} + + navigate(item.path)} + > + {renderMenuIcon(item)} + + {getItemLabel(item)} + + + + + + + {item.children.map((child) => ( + + + + {child.label} + + + + ))} + + ) : ( - +
diff --git a/ui/src/components/profiles/editor/header-section.tsx b/ui/src/components/profiles/editor/header-section.tsx index 25b2095d..637a93f3 100644 --- a/ui/src/components/profiles/editor/header-section.tsx +++ b/ui/src/components/profiles/editor/header-section.tsx @@ -5,19 +5,30 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react'; import { OpenRouterBadge } from '@/components/profiles/openrouter-badge'; import { isOpenRouterProfile } from './utils'; import type { Settings } from './types'; +import type { CliTarget } from '@/lib/api-client'; interface HeaderSectionProps { profileName: string; + target: CliTarget; data: { path?: string; mtime: number } | undefined; settings?: Settings; isLoading: boolean; isSaving: boolean; + isTargetSaving: boolean; hasChanges: boolean; isRawJsonValid: boolean; + onTargetChange: (target: CliTarget) => void; onRefresh: () => void; onDelete?: () => void; onSave: () => void; @@ -25,16 +36,22 @@ interface HeaderSectionProps { export function HeaderSection({ profileName, + target, data, settings, isLoading, isSaving, + isTargetSaving, hasChanges, isRawJsonValid, + onTargetChange, onRefresh, onDelete, onSave, }: HeaderSectionProps) { + const isMutating = isSaving || isTargetSaving; + const disableHeaderActions = isLoading || isMutating; + return (
@@ -52,17 +69,37 @@ export function HeaderSection({ Last modified: {new Date(data.mtime).toLocaleString()}

)} +
+ Default target: + + {isTargetSaving && } +
- {onDelete && ( - )} -
+
+ +
+ + {isDroidTarget + ? `ccsd ${profileName} "prompt"` + : `ccs ${profileName} --target droid "prompt"`} + + +
+
+
+ +
+ + ccs {profileName} --target claude "prompt" + + +
+
diff --git a/ui/src/components/profiles/editor/types.ts b/ui/src/components/profiles/editor/types.ts index 950ae645..76aa9a8d 100644 --- a/ui/src/components/profiles/editor/types.ts +++ b/ui/src/components/profiles/editor/types.ts @@ -2,6 +2,8 @@ * Types for Profile Editor */ +import type { CliTarget } from '@/lib/api-client'; + export interface Settings { env?: Record; } @@ -15,6 +17,7 @@ export interface SettingsResponse { export interface ProfileEditorProps { profileName: string; + profileTarget?: CliTarget; onDelete?: () => void; onHasChangesUpdate?: (hasChanges: boolean) => void; } diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 6bb32bf1..510e165a 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -12,6 +12,13 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Dialog, DialogContent, @@ -41,6 +48,7 @@ import { getNewestModelsPerProvider, } from '@/lib/openrouter-utils'; import type { CategorizedModel } from '@/lib/openrouter-types'; +import type { CliTarget } from '@/lib/api-client'; const schema = z.object({ name: z @@ -53,6 +61,7 @@ const schema = z.object({ opusModel: z.string().optional(), sonnetModel: z.string().optional(), haikuModel: z.string().optional(), + target: z.enum(['claude', 'droid']), }); type FormData = z.infer; @@ -78,6 +87,7 @@ const EMPTY_FORM_VALUES: FormData = { opusModel: '', sonnetModel: '', haikuModel: '', + target: 'claude', }; const RECOMMENDED_PRESETS = getPresetsByCategory('recommended'); @@ -117,6 +127,7 @@ export function ProfileCreateDialog({ }); const baseUrlValue = useWatch({ control, name: 'baseUrl' }); + const targetValue = useWatch({ control, name: 'target' }); const applyPresetToForm = useCallback( (preset: ProviderPreset | null) => { if (!preset) { @@ -445,6 +456,30 @@ export function ProfileCreateDialog({ ) )}
+ +
+ + +

+ Run with{' '} + + {targetValue === 'droid' ? 'ccsd' : 'ccs'} + {' '} + by default. You can still override each run with{' '} + --target. +

+
diff --git a/ui/src/components/updates/notice-progress-badge.tsx b/ui/src/components/updates/notice-progress-badge.tsx new file mode 100644 index 00000000..5b6c77ec --- /dev/null +++ b/ui/src/components/updates/notice-progress-badge.tsx @@ -0,0 +1,50 @@ +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { type NoticeProgressState } from '@/lib/updates-notice-state'; + +const NOTICE_PROGRESS_META: Record< + NoticeProgressState, + { label: string; className: string; showDot?: boolean } +> = { + new: { + label: 'Needs Action', + className: + 'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300', + showDot: true, + }, + seen: { + label: 'In Review', + className: + 'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300', + }, + done: { + label: 'Done', + className: + 'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300', + }, + dismissed: { + label: 'Dismissed', + className: + 'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30', + }, +}; + +export function NoticeProgressBadge({ + state, + className, +}: { + state: NoticeProgressState; + className?: string; +}) { + const meta = NOTICE_PROGRESS_META[state]; + + return ( + + {meta.showDot && } + {meta.label} + + ); +} diff --git a/ui/src/components/updates/support-entry-card.tsx b/ui/src/components/updates/support-entry-card.tsx new file mode 100644 index 00000000..8d8e64b4 --- /dev/null +++ b/ui/src/components/updates/support-entry-card.tsx @@ -0,0 +1,96 @@ +import { Link } from 'react-router-dom'; +import { ArrowUpRight } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + SUPPORT_SCOPE_LABELS, + type CliSupportEntry, + type SupportScope, +} from '@/lib/support-updates-catalog'; +import { SupportStatusBadge } from './support-status-badge'; + +const PILLAR_LABELS: { key: keyof CliSupportEntry['pillars']; label: string }[] = [ + { key: 'baseUrl', label: 'Base URL' }, + { key: 'auth', label: 'Auth' }, + { key: 'model', label: 'Model' }, +]; + +const SCOPE_STYLES: Record = { + target: + 'border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/50 dark:bg-violet-900/20 dark:text-violet-300', + cliproxy: + 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/50 dark:bg-sky-900/20 dark:text-sky-300', + 'api-profiles': + 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-900/20 dark:text-emerald-300', + websearch: + 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-300', +}; + +export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) { + return ( + + +
+
+ {entry.name} + {entry.summary} +
+ +
+ + + {SUPPORT_SCOPE_LABELS[entry.scope]} + +
+ + +
+ {PILLAR_LABELS.map((pillar) => ( +
+

+ {pillar.label} +

+

{entry.pillars[pillar.key]}

+
+ ))} +
+ +
+

+ Dashboard +

+
+ {entry.routes.map((route) => ( + + {route.label} + + + ))} +
+
+ +
+

+ CLI Usage +

+
+ {entry.commands.slice(0, 2).map((command) => ( + + {command} + + ))} +
+
+ + {entry.notes &&

{entry.notes}

} +
+
+ ); +} diff --git a/ui/src/components/updates/support-status-badge.tsx b/ui/src/components/updates/support-status-badge.tsx new file mode 100644 index 00000000..2ab10cd8 --- /dev/null +++ b/ui/src/components/updates/support-status-badge.tsx @@ -0,0 +1,31 @@ +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { SupportStatus } from '@/lib/support-updates-catalog'; + +const STATUS_LABELS: Record = { + new: 'New', + stable: 'Stable', + planned: 'Planned', +}; + +const STATUS_STYLES: Record = { + new: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-300', + stable: + 'border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-300', + planned: + 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-300', +}; + +export function SupportStatusBadge({ + status, + className, +}: { + status: SupportStatus; + className?: string; +}) { + return ( + + {STATUS_LABELS[status]} + + ); +} diff --git a/ui/src/components/updates/updates-details-panel.tsx b/ui/src/components/updates/updates-details-panel.tsx new file mode 100644 index 00000000..8a7c107f --- /dev/null +++ b/ui/src/components/updates/updates-details-panel.tsx @@ -0,0 +1,157 @@ +import { Link } from 'react-router-dom'; +import { CalendarClock, CheckCircle2, EyeOff, RotateCcw, Sparkles } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { CopyButton } from '@/components/ui/copy-button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { SupportStatusBadge } from '@/components/updates/support-status-badge'; +import { NoticeProgressBadge } from '@/components/updates/notice-progress-badge'; +import { UpdatesNoticeActionRow } from '@/components/updates/updates-notice-action-row'; +import { + SUPPORT_SCOPE_LABELS, + formatCatalogDate, + type CliSupportEntry, + type SupportNotice, +} from '@/lib/support-updates-catalog'; +import { type NoticeProgressState } from '@/lib/updates-notice-state'; + +type UpdatableNoticeProgress = 'new' | 'seen' | 'done' | 'dismissed'; + +export function UpdatesDetailsPanel({ + notice, + progress, + relatedEntries, + onUpdateProgress, +}: { + notice: SupportNotice | null; + progress: NoticeProgressState | null; + relatedEntries: CliSupportEntry[]; + onUpdateProgress: (nextState: UpdatableNoticeProgress) => void; +}) { + if (!notice) { + return ( +
+

No updates available.

+
+ ); + } + + return ( +
+
+
+
+

{notice.title}

+

{notice.summary}

+
+
+ {progress && } + +
+
+ +
+ + Published {formatCatalogDate(notice.publishedAt)} +
+ +
+ + + +
+
+ +
+
+ + +
+ + Do Next +
+ {notice.primaryAction} +
+ + +
+ {notice.actions.map((action) => ( + + ))} +
+
+
+
+ +
+ + + Impacted Integrations + Related areas based on update scope and routing. + + + +
+ {relatedEntries.map((entry) => ( +
+
+

{entry.name}

+ + {SUPPORT_SCOPE_LABELS[entry.scope]} + +
+
+ {entry.routes[0] && ( + + )} + {entry.commands[0] && ( +
+ + {entry.commands[0]} + + +
+ )} +
+
+ ))} +
+
+
+
+ + + + Why It Matters + + Short context only, no wall-of-text release notes. + + + + +
    + {notice.highlights.map((highlight) => ( +
  • - {highlight}
  • + ))} +
+
+
+
+
+
+
+
+ ); +} diff --git a/ui/src/components/updates/updates-inbox-item.tsx b/ui/src/components/updates/updates-inbox-item.tsx new file mode 100644 index 00000000..4b7ea984 --- /dev/null +++ b/ui/src/components/updates/updates-inbox-item.tsx @@ -0,0 +1,45 @@ +import { ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatCatalogDate, type SupportNotice } from '@/lib/support-updates-catalog'; +import { type NoticeProgressState } from '@/lib/updates-notice-state'; +import { NoticeProgressBadge } from './notice-progress-badge'; + +export function UpdatesInboxItem({ + notice, + progress, + selected, + onSelect, +}: { + notice: SupportNotice; + progress: NoticeProgressState; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/ui/src/components/updates/updates-notice-action-row.tsx b/ui/src/components/updates/updates-notice-action-row.tsx new file mode 100644 index 00000000..0ac130f0 --- /dev/null +++ b/ui/src/components/updates/updates-notice-action-row.tsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom'; +import { ArrowUpRight, Terminal } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { CopyButton } from '@/components/ui/copy-button'; +import { type SupportNoticeAction } from '@/lib/support-updates-catalog'; + +export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) { + return ( +
+
+
+

{action.label}

+

{action.description}

+
+ + {action.type === 'route' && action.path && ( + + )} +
+ + {action.type === 'command' && action.command && ( +
+ + {action.command} + +
+ )} +
+ ); +} diff --git a/ui/src/components/updates/updates-spotlight.tsx b/ui/src/components/updates/updates-spotlight.tsx new file mode 100644 index 00000000..e9bea171 --- /dev/null +++ b/ui/src/components/updates/updates-spotlight.tsx @@ -0,0 +1,51 @@ +import { Link } from 'react-router-dom'; +import { BellRing, ExternalLink } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { formatCatalogDate, getLatestSupportNotice } from '@/lib/support-updates-catalog'; +import { cn } from '@/lib/utils'; + +export function UpdatesSpotlight({ + className, + compact = false, +}: { + className?: string; + compact?: boolean; +}) { + const latest = getLatestSupportNotice(); + if (!latest) { + return null; + } + + const primaryCommand = latest.commands[0]; + + return ( + + + + {latest.title} + + {formatCatalogDate(latest.publishedAt)} + + + +

{latest.summary}

+ +
+ + Open Updates Center + + + + {!compact && primaryCommand && ( + + {primaryCommand} + + )} +
+
+
+ ); +} diff --git a/ui/src/hooks/use-cliproxy-logs.ts b/ui/src/hooks/use-cliproxy-logs.ts index 0f6f9ba0..5b8bf7bd 100644 --- a/ui/src/hooks/use-cliproxy-logs.ts +++ b/ui/src/hooks/use-cliproxy-logs.ts @@ -30,6 +30,29 @@ const MAX_LOGS = 500; const FLUSH_INTERVAL = 100; // ms const POLL_INTERVAL = 1000; // ms +interface CliproxyErrorLogFile { + name: string; + size: number; + modified: number; + statusCode?: number; + model?: string; +} + +function toTimestampMs(modified: number): number { + return modified > 1_000_000_000_000 ? modified : modified * 1000; +} + +function buildLogMessage(file: CliproxyErrorLogFile): string { + const parts = [file.name]; + if (typeof file.statusCode === 'number') { + parts.push(`HTTP ${file.statusCode}`); + } + if (file.model) { + parts.push(file.model); + } + return parts.join(' | '); +} + export function useCliproxyLogs() { const [state, setState] = useState({ logs: [], @@ -86,15 +109,27 @@ export function useCliproxyLogs() { if (isPausedRef.current) return; try { - const after = lastTimestampRef.current ?? new Date(Date.now() - 60000).toISOString(); - const response = await fetch(`/api/cliproxy/logs?after=${encodeURIComponent(after)}`); + const afterIso = lastTimestampRef.current ?? new Date(Date.now() - 60000).toISOString(); + const parsedAfterMs = Date.parse(afterIso); + const afterMs = Number.isNaN(parsedAfterMs) ? Date.now() - 60000 : parsedAfterMs; + const response = await fetch('/api/cliproxy/error-logs'); if (!response.ok) { throw new Error('Failed to fetch logs'); } - const data = await response.json(); - const entries: LogEntry[] = data.logs ?? []; + const data = (await response.json()) as { files?: CliproxyErrorLogFile[] }; + const entries: LogEntry[] = (data.files ?? []) + .filter((file) => toTimestampMs(file.modified) > afterMs) + .sort((a, b) => toTimestampMs(a.modified) - toTimestampMs(b.modified)) + .map((file) => ({ + id: file.name, + timestamp: new Date(toTimestampMs(file.modified)).toISOString(), + level: 'error' as const, + message: buildLogMessage(file), + provider: 'cliproxy', + requestId: file.name, + })); if (entries.length > 0) { bufferRef.current.push(...entries); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 646d39f0..12ec590e 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -94,10 +94,13 @@ async function request(url: string, options?: RequestInit): Promise { } // Types +export type CliTarget = 'claude' | 'droid'; + export interface Profile { name: string; settingsPath: string; configured: boolean; + target?: CliTarget; } export interface CreateProfile { @@ -108,6 +111,7 @@ export interface CreateProfile { opusModel?: string; sonnetModel?: string; haikuModel?: string; + target?: CliTarget; } export interface UpdateProfile { @@ -117,6 +121,7 @@ export interface UpdateProfile { opusModel?: string; sonnetModel?: string; haikuModel?: string; + target?: CliTarget; } export interface Variant { @@ -126,6 +131,7 @@ export interface Variant { account?: string; port?: number; model?: string; + target?: CliTarget; type?: 'composite'; default_tier?: 'opus' | 'sonnet' | 'haiku'; tiers?: { @@ -140,6 +146,7 @@ export interface CreateVariant { provider: CLIProxyProvider; model?: string; account?: string; + target?: CliTarget; type?: 'composite'; default_tier?: 'opus' | 'sonnet' | 'haiku'; tiers?: { @@ -153,6 +160,7 @@ export interface UpdateVariant { provider?: CLIProxyProvider; model?: string; account?: string; + target?: CliTarget; type?: 'composite'; default_tier?: 'opus' | 'sonnet' | 'haiku'; tiers?: { diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts new file mode 100644 index 00000000..aa9cee80 --- /dev/null +++ b/ui/src/lib/support-updates-catalog.ts @@ -0,0 +1,298 @@ +export type SupportStatus = 'new' | 'stable' | 'planned'; + +export type SupportScope = 'target' | 'cliproxy' | 'api-profiles' | 'websearch'; + +export interface SupportRouteHint { + label: string; + path: string; +} + +export interface SupportNoticeAction { + id: string; + label: string; + description: string; + type: 'route' | 'command'; + path?: string; + command?: string; +} + +export interface SupportNotice { + id: string; + title: string; + summary: string; + primaryAction: string; + publishedAt: string; + status: SupportStatus; + scopes: SupportScope[]; + entryIds: string[]; + highlights: string[]; + actions: SupportNoticeAction[]; + routes: SupportRouteHint[]; + commands: string[]; +} + +export interface CliSupportEntry { + id: string; + name: string; + scope: SupportScope; + status: SupportStatus; + summary: string; + pillars: { + baseUrl: string; + auth: string; + model: string; + }; + routes: SupportRouteHint[]; + commands: string[]; + notes?: string; +} + +export const SUPPORT_SCOPE_LABELS: Record = { + target: 'Target CLI', + cliproxy: 'CLIProxy Provider', + 'api-profiles': 'API Profile', + websearch: 'WebSearch', +}; + +export const SUPPORT_NOTICES: SupportNotice[] = [ + { + id: 'droid-target-support', + title: 'Factory Droid support is live', + summary: + 'API Profiles and CLIProxy variants now support Droid as a first-class execution target.', + primaryAction: 'Set Droid as your default execution target for non-Claude workflows.', + publishedAt: '2026-02-25', + status: 'new', + scopes: ['target', 'api-profiles', 'cliproxy'], + entryIds: ['droid-target', 'custom-api-profiles', 'codex-cliproxy', 'agy-cliproxy'], + highlights: [ + 'Set default target to Droid when creating or editing API Profiles.', + 'Set default target to Droid for CLIProxy variants, including Codex and Antigravity flows.', + 'Use ccsd alias or --target droid for one-off target overrides.', + ], + actions: [ + { + id: 'open-api-profiles', + label: 'Set default target in API Profiles', + description: + 'Open API Profiles and set Default Target to Droid for profiles you run often.', + type: 'route', + path: '/providers', + }, + { + id: 'open-cliproxy', + label: 'Set default target in CLIProxy variants', + description: + 'Open CLIProxy variants and set target to Droid for Codex/Antigravity or custom variants.', + type: 'route', + path: '/cliproxy', + }, + { + id: 'copy-ccsd-command', + label: 'Run once with Droid alias', + description: 'Use ccsd to force Droid target with your current profile.', + type: 'command', + command: 'ccsd glm', + }, + { + id: 'copy-target-override', + label: 'Run once with --target override', + description: 'Keep your default profile but force Droid for a single command.', + type: 'command', + command: 'ccs codex --target droid "your prompt"', + }, + ], + routes: [ + { label: 'API Profiles', path: '/providers' }, + { label: 'CLIProxy', path: '/cliproxy' }, + ], + commands: [ + 'ccsd glm', + 'ccs codex --target droid "your prompt"', + 'ccs cliproxy create mycodex --provider codex --target droid', + ], + }, + { + id: 'updates-center-launch', + title: 'Updates inbox is available for rollout tasks', + summary: + 'A focused updates inbox exists for setup tasks and rollout guidance when you need it.', + primaryAction: + 'Use this page only when needed for rollout tasks, then return to your normal workflow.', + publishedAt: '2026-02-25', + status: 'new', + scopes: ['target', 'cliproxy', 'api-profiles', 'websearch'], + entryIds: ['droid-target', 'codex-cliproxy', 'custom-api-profiles', 'opencode-websearch'], + highlights: [ + 'Single data source powers update content and integration mapping.', + 'Notices can be tracked as new, seen, done, or dismissed.', + 'Catalog covers target CLI, CLIProxy providers, and WebSearch integrations.', + ], + actions: [ + { + id: 'open-updates-page', + label: 'Open updates inbox when needed', + description: 'Review rollout tasks only when you want guided setup changes.', + type: 'route', + path: '/updates', + }, + { + id: 'copy-open-dashboard', + label: 'Open dashboard from terminal', + description: 'Re-open config dashboard anytime from CLI.', + type: 'command', + command: 'ccs config', + }, + ], + routes: [{ label: 'Updates Inbox', path: '/updates' }], + commands: ['ccs config'], + }, +]; + +export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ + { + id: 'claude-target', + name: 'Claude Code', + scope: 'target', + status: 'stable', + summary: 'Default runtime target for all CCS profile types.', + pillars: { + baseUrl: 'From profile settings (ANTHROPIC_BASE_URL)', + auth: 'From profile settings (ANTHROPIC_AUTH_TOKEN)', + model: 'From profile settings (ANTHROPIC_MODEL)', + }, + routes: [ + { label: 'API Profiles', path: '/providers' }, + { label: 'CLIProxy', path: '/cliproxy' }, + ], + commands: ['ccs', 'ccs glm "your prompt"'], + }, + { + id: 'droid-target', + name: 'Factory Droid', + scope: 'target', + status: 'new', + summary: 'First-class target for API Profiles and CLIProxy variants.', + pillars: { + baseUrl: 'From profile or variant settings', + auth: 'From profile or variant settings', + model: 'From profile or variant settings', + }, + routes: [ + { label: 'API Profiles', path: '/providers' }, + { label: 'CLIProxy', path: '/cliproxy' }, + ], + commands: ['ccsd glm', 'ccs km --target droid', 'ccs codex --target droid'], + notes: 'Use ccsd alias for automatic Droid target selection.', + }, + { + id: 'codex-cliproxy', + name: 'Codex via CLIProxy', + scope: 'cliproxy', + status: 'stable', + summary: 'OAuth-backed provider with configurable variant model and target.', + pillars: { + baseUrl: 'Managed by CLIProxy backend', + auth: 'OAuth account via CLIProxy auth flow', + model: 'Selectable per provider or variant', + }, + routes: [ + { label: 'CLIProxy', path: '/cliproxy' }, + { label: 'Control Panel', path: '/cliproxy/control-panel' }, + ], + commands: ['ccs codex', 'ccs cliproxy create mycodex --provider codex'], + }, + { + id: 'gemini-cliproxy', + name: 'Gemini via CLIProxy', + scope: 'cliproxy', + status: 'stable', + summary: 'OAuth-backed Gemini provider with multi-account management.', + pillars: { + baseUrl: 'Managed by CLIProxy backend', + auth: 'OAuth account via CLIProxy auth flow', + model: 'Selectable per provider or variant', + }, + routes: [ + { label: 'CLIProxy', path: '/cliproxy' }, + { label: 'Control Panel', path: '/cliproxy/control-panel' }, + ], + commands: ['ccs gemini', 'ccs cliproxy create mygem --provider gemini'], + }, + { + id: 'agy-cliproxy', + name: 'Antigravity via CLIProxy', + scope: 'cliproxy', + status: 'stable', + summary: 'OAuth-backed Antigravity provider with variant target controls.', + pillars: { + baseUrl: 'Managed by CLIProxy backend', + auth: 'OAuth account via CLIProxy auth flow', + model: 'Selectable per provider or variant', + }, + routes: [ + { label: 'CLIProxy', path: '/cliproxy' }, + { label: 'Control Panel', path: '/cliproxy/control-panel' }, + ], + commands: ['ccs agy', 'ccs cliproxy create myagy --provider agy --target droid'], + }, + { + id: 'custom-api-profiles', + name: 'Custom API Profiles', + scope: 'api-profiles', + status: 'stable', + summary: 'Any Anthropic-compatible endpoint with per-profile target and model mapping.', + pillars: { + baseUrl: 'User-defined endpoint', + auth: 'User-defined token/key', + model: 'User-defined model identifier', + }, + routes: [{ label: 'API Profiles', path: '/providers' }], + commands: ['ccs api create myprofile', 'ccs myprofile "your prompt"'], + }, + { + id: 'opencode-websearch', + name: 'OpenCode WebSearch', + scope: 'websearch', + status: 'stable', + summary: 'WebSearch provider surfaced in Settings for third-party profile workflows.', + pillars: { + baseUrl: 'Managed by OpenCode CLI integration', + auth: 'Provider-specific (managed externally)', + model: 'Configurable in WebSearch settings', + }, + routes: [{ label: 'Settings', path: '/settings' }], + commands: ['ccs config', 'ccs codex "your prompt"'], + notes: 'Enable OpenCode in Settings > WebSearch to activate fallback search.', + }, +]; + +const SUPPORT_ENTRY_LOOKUP = new Map(CLI_SUPPORT_ENTRIES.map((entry) => [entry.id, entry])); + +export function getSupportEntriesForNotice(notice: SupportNotice): CliSupportEntry[] { + return notice.entryIds + .map((entryId) => SUPPORT_ENTRY_LOOKUP.get(entryId)) + .filter((entry): entry is CliSupportEntry => Boolean(entry)); +} + +export function getLatestSupportNotice(): SupportNotice | null { + if (SUPPORT_NOTICES.length === 0) { + return null; + } + + return [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))[0]; +} + +export function formatCatalogDate(value: string): string { + const parsed = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }).format(parsed); +} diff --git a/ui/src/lib/updates-notice-state.ts b/ui/src/lib/updates-notice-state.ts new file mode 100644 index 00000000..66052f37 --- /dev/null +++ b/ui/src/lib/updates-notice-state.ts @@ -0,0 +1,63 @@ +import { type SupportNotice, type SupportStatus } from '@/lib/support-updates-catalog'; + +export type NoticeProgressState = 'new' | 'seen' | 'done' | 'dismissed'; + +export type NoticeProgressMap = Record; + +const NOTICE_PROGRESS_STORAGE_KEY = 'ccs:updates:notice-progress:v1'; + +export function getDefaultNoticeProgress(status: SupportStatus): NoticeProgressState { + return status === 'new' ? 'new' : 'seen'; +} + +export function getNoticeProgress( + notice: Pick, + progressMap: NoticeProgressMap +): NoticeProgressState { + return progressMap[notice.id] ?? getDefaultNoticeProgress(notice.status); +} + +export function isActionableNoticeState(progress: NoticeProgressState): boolean { + return progress !== 'done' && progress !== 'dismissed'; +} + +export function readNoticeProgressMap(): NoticeProgressMap { + if (typeof window === 'undefined') { + return {}; + } + + try { + const rawValue = window.localStorage.getItem(NOTICE_PROGRESS_STORAGE_KEY); + if (!rawValue) { + return {}; + } + + const parsed = JSON.parse(rawValue); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + const normalized: NoticeProgressMap = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof key !== 'string') { + continue; + } + + if (value === 'new' || value === 'seen' || value === 'done' || value === 'dismissed') { + normalized[key] = value; + } + } + + return normalized; + } catch { + return {}; + } +} + +export function writeNoticeProgressMap(progressMap: NoticeProgressMap): void { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(NOTICE_PROGRESS_STORAGE_KEY, JSON.stringify(progressMap)); +} diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 0152d04a..1d56ac11 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -7,6 +7,7 @@ import { useState, useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; import { Plus, Search, @@ -222,6 +223,7 @@ export function ApiPage() { setDeleteConfirm(selectedProfileData.name)} onHasChangesUpdate={setEditorHasChanges} /> @@ -308,7 +310,12 @@ function ProfileListItem({ {/* Profile info */}
-
{profile.name}
+
+
{profile.name}
+ + {profile.target || 'claude'} + +
{profile.settingsPath} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 5d12c178..e75d645c 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -120,6 +120,9 @@ function VariantSidebarItem({ variant + + {variant.target || 'claude'} +
{parentAuth?.authenticated ? ( @@ -405,6 +408,7 @@ export function CliproxyPage() { catalog={MODEL_CATALOGS[selectedVariantData.provider]} logoProvider={selectedVariantData.provider} baseProvider={selectedVariantData.provider} + defaultTarget={selectedVariantData.target} isRemoteMode={isRemoteMode} port={selectedVariantData.port} onAddAccount={() => diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index 03ba32b3..dda9ae12 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -15,3 +15,5 @@ export { SharedPage } from './shared'; export { AnalyticsPage } from './analytics'; export { CursorPage } from './cursor'; + +export { UpdatesPage } from './updates'; diff --git a/ui/src/pages/updates.tsx b/ui/src/pages/updates.tsx new file mode 100644 index 00000000..2ce8c9d1 --- /dev/null +++ b/ui/src/pages/updates.tsx @@ -0,0 +1,189 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Megaphone, Search } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { UpdatesDetailsPanel } from '@/components/updates/updates-details-panel'; +import { UpdatesInboxItem } from '@/components/updates/updates-inbox-item'; +import { + SUPPORT_NOTICES, + getSupportEntriesForNotice, + type SupportNotice, +} from '@/lib/support-updates-catalog'; +import { + getNoticeProgress, + isActionableNoticeState, + readNoticeProgressMap, + writeNoticeProgressMap, + type NoticeProgressMap, +} from '@/lib/updates-notice-state'; + +type NoticeViewMode = 'inbox' | 'done' | 'all'; + +const NOTICE_VIEW_MODES: { id: NoticeViewMode; label: string }[] = [ + { id: 'inbox', label: 'Action Required' }, + { id: 'done', label: 'Done' }, + { id: 'all', label: 'All' }, +]; + +function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean { + if (!queryValue) { + return true; + } + + const haystack = [ + notice.title, + notice.summary, + notice.primaryAction, + ...notice.highlights, + ...notice.commands, + ...notice.actions.map( + (action) => `${action.label} ${action.description} ${action.command || ''}` + ), + ...notice.routes.map((route) => route.label), + ] + .join(' ') + .toLowerCase(); + + return haystack.includes(queryValue); +} + +export function UpdatesPage() { + const notices = useMemo( + () => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)), + [] + ); + const [viewMode, setViewMode] = useState('inbox'); + const [query, setQuery] = useState(''); + const [progressMap, setProgressMap] = useState(() => readNoticeProgressMap()); + const [selectedNoticeId, setSelectedNoticeId] = useState(null); + + useEffect(() => { + writeNoticeProgressMap(progressMap); + }, [progressMap]); + + const visibleNotices = useMemo(() => { + const queryValue = query.trim().toLowerCase(); + + return notices.filter((notice) => { + const progress = getNoticeProgress(notice, progressMap); + const matchesQuery = noticeMatchesQuery(notice, queryValue); + if (!matchesQuery) return false; + if (viewMode === 'done') return progress === 'done'; + if (viewMode === 'inbox') return isActionableNoticeState(progress); + return true; + }); + }, [notices, progressMap, query, viewMode]); + + const selectedNotice = useMemo(() => { + const selectionPool = viewMode === 'all' ? notices : visibleNotices; + return ( + selectionPool.find((notice) => notice.id === selectedNoticeId) ?? selectionPool[0] ?? null + ); + }, [notices, selectedNoticeId, viewMode, visibleNotices]); + + const handleSelectNotice = (notice: SupportNotice) => { + setSelectedNoticeId(notice.id); + setProgressMap((previous) => { + const progress = getNoticeProgress(notice, previous); + if (progress !== 'new') { + return previous; + } + + return { ...previous, [notice.id]: 'seen' }; + }); + }; + + const pendingCount = useMemo( + () => + notices.filter((notice) => isActionableNoticeState(getNoticeProgress(notice, progressMap))) + .length, + [notices, progressMap] + ); + const doneCount = useMemo( + () => notices.filter((notice) => getNoticeProgress(notice, progressMap) === 'done').length, + [notices, progressMap] + ); + + return ( +
+
+
+
+
+ +

Updates Inbox

+
+

+ Focus on actions, then mark updates done or dismissed. +

+
+ +
+
+

Needs Action

+

{pendingCount}

+
+
+

Done

+

{doneCount}

+
+
+ +
+ + setQuery(event.target.value)} + placeholder="Search actions or commands" + className="h-9 pl-8" + /> +
+ +
+ {NOTICE_VIEW_MODES.map((mode) => ( + + ))} +
+
+ + +
+ {visibleNotices.length === 0 ? ( +
+ No notices match this view. +
+ ) : ( + visibleNotices.map((notice) => ( + handleSelectNotice(notice)} + /> + )) + )} +
+
+
+ + { + if (!selectedNotice) return; + setProgressMap((previous) => ({ ...previous, [selectedNotice.id]: nextState })); + }} + /> +
+ ); +}