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/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/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); });