From 461236e5decc88e3488781cd94528d2ef99a3e9d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:51:31 +0700 Subject: [PATCH 01/31] feat(api): persist profile target metadata - add target fields to API profile and CLIProxy variant service types - persist target in unified config and legacy profile_targets map - expose target update API via updateApiProfileTarget --- src/api/services/index.ts | 3 +- src/api/services/profile-reader.ts | 9 ++- src/api/services/profile-types.ts | 11 ++++ src/api/services/profile-writer.ts | 90 +++++++++++++++++++++++++++--- 4 files changed, 104 insertions(+), 9 deletions(-) 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..028931c7 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -9,6 +9,7 @@ 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'; /** @@ -68,6 +69,7 @@ export function listApiProfiles(): ApiListResult { settingsPath: profile.settings || 'config.yaml', isConfigured: isApiProfileConfigured(name), configSource: 'unified', + target: profile.target || 'claude', }); } // CLIProxy variants @@ -80,10 +82,13 @@ export function listApiProfiles(): ApiListResult { name, provider, settings: variant?.settings || '-', + target: variant?.target || 'claude', }); } } 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 +99,18 @@ export function listApiProfiles(): ApiListResult { settingsPath: settingsPath as string, isConfigured: isApiProfileConfigured(name), configSource: 'legacy', + target: legacyTargetMap?.[name] || 'claude', }); } // 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?: TargetType }; variants.push({ name, provider: variant.provider, settings: variant.settings, + target: variant.target || 'claude', }); } } 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..fa8ff9c4 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(); From e8330546429803a1a94cc508e3dc6a1572ecef9c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:51:43 +0700 Subject: [PATCH 02/31] feat(api): support profile target in routes - validate target payloads for profile create/update - return target in profile list and create responses - allow updating target independently from settings values --- src/web-server/routes/profile-routes.ts | 98 ++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 37a895dc..70aad36b 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(); +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 }); } From 3dacb39deb5cf88978c7d98e3cd95c5a12dca455 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:51:54 +0700 Subject: [PATCH 03/31] feat(api): add --target to api command - parse and validate --target claude|droid on api create flow - show target in API and CLIProxy list tables and usage output - add parser unit tests for target success and failure cases --- src/commands/api-command.ts | 79 +++++++++++++++++--- tests/unit/commands/api-command-args.test.ts | 15 ++++ 2 files changed, 84 insertions(+), 10 deletions(-) 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/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index b8a0dc82..fc5ac004 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -35,4 +35,19 @@ 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('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']); + }); }); From f4a692729300d0ec7894b985c57a9d3a5cfc5a52 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:52:05 +0700 Subject: [PATCH 04/31] feat(cliproxy): persist target on variants - add target to variant create/update flows for single and composite modes - persist target in unified config and legacy cliproxy entries - include target in variant list/remove return payloads --- .../services/variant-config-adapter.ts | 69 ++++++++----------- src/cliproxy/services/variant-service.ts | 43 +++++++++--- 2 files changed, 63 insertions(+), 49 deletions(-) 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) { From 5b60784eb82e51c0356cf416ba6323094e04d19c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:52:18 +0700 Subject: [PATCH 05/31] feat(cliproxy): accept target in variant routes - validate target request payloads for create and update APIs - pass target into single/composite service operations - include target in variant API responses --- src/web-server/routes/variant-routes.ts | 67 ++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index c21429ba..b6359c95 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(); +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) { From d2d1d599cd220fb1e2c1eae9a501148b97f0770b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:52:29 +0700 Subject: [PATCH 06/31] feat(cliproxy): add target support to subcommands - add --target parsing and validation in create/edit/remove flows - show target in list/remove/edit UX and usage examples - document target option in cliproxy help output --- src/commands/cliproxy/auth-subcommand.ts | 7 +- src/commands/cliproxy/help-subcommand.ts | 1 + src/commands/cliproxy/variant-subcommand.ts | 173 +++++++++++++++++++- 3 files changed, 172 insertions(+), 9 deletions(-) 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..53171b20 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,13 +33,23 @@ interface CliproxyProfileArgs { provider?: CLIProxyProfileName; model?: string; account?: string; + target?: TargetType; force?: boolean; yes?: boolean; composite?: boolean; + errors: string[]; +} + +function parseTargetValue(rawValue: string): TargetType | null { + const normalized = rawValue.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + return null; } function parseProfileArgs(args: string[]): CliproxyProfileArgs { - const result: CliproxyProfileArgs = {}; + const result: CliproxyProfileArgs = { errors: [] }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--provider' && args[i + 1]) { @@ -47,6 +58,27 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { result.model = args[++i]; } else if (arg === '--account' && args[i + 1]) { result.account = args[++i]; + } else if (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 (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 (arg === '--force') { result.force = true; } else if (arg === '--yes' || arg === '-y') { @@ -145,6 +177,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 +205,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 +251,7 @@ export async function handleCreate( const result = createCompositeVariant({ name, defaultTier, + target: resolvedTarget, tiers: { opus, sonnet, haiku }, }); @@ -217,7 +266,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 +278,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 +417,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 +434,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 +467,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 +524,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 +551,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 +596,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 +649,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 +672,7 @@ export async function handleEdit( const result = updateVariant(name, { provider: newProvider, model: changeModel ? newModel : undefined, + target: newTarget, }); if (!result.success) { @@ -566,13 +680,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 +721,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 +771,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 +811,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( From 8a2a7c3eb0c86aa1207099d9f67fb57840860673 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:52:40 +0700 Subject: [PATCH 07/31] feat(runtime): run cliproxy profiles on droid - execute cliproxy profiles on droid using resolved proxy env credentials - enforce auth/service preconditions and block Claude-only management flags - allow dotted profile names in droid adapter/config manager --- src/ccs.ts | 147 ++++++++++++++++++++- src/targets/droid-adapter.ts | 10 +- src/targets/droid-config-manager.ts | 6 +- tests/unit/targets/target-registry.test.ts | 4 +- 4 files changed, 153 insertions(+), 14 deletions(-) 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); }); From 9a63f9bd36d6c53131b1a7681c7c7658516cce46 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:52:50 +0700 Subject: [PATCH 08/31] feat(config): support legacy profile target overrides - add profile_targets map to legacy config typing - load target defaults for settings/default profiles from config.json - expose target metadata for legacy cliproxy variants in profile detection --- src/auth/profile-detector.ts | 7 +++++++ src/types/config.ts | 5 +++++ 2 files changed, 12 insertions(+) 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/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; } From ca78e63205aeb7cbd6c059c7f70fffcc509f860d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:53:02 +0700 Subject: [PATCH 09/31] feat(ui): add target support to API profile dashboard - extend API client types with claude|droid target fields - add default target selector in API profile create dialog - show target badges in API profile list --- .../profiles/profile-create-dialog.tsx | 35 +++++++++++++++++++ ui/src/lib/api-client.ts | 8 +++++ ui/src/pages/api.tsx | 9 ++++- 3 files changed, 51 insertions(+), 1 deletion(-) 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/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/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} From db38ccc117e59fdc9f70c584aaa569b7f70cbe4a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:53:22 +0700 Subject: [PATCH 10/31] feat(ui): support editing profile default target - add target selector in profile editor header - call profile update API to persist target changes - update info panel usage snippets for target-aware commands --- .../profiles/editor/friendly-ui-section.tsx | 5 +- .../profiles/editor/header-section.tsx | 27 ++++++++++ ui/src/components/profiles/editor/index.tsx | 49 ++++++++++++++++++- .../profiles/editor/info-section.tsx | 46 ++++++++++++++++- ui/src/components/profiles/editor/types.ts | 3 ++ 5 files changed, 127 insertions(+), 3 deletions(-) diff --git a/ui/src/components/profiles/editor/friendly-ui-section.tsx b/ui/src/components/profiles/editor/friendly-ui-section.tsx index f193290c..7a5bdeed 100644 --- a/ui/src/components/profiles/editor/friendly-ui-section.tsx +++ b/ui/src/components/profiles/editor/friendly-ui-section.tsx @@ -20,9 +20,11 @@ import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './uti import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import type { Settings, SettingsResponse } from './types'; +import type { CliTarget } from '@/lib/api-client'; interface FriendlyUISectionProps { profileName: string; + target: CliTarget; data: SettingsResponse | undefined; currentSettings: Settings | undefined; newEnvKey: string; @@ -36,6 +38,7 @@ interface FriendlyUISectionProps { export function FriendlyUISection({ profileName, + target, data, currentSettings, newEnvKey, @@ -263,7 +266,7 @@ export function FriendlyUISection({ value="info" className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden" > - +
diff --git a/ui/src/components/profiles/editor/header-section.tsx b/ui/src/components/profiles/editor/header-section.tsx index 25b2095d..6692222a 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,12 +36,15 @@ interface HeaderSectionProps { export function HeaderSection({ profileName, + target, data, settings, isLoading, isSaving, + isTargetSaving, hasChanges, isRawJsonValid, + onTargetChange, onRefresh, onDelete, onSave, @@ -52,6 +66,19 @@ export function HeaderSection({ Last modified: {new Date(data.mtime).toLocaleString()}

)} +
+ Default target: + + {isTargetSaving && } +
+
+ +
+ + {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; } From 543ec5f0502e6c9cb24e0837b72e6c4d82f15c4d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:53:40 +0700 Subject: [PATCH 11/31] feat(ui): add target controls to cliproxy variants - add default target selectors in create/edit dialogs - include target in variant create/update payloads - display target badge column in variants table --- .../components/cliproxy/cliproxy-dialog.tsx | 29 +++++++++++++++++++ .../cliproxy/cliproxy-edit-dialog.tsx | 29 +++++++++++++++++++ ui/src/components/cliproxy/cliproxy-table.tsx | 9 ++++++ 3 files changed, 67 insertions(+) diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index 0642596a..879c5e07 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -26,6 +26,7 @@ const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), model: z.string().optional(), account: z.string().optional(), + target: z.enum(['claude', 'droid']).default('claude'), }); const compositeSchema = z.object({ @@ -34,6 +35,7 @@ const compositeSchema = z.object({ .min(1, 'Name is required') .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'), default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), + target: z.enum(['claude', 'droid']).default('claude'), tiers: z.object({ opus: z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), @@ -74,12 +76,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { const singleForm = useForm({ 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/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={() => From 172e5995747c31acce3da4ab395e5581d7130e65 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:54:22 +0700 Subject: [PATCH 13/31] docs(cli): document target defaults and ccsd cliproxy usage - add multi-target help examples for ccsd codex and ccsd agy - document per-profile target defaults for API and CLIProxy profiles - include dashboard parity notes for target configuration --- README.md | 24 ++++++++++++++++++++++++ src/commands/help-command.ts | 6 ++++++ 2 files changed, 30 insertions(+) diff --git a/README.md b/README.md index 9c173863..a51427c1 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,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/commands/help-command.ts b/src/commands/help-command.ts index 1fdc3d63..38953277 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)'], ]); From 1c7e4e116f05fff4802c5b2b228cdffebe4e2228 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:59:33 +0700 Subject: [PATCH 14/31] fix(ui): align cliproxy target schema typing - remove zod default() on target fields in cliproxy forms - keep target defaults via react-hook-form defaultValues - resolve ci-parity TypeScript resolver mismatch errors --- ui/src/components/cliproxy/cliproxy-dialog.tsx | 4 ++-- ui/src/components/cliproxy/cliproxy-edit-dialog.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index 879c5e07..6420fc58 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -26,7 +26,7 @@ const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), model: z.string().optional(), account: z.string().optional(), - target: z.enum(['claude', 'droid']).default('claude'), + target: z.enum(['claude', 'droid']), }); const compositeSchema = z.object({ @@ -35,7 +35,7 @@ const compositeSchema = z.object({ .min(1, 'Name is required') .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'), default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), - target: z.enum(['claude', 'droid']).default('claude'), + target: z.enum(['claude', 'droid']), tiers: z.object({ opus: z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 0e95ea44..3f4c4f1b 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -20,12 +20,12 @@ const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), model: z.string().optional(), account: z.string().optional(), - target: z.enum(['claude', 'droid']).default('claude'), + target: z.enum(['claude', 'droid']), }); const compositeSchema = z.object({ default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), - target: z.enum(['claude', 'droid']).default('claude'), + target: z.enum(['claude', 'droid']), tiers: z.object({ opus: z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), From e32dedcf4c0f45470303eba6ed66e10c21097c72 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:00:39 +0700 Subject: [PATCH 15/31] fix(api): remove stale legacy profile target mappings - delete profile_targets entry when deleting a legacy API profile - prune empty profile_targets object to avoid config drift --- src/api/services/profile-writer.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index fa8ff9c4..f90455c2 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -251,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'; From 2bd3c40c7ad4072634b395c0c5cfa7a29a2657a2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:00:49 +0700 Subject: [PATCH 16/31] fix(targets): sanitize invalid persisted target values - fallback unknown persisted targets to claude in profile listing and runtime resolution - add regression test for invalid profile target fallback --- src/api/services/profile-reader.ts | 21 +++++++++++++++------ src/targets/target-resolver.ts | 10 +++++++--- tests/unit/targets/target-resolver.test.ts | 5 +++++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index 028931c7..190426da 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -12,6 +12,15 @@ import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-c 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 */ @@ -69,7 +78,7 @@ export function listApiProfiles(): ApiListResult { settingsPath: profile.settings || 'config.yaml', isConfigured: isApiProfileConfigured(name), configSource: 'unified', - target: profile.target || 'claude', + target: sanitizeTarget(profile.target), }); } // CLIProxy variants @@ -82,12 +91,12 @@ export function listApiProfiles(): ApiListResult { name, provider, settings: variant?.settings || '-', - target: variant?.target || 'claude', + target: sanitizeTarget(variant?.target), }); } } else { const config = loadConfigSafe(); - const legacyTargetMap = (config as { profile_targets?: Record }) + 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 @@ -99,18 +108,18 @@ export function listApiProfiles(): ApiListResult { settingsPath: settingsPath as string, isConfigured: isApiProfileConfigured(name), configSource: 'legacy', - target: legacyTargetMap?.[name] || 'claude', + 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; target?: TargetType }; + const variant = v as { provider: string; settings: string; target?: unknown }; variants.push({ name, provider: variant.provider, settings: variant.settings, - target: variant.target || 'claude', + target: sanitizeTarget(variant.target), }); } } 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/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'); From 2e8c7a36915e86e697b358db68aa029a200627da Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:01:06 +0700 Subject: [PATCH 17/31] test(web-server): cover profile and variant target parsing - export route target parsers for direct unit coverage - verify accepted and rejected target payloads for both route modules --- src/web-server/routes/profile-routes.ts | 2 +- src/web-server/routes/variant-routes.ts | 2 +- .../web-server/target-parse-routes.test.ts | 27 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 tests/unit/web-server/target-parse-routes.test.ts diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 70aad36b..1b1a209f 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -18,7 +18,7 @@ import { updateSettingsFile } from './route-helpers'; const router = Router(); -function parseTarget(rawTarget: unknown): TargetType | null { +export function parseTarget(rawTarget: unknown): TargetType | null { if (rawTarget === undefined || rawTarget === null || rawTarget === '') { return null; } diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index b6359c95..65a4c07b 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -24,7 +24,7 @@ import { const router = Router(); -function parseTarget(rawTarget: unknown): TargetType | null { +export function parseTarget(rawTarget: unknown): TargetType | null { if (rawTarget === undefined || rawTarget === null || rawTarget === '') { return null; } 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(); + }); +}); From ac1c744239d57fb52441dde32cd5cb7f41e9f2b5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:01:22 +0700 Subject: [PATCH 18/31] fix(cliproxy-sync): honor profile settings paths and targets - resolve settings file from profile.settingsPath instead of reconstructing by name - skip non-claude targets for local claude-api-key sync semantics - add mapper regression tests for both behaviors --- src/cliproxy/sync/profile-mapper.ts | 19 ++++- tests/unit/cliproxy/profile-mapper.test.ts | 96 ++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) 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/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', () => { From 8d95de9fd37cfbe8be9e83481cce07dedc418106 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:01:32 +0700 Subject: [PATCH 19/31] fix(cli): improve cliproxy target parsing edge cases - support POSIX -- terminator so dash-prefixed variant names remain positional - export variant parser for direct unit tests - add --target parsing coverage for API and cliproxy arg parsers --- src/commands/cliproxy/variant-subcommand.ts | 27 ++++++---- tests/unit/commands/api-command-args.test.ts | 30 +++++++++++ .../commands/cliproxy-variant-args.test.ts | 51 +++++++++++++++++++ 3 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 tests/unit/commands/cliproxy-variant-args.test.ts diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 53171b20..b3a00056 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -48,17 +48,24 @@ function parseTargetValue(rawValue: string): TargetType | null { return null; } -function parseProfileArgs(args: string[]): CliproxyProfileArgs { +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 === '--target') { + } else if (parseOptions && arg === '--target') { const rawValue = args[i + 1]; if (!rawValue || rawValue.startsWith('-')) { result.errors.push('Missing value for --target'); @@ -71,7 +78,7 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { result.target = parsedTarget; } } - } else if (arg.startsWith('--target=')) { + } else if (parseOptions && arg.startsWith('--target=')) { const rawValue = arg.slice('--target='.length); const parsedTarget = parseTargetValue(rawValue); if (!parsedTarget) { @@ -79,13 +86,13 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { } else { result.target = parsedTarget; } - } else if (arg === '--force') { + } 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; } } diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index fc5ac004..b73e1e08 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -44,10 +44,40 @@ describe('api-command arg parser', () => { 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([]); + }); +}); From b658b20709e4b8b1766442cbc4b9c944d34fc0bc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:01:41 +0700 Subject: [PATCH 20/31] fix(help): align cliproxy status and provider filter docs - replace incorrect root help command example for provider-filtered status - add parity regression test for cliproxy quota provider guidance --- src/commands/help-command.ts | 4 ++- .../unit/commands/help-command-parity.test.ts | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 tests/unit/commands/help-command-parity.test.ts diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 38953277..4d028e97 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -353,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/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); + }); +}); From d385bd19f2206db98571a184eba7960edb382afa Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:01:53 +0700 Subject: [PATCH 21/31] fix(ui): harden target update flows in profile editors - send strict target-only payloads when cliproxy edit only changes target - route profile target updates through shared api client error handling - block concurrent save/target actions in header controls --- .../cliproxy/cliproxy-edit-dialog.tsx | 146 ++++++++++++++++-- .../profiles/editor/header-section.tsx | 20 ++- ui/src/components/profiles/editor/index.tsx | 44 +++--- 3 files changed, 169 insertions(+), 41 deletions(-) diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 3f4c4f1b..82abaf5e 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -14,7 +14,7 @@ import { Label } from '@/components/ui/label'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { useUpdateVariant } from '@/hooks/use-cliproxy'; import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; -import type { Variant } from '@/lib/api-client'; +import type { UpdateVariant, Variant } from '@/lib/api-client'; const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), @@ -59,6 +59,63 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({ label: getProviderDisplayName(id), })); +const COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const; + +function normalizeOptionalValue(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function isSingleVariantOnlyTargetChange(variant: Variant, data: SingleProviderFormData): boolean { + const currentTarget = variant.target || 'claude'; + const currentModel = normalizeOptionalValue(variant.model); + const currentAccount = normalizeOptionalValue(variant.account); + const nextModel = normalizeOptionalValue(data.model); + const nextAccount = normalizeOptionalValue(data.account); + + return ( + data.target !== currentTarget && + data.provider === variant.provider && + nextModel === currentModel && + nextAccount === currentAccount + ); +} + +function normalizeCompositeTier(tier: { provider: string; model: string; account?: string }) { + return { + provider: tier.provider, + model: tier.model.trim(), + account: normalizeOptionalValue(tier.account), + }; +} + +function isCompositeVariantOnlyTargetChange(variant: Variant, data: CompositeFormData): boolean { + const currentTarget = variant.target || 'claude'; + const existingTiers = variant.tiers; + + if (!existingTiers || !variant.default_tier) { + return false; + } + + if (data.target === currentTarget) { + return false; + } + + if (data.default_tier !== variant.default_tier) { + return false; + } + + return COMPOSITE_TIERS.every((tier) => { + const current = normalizeCompositeTier(existingTiers[tier]); + const next = normalizeCompositeTier(data.tiers[tier]); + return ( + next.provider === current.provider && + next.model === current.model && + next.account === current.account + ); + }); +} + export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEditDialogProps) { const updateMutation = useUpdateVariant(); const isComposite = variant?.type === 'composite'; @@ -102,10 +159,40 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit const onSubmitSingle = async (data: SingleProviderFormData) => { if (!variant) return; - // Filter out undefined values - backend interprets undefined as "no change" - const payload = Object.fromEntries( - Object.entries(data).filter(([, v]) => v !== undefined && v !== '') - ) as SingleProviderFormData; + + let payload: UpdateVariant = {}; + + if (isSingleVariantOnlyTargetChange(variant, data)) { + payload = { target: data.target }; + } else { + const currentTarget = variant.target || 'claude'; + const currentModel = normalizeOptionalValue(variant.model); + const currentAccount = normalizeOptionalValue(variant.account); + const nextModel = normalizeOptionalValue(data.model); + const nextAccount = normalizeOptionalValue(data.account); + + if (data.provider !== variant.provider) { + payload.provider = data.provider; + } + + if (nextModel !== currentModel) { + payload.model = nextModel; + } + + if (nextAccount !== currentAccount) { + payload.account = nextAccount; + } + + if (data.target !== currentTarget) { + payload.target = data.target; + } + } + + if (Object.keys(payload).length === 0) { + onOpenChange(false); + return; + } + try { await updateMutation.mutateAsync({ name: variant.name, data: payload }); onOpenChange(false); @@ -116,14 +203,53 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit const onSubmitComposite = async (data: CompositeFormData) => { if (!variant) return; + + let payload: UpdateVariant = {}; + + if (isCompositeVariantOnlyTargetChange(variant, data)) { + payload = { target: data.target }; + } else { + const existingTiers = variant.tiers; + const normalizedTiers: NonNullable = { + opus: normalizeCompositeTier(data.tiers.opus), + sonnet: normalizeCompositeTier(data.tiers.sonnet), + haiku: normalizeCompositeTier(data.tiers.haiku), + }; + + const tiersChanged = !existingTiers + ? true + : COMPOSITE_TIERS.some((tier) => { + const current = normalizeCompositeTier(existingTiers[tier]); + const next = normalizedTiers[tier]; + return ( + next.provider !== current.provider || + next.model !== current.model || + next.account !== current.account + ); + }); + + if (variant.default_tier !== data.default_tier) { + payload.default_tier = data.default_tier; + } + + if ((variant.target || 'claude') !== data.target) { + payload.target = data.target; + } + + if (tiersChanged) { + payload.tiers = normalizedTiers; + } + } + + if (Object.keys(payload).length === 0) { + onOpenChange(false); + return; + } + try { await updateMutation.mutateAsync({ name: variant.name, - data: { - default_tier: data.default_tier, - target: data.target, - tiers: data.tiers, - }, + data: payload, }); onOpenChange(false); } catch (error) { diff --git a/ui/src/components/profiles/editor/header-section.tsx b/ui/src/components/profiles/editor/header-section.tsx index 6692222a..637a93f3 100644 --- a/ui/src/components/profiles/editor/header-section.tsx +++ b/ui/src/components/profiles/editor/header-section.tsx @@ -49,6 +49,9 @@ export function HeaderSection({ onDelete, onSave, }: HeaderSectionProps) { + const isMutating = isSaving || isTargetSaving; + const disableHeaderActions = isLoading || isMutating; + return (

@@ -68,8 +71,15 @@ export function HeaderSection({ )}
Default target: - { + if (disableHeaderActions) return; + onTargetChange(value as CliTarget); + }} + disabled={disableHeaderActions} + > + @@ -81,15 +91,15 @@ export function HeaderSection({
- {onDelete && ( - )} - + ))} + {filteredEntries.length} entries +
+ + {filteredEntries.length === 0 ? ( + + + No support entries match this filter. + + + ) : ( +
+ {filteredEntries.map((entry) => ( + + ))} +
+ )} + + + + + Maintainer Notes + + Keep update messaging in one place for future CLI expansions. + + + +

+ Edit{' '} + + ui/src/lib/support-updates-catalog.ts + {' '} + to add new notices or support entries. +

+

+ Home spotlight and this page consume the same catalog, so announcements stay consistent + without repeated UI edits. +

+
+
+
+ ); +} From c7c4c87fb43c631564ba25900fef43e1768a1a06 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 18:05:35 +0700 Subject: [PATCH 24/31] feat(ui): surface updates center across dashboard - add /updates route and sidebar navigation entry - show updates spotlight on Home, API Profiles, and CLIProxy pages - export Updates page for lazy route loading consistency --- ui/src/App.tsx | 9 ++++ ui/src/components/layout/app-sidebar.tsx | 2 + .../components/updates/updates-spotlight.tsx | 51 +++++++++++++++++++ ui/src/pages/api.tsx | 5 ++ ui/src/pages/cliproxy.tsx | 2 + ui/src/pages/home.tsx | 3 ++ ui/src/pages/index.tsx | 2 + 7 files changed, 74 insertions(+) create mode 100644 ui/src/components/updates/updates-spotlight.tsx 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() { } /> + }> + + + } + /> + + + {latest.title} + + {formatCatalogDate(latest.publishedAt)} + + + +

{latest.summary}

+ +
+ + Open Updates Center + + + + {!compact && primaryCommand && ( + + {primaryCommand} + + )} +
+
+ + ); +} diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 1d56ac11..dc09dfbb 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -23,6 +23,7 @@ import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog import { OpenRouterBanner } from '@/components/profiles/openrouter-banner'; import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start'; import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card'; +import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; import { useOpenRouterModels } from '@/hooks/use-openrouter-models'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; @@ -96,6 +97,10 @@ export function ApiPage() { {/* OpenRouter Announcement Banner */} setCreateDialogOpen(true)} /> +
+ +
+ {/* Main Content */}
{/* Left Panel - Profiles List */} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index e75d645c..d79b3c76 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -17,6 +17,7 @@ import { AccountSafetyWarningCard } from '@/components/account/account-safety-wa import { ProviderEditor } from '@/components/cliproxy/provider-editor'; import { ProviderLogo } from '@/components/cliproxy/provider-logo'; import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; +import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { useCliproxy, useCliproxyAuth, @@ -397,6 +398,7 @@ export function CliproxyPage() { {/* Right Panel */}
+ {showAccountSafetyWarning && } {selectedVariantData && parentAuthForVariant ? ( diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 2b192021..85a1cc19 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -7,6 +7,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react'; import { useOverview } from '@/hooks/use-overview'; import { useSharedSummary } from '@/hooks/use-shared'; +import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { cn } from '@/lib/utils'; import type { LucideIcon } from 'lucide-react'; @@ -165,6 +166,8 @@ export function HomePage() {
+ + {/* Configuration Warning */} {shared?.symlinkStatus && !shared.symlinkStatus.valid && ( 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'; From 0a5b12b46bd587d0651524da4ea6eb5f8d787201 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 18:05:53 +0700 Subject: [PATCH 25/31] docs(readme): document dashboard updates center - add direct updates hub URL under dashboard quick start - include Updates Center in dashboard capability overview --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a51427c1..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** From d47efc783ea606d6d9d4238d7b16bfadd0a1e25d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 18:14:14 +0700 Subject: [PATCH 26/31] fix(ui): align updates page with dashboard layout patterns - refactor updates route to full-height split layout like API/CLIProxy pages - move scrolling into internal panels via ScrollArea - keep announcements and matrix in consistent master-detail structure --- ui/src/pages/updates.tsx | 414 +++++++++++++++++++++++++-------------- 1 file changed, 270 insertions(+), 144 deletions(-) diff --git a/ui/src/pages/updates.tsx b/ui/src/pages/updates.tsx index 24f7cd2b..06d2597e 100644 --- a/ui/src/pages/updates.tsx +++ b/ui/src/pages/updates.tsx @@ -1,10 +1,11 @@ import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { BellRing, Filter, Megaphone, Search } from 'lucide-react'; +import { BellRing, ChevronRight, Filter, Megaphone, Search } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; import { SupportEntryCard } from '@/components/updates/support-entry-card'; import { SupportStatusBadge } from '@/components/updates/support-status-badge'; import { @@ -12,6 +13,7 @@ import { SUPPORT_NOTICES, SUPPORT_SCOPE_LABELS, formatCatalogDate, + type SupportNotice, type SupportScope, } from '@/lib/support-updates-catalog'; @@ -25,9 +27,77 @@ const SCOPE_FILTERS: { id: ScopeFilter; label: string }[] = [ { id: 'websearch', label: SUPPORT_SCOPE_LABELS.websearch }, ]; +const CORE_CONTRACT = [ + { + id: 'base-url', + title: 'Base URL', + detail: 'Source endpoint is explicit per target/provider/profile.', + }, + { + id: 'auth', + title: 'Auth', + detail: 'OAuth or token ownership is visible in one support matrix.', + }, + { + id: 'model', + title: 'Model', + detail: 'Default model behavior stays configurable and documented.', + }, +] as const; + +function NoticeListItem({ + notice, + isSelected, + onSelect, +}: { + notice: SupportNotice; + isSelected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + export function UpdatesPage() { const [scope, setScope] = useState('all'); const [query, setQuery] = useState(''); + const [selectedNoticeId, setSelectedNoticeId] = useState( + SUPPORT_NOTICES[0]?.id ?? null + ); + + const selectedNotice = useMemo( + () => SUPPORT_NOTICES.find((notice) => notice.id === selectedNoticeId) ?? SUPPORT_NOTICES[0], + [selectedNoticeId] + ); const filteredEntries = useMemo(() => { const queryValue = query.trim().toLowerCase(); @@ -55,160 +125,216 @@ export function UpdatesPage() { }); }, [scope, query]); + const scopeStats = useMemo( + () => + SCOPE_FILTERS.filter((filter) => filter.id !== 'all').map((filter) => ({ + id: filter.id, + label: filter.label, + count: CLI_SUPPORT_ENTRIES.filter((entry) => entry.scope === filter.id).length, + })), + [] + ); + return ( -
- - - - - CCS Updates Center - - - Release visibility for runtime support, CLIProxy providers, and integration readiness. - - - - - - - This page is data-driven. Update one catalog file to publish new support notices - across dashboard surfaces. - - - -
- - ccsd glm - - - ccs codex --target droid "your prompt" - - - ccs cliproxy create mycodex --provider codex --target droid - +
+
+
+
+ +

Updates Center

- - - -
-
-

Announcements

- {SUPPORT_NOTICES.length} published -
- -
- {SUPPORT_NOTICES.map((notice) => ( - - -
-
- {notice.title} - {notice.summary} -
- -
-

- {formatCatalogDate(notice.publishedAt)} -

-
- -
    - {notice.highlights.map((highlight) => ( -
  • {highlight}
  • - ))} -
- -
- {notice.routes.map((route) => ( - - {route.label} - - ))} -
-
-
- ))} -
-
- -
-
-
-

Support Matrix

-

- Search by CLI/provider and filter by support surface. -

-
- -
- +

+ Release visibility for target, provider, and support rollouts. +

+
+ setQuery(event.target.value)} - placeholder="Search by command, provider, or note" - className="pl-8" + placeholder="Search support matrix" + className="pl-8 h-9" />
-
- - - Scope: - - {SCOPE_FILTERS.map((filter) => ( - - ))} - {filteredEntries.length} entries -
- - {filteredEntries.length === 0 ? ( - - - No support entries match this filter. - - - ) : ( -
- {filteredEntries.map((entry) => ( - + +
+ {SUPPORT_NOTICES.map((notice) => ( + setSelectedNoticeId(notice.id)} + /> ))}
- )} -
+ - - - Maintainer Notes - - Keep update messaging in one place for future CLI expansions. - - - -

- Edit{' '} - - ui/src/lib/support-updates-catalog.ts - {' '} - to add new notices or support entries. -

-

- Home spotlight and this page consume the same catalog, so announcements stay consistent - without repeated UI edits. -

-
-
+
+
+ + {SUPPORT_NOTICES.length} notice{SUPPORT_NOTICES.length !== 1 ? 's' : ''} + + + {CLI_SUPPORT_ENTRIES.length} support entr + {CLI_SUPPORT_ENTRIES.length !== 1 ? 'ies' : 'y'} + +
+
+
+ +
+ {selectedNotice && ( +
+
+
+

{selectedNotice.title}

+

{selectedNotice.summary}

+
+ +
+ +
+ + Published {formatCatalogDate(selectedNotice.publishedAt)} +
+ +
    + {selectedNotice.highlights.map((highlight) => ( +
  • - {highlight}
  • + ))} +
+ +
+ {selectedNotice.routes.map((route) => ( + + {route.label} + + ))} +
+ +
+ {selectedNotice.commands.map((command) => ( + + {command} + + ))} +
+
+ )} + +
+
+ + + Support Matrix + + Filter by support area. Internal scroll keeps the page frame stable. + + + + +
+
+ + + Scope: + + {SCOPE_FILTERS.map((filter) => ( + + ))} + + {filteredEntries.length} match + +
+ +
+ + {filteredEntries.length === 0 ? ( +
+ No support entries match this filter. +
+ ) : ( +
+ {filteredEntries.map((entry) => ( + + ))} +
+ )} +
+
+
+
+
+ + + + Config Contract + + Every new CLI integration follows the same three configuration pillars. + + + + + +
+
+ {CORE_CONTRACT.map((item) => ( +
+

+ {item.title} +

+

{item.detail}

+
+ ))} +
+ +
+

+ Coverage by Scope +

+
+ {scopeStats.map((stat) => ( +
+ {stat.label} + + {stat.count} + +
+ ))} +
+
+ +
+ Update source of truth:{' '} + + ui/src/lib/support-updates-catalog.ts + +
+
+
+
+
+
+
+
); } From 473644564d4ef7785fe06bac3d6c6d5baef16ca3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 18:21:50 +0700 Subject: [PATCH 27/31] fix(ui): redesign updates page as changelog-first layout - shift /updates from support-matrix-heavy view to compact announcement workflow - reduce top section footprint and align structure with existing dashboard pages - enforce internal panel scrolling while keeping outer page frame fixed --- ui/src/pages/updates.tsx | 295 ++++++++++++++++++++------------------- 1 file changed, 150 insertions(+), 145 deletions(-) diff --git a/ui/src/pages/updates.tsx b/ui/src/pages/updates.tsx index 06d2597e..ffc85428 100644 --- a/ui/src/pages/updates.tsx +++ b/ui/src/pages/updates.tsx @@ -1,12 +1,12 @@ import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { BellRing, ChevronRight, Filter, Megaphone, Search } from 'lucide-react'; +import { CalendarClock, ChevronRight, Filter, Megaphone, Search, Sparkles } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; -import { SupportEntryCard } from '@/components/updates/support-entry-card'; import { SupportStatusBadge } from '@/components/updates/support-status-badge'; import { CLI_SUPPORT_ENTRIES, @@ -27,24 +27,6 @@ const SCOPE_FILTERS: { id: ScopeFilter; label: string }[] = [ { id: 'websearch', label: SUPPORT_SCOPE_LABELS.websearch }, ]; -const CORE_CONTRACT = [ - { - id: 'base-url', - title: 'Base URL', - detail: 'Source endpoint is explicit per target/provider/profile.', - }, - { - id: 'auth', - title: 'Auth', - detail: 'OAuth or token ownership is visible in one support matrix.', - }, - { - id: 'model', - title: 'Model', - detail: 'Default model behavior stays configurable and documented.', - }, -] as const; - function NoticeListItem({ notice, isSelected, @@ -59,29 +41,20 @@ function NoticeListItem({ type="button" onClick={onSelect} className={cn( - 'w-full rounded-lg border px-3 py-2.5 text-left transition-colors', + 'w-full rounded-lg border px-3 py-2 text-left transition-colors', isSelected ? 'border-primary/20 bg-primary/10' : 'border-transparent hover:border-border hover:bg-muted/70' )} >
-
+

{notice.title}

-

{notice.summary}

-
- - - {formatCatalogDate(notice.publishedAt)} - -
+

+ {formatCatalogDate(notice.publishedAt)} +

- +
); @@ -125,33 +98,23 @@ export function UpdatesPage() { }); }, [scope, query]); - const scopeStats = useMemo( - () => - SCOPE_FILTERS.filter((filter) => filter.id !== 'all').map((filter) => ({ - id: filter.id, - label: filter.label, - count: CLI_SUPPORT_ENTRIES.filter((entry) => entry.scope === filter.id).length, - })), - [] - ); - return ( -
-
+
+
-

Updates Center

+

Updates

- Release visibility for target, provider, and support rollouts. + Product announcements and release notes.

setQuery(event.target.value)} - placeholder="Search support matrix" + placeholder="Search updates or integrations" className="pl-8 h-9" />
@@ -176,67 +139,39 @@ export function UpdatesPage() { {SUPPORT_NOTICES.length} notice{SUPPORT_NOTICES.length !== 1 ? 's' : ''} - {CLI_SUPPORT_ENTRIES.length} support entr - {CLI_SUPPORT_ENTRIES.length !== 1 ? 'ies' : 'y'} + {filteredEntries.length} result{filteredEntries.length !== 1 ? 's' : ''}
-
+
{selectedNotice && ( -
+
-

{selectedNotice.title}

-

{selectedNotice.summary}

+

{selectedNotice.title}

+

{selectedNotice.summary}

- -
- - Published {formatCatalogDate(selectedNotice.publishedAt)} -
- -
    - {selectedNotice.highlights.map((highlight) => ( -
  • - {highlight}
  • - ))} -
- -
- {selectedNotice.routes.map((route) => ( - - {route.label} - - ))} -
- -
- {selectedNotice.commands.map((command) => ( - - {command} - - ))} +
+ + Published {formatCatalogDate(selectedNotice.publishedAt)}
)} -
-
- +
+
+ - Support Matrix +
+ + Release Details +
- Filter by support area. Internal scroll keeps the page frame stable. + Changelog-first view with impacted integrations and quick commands.
@@ -245,7 +180,7 @@ export function UpdatesPage() {
- Scope: + Filter: {SCOPE_FILTERS.map((filter) => (
-
- - {filteredEntries.length === 0 ? ( -
- No support entries match this filter. -
- ) : ( -
- {filteredEntries.map((entry) => ( - + +
+
+

+ What Changed +

+
    + {selectedNotice?.highlights.map((highlight) => ( +
  • - {highlight}
  • + ))} +
+
+ +
+

+ Dashboard Entry Points +

+
+ {selectedNotice?.routes.map((route) => ( + + {route.label} + ))}
- )} - -
+ + +
+

+ Quick Commands +

+
+ {selectedNotice?.commands.map((command) => ( + + {command} + + ))} +
+
+ +
+

+ Impacted Integrations +

+ {filteredEntries.length === 0 ? ( +
+ No integration entries match your current filter. +
+ ) : ( +
+ {filteredEntries.map((entry) => ( +
+
+
+

{entry.name}

+

{entry.summary}

+
+ +
+
+ + {SUPPORT_SCOPE_LABELS[entry.scope]} + + {entry.routes.map((route) => ( + + {route.label} + + ))} +
+ + {entry.commands[0]} + +
+ ))} +
+ )} +
+
+
- + - Config Contract - - Every new CLI integration follows the same three configuration pillars. - + Announcement Timeline + Recent notices in chronological order. - -
-
- {CORE_CONTRACT.map((item) => ( -
-

- {item.title} -

-

{item.detail}

+
+ {SUPPORT_NOTICES.map((notice) => ( +
+

{notice.summary}

+

+ {formatCatalogDate(notice.publishedAt)} +

+ + ))} -
-

- Coverage by Scope -

-
- {scopeStats.map((stat) => ( -
- {stat.label} - - {stat.count} - -
- ))} -
-
- -
+
Update source of truth:{' '} ui/src/lib/support-updates-catalog.ts From 1698eadc943fbeca97e32a5857f95507fe62106e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 18:27:28 +0700 Subject: [PATCH 28/31] fix(ui): remove nested sidebar list markup --- ui/src/components/layout/app-sidebar.tsx | 59 +++++++++++------------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index c0a309b3..ed4631a4 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -172,38 +172,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} + + + + ))} + + ) : ( Date: Wed, 25 Feb 2026 20:06:02 +0700 Subject: [PATCH 29/31] feat(ui): redesign updates center into action inbox - replace release-note-heavy layout with task-first action inbox - add persistent local notice progress states (new/seen/done/dismissed) - introduce actionable notice metadata and related integration mapping - keep full-page shell fixed with internal scrolling only --- .../updates/notice-progress-badge.tsx | 50 ++ .../updates/updates-details-panel.tsx | 157 ++++++ .../components/updates/updates-inbox-item.tsx | 45 ++ .../updates/updates-notice-action-row.tsx | 38 ++ ui/src/lib/support-updates-catalog.ts | 75 +++ ui/src/lib/updates-notice-state.ts | 63 +++ ui/src/pages/updates.tsx | 448 ++++++------------ 7 files changed, 574 insertions(+), 302 deletions(-) create mode 100644 ui/src/components/updates/notice-progress-badge.tsx create mode 100644 ui/src/components/updates/updates-details-panel.tsx create mode 100644 ui/src/components/updates/updates-inbox-item.tsx create mode 100644 ui/src/components/updates/updates-notice-action-row.tsx create mode 100644 ui/src/lib/updates-notice-state.ts 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/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..5bbe1e37 --- /dev/null +++ b/ui/src/components/updates/updates-notice-action-row.tsx @@ -0,0 +1,38 @@ +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 }) { + const isRouteAction = action.type === 'route' && action.path; + const isCommandAction = action.type === 'command' && action.command; + + return ( +
+
+
+

{action.label}

+

{action.description}

+
+ + {isRouteAction && ( + + )} +
+ + {isCommandAction && ( +
+ + {action.command} + +
+ )} +
+ ); +} diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index 195bc992..d0f2d86f 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -7,13 +7,26 @@ export interface SupportRouteHint { 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[]; } @@ -47,13 +60,48 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ 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' }, @@ -69,13 +117,32 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ title: 'Updates Center added to dashboard navigation', summary: 'CCS now has a dedicated updates route so support announcements are visible and reusable.', + primaryAction: 'Use this page as your action inbox, then close updates when done.', 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 Home spotlight and Updates Center page.', 'New support entries can be added without touching multiple pages.', 'Catalog includes targets, CLIProxy providers, and WebSearch integrations.', ], + actions: [ + { + id: 'open-updates-page', + label: 'Review new support updates', + description: 'Work through pending notices and mark them done when configured.', + 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 Center', path: '/updates' }], commands: ['ccs config'], }, @@ -199,6 +266,14 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ }, ]; +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; 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/updates.tsx b/ui/src/pages/updates.tsx index ffc85428..2ce8c9d1 100644 --- a/ui/src/pages/updates.tsx +++ b/ui/src/pages/updates.tsx @@ -1,345 +1,189 @@ -import { useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { CalendarClock, ChevronRight, Filter, Megaphone, Search, Sparkles } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Megaphone, Search } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { cn } from '@/lib/utils'; -import { SupportStatusBadge } from '@/components/updates/support-status-badge'; +import { UpdatesDetailsPanel } from '@/components/updates/updates-details-panel'; +import { UpdatesInboxItem } from '@/components/updates/updates-inbox-item'; import { - CLI_SUPPORT_ENTRIES, SUPPORT_NOTICES, - SUPPORT_SCOPE_LABELS, - formatCatalogDate, + getSupportEntriesForNotice, type SupportNotice, - type SupportScope, } from '@/lib/support-updates-catalog'; +import { + getNoticeProgress, + isActionableNoticeState, + readNoticeProgressMap, + writeNoticeProgressMap, + type NoticeProgressMap, +} from '@/lib/updates-notice-state'; -type ScopeFilter = 'all' | SupportScope; +type NoticeViewMode = 'inbox' | 'done' | 'all'; -const SCOPE_FILTERS: { id: ScopeFilter; label: string }[] = [ +const NOTICE_VIEW_MODES: { id: NoticeViewMode; label: string }[] = [ + { id: 'inbox', label: 'Action Required' }, + { id: 'done', label: 'Done' }, { id: 'all', label: 'All' }, - { id: 'target', label: SUPPORT_SCOPE_LABELS.target }, - { id: 'cliproxy', label: SUPPORT_SCOPE_LABELS.cliproxy }, - { id: 'api-profiles', label: SUPPORT_SCOPE_LABELS['api-profiles'] }, - { id: 'websearch', label: SUPPORT_SCOPE_LABELS.websearch }, ]; -function NoticeListItem({ - notice, - isSelected, - onSelect, -}: { - notice: SupportNotice; - isSelected: boolean; - onSelect: () => void; -}) { - return ( - - ); +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 [scope, setScope] = useState('all'); + const notices = useMemo( + () => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)), + [] + ); + const [viewMode, setViewMode] = useState('inbox'); const [query, setQuery] = useState(''); - const [selectedNoticeId, setSelectedNoticeId] = useState( - SUPPORT_NOTICES[0]?.id ?? null - ); + const [progressMap, setProgressMap] = useState(() => readNoticeProgressMap()); + const [selectedNoticeId, setSelectedNoticeId] = useState(null); - const selectedNotice = useMemo( - () => SUPPORT_NOTICES.find((notice) => notice.id === selectedNoticeId) ?? SUPPORT_NOTICES[0], - [selectedNoticeId] - ); + useEffect(() => { + writeNoticeProgressMap(progressMap); + }, [progressMap]); - const filteredEntries = useMemo(() => { + const visibleNotices = useMemo(() => { const queryValue = query.trim().toLowerCase(); - return CLI_SUPPORT_ENTRIES.filter((entry) => { - if (scope !== 'all' && entry.scope !== scope) { - return false; - } - - if (!queryValue) { - return true; - } - - const haystack = [ - entry.name, - entry.summary, - entry.notes || '', - ...entry.commands, - ...entry.routes.map((route) => route.label), - ] - .join(' ') - .toLowerCase(); - - return haystack.includes(queryValue); + 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; }); - }, [scope, query]); + }, [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

+
+
+
+ +

Updates Inbox

+
+

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

-

- Product announcements and release notes. -

+ +
+
+

Needs Action

+

{pendingCount}

+
+
+

Done

+

{doneCount}

+
+
+
setQuery(event.target.value)} - placeholder="Search updates or integrations" - className="pl-8 h-9" + placeholder="Search actions or commands" + className="h-9 pl-8" />
+ +
+ {NOTICE_VIEW_MODES.map((mode) => ( + + ))} +
-
- {SUPPORT_NOTICES.map((notice) => ( - setSelectedNoticeId(notice.id)} - /> - ))} +
+ {visibleNotices.length === 0 ? ( +
+ No notices match this view. +
+ ) : ( + visibleNotices.map((notice) => ( + handleSelectNotice(notice)} + /> + )) + )}
- -
-
- - {SUPPORT_NOTICES.length} notice{SUPPORT_NOTICES.length !== 1 ? 's' : ''} - - - {filteredEntries.length} result{filteredEntries.length !== 1 ? 's' : ''} - -
-
-
- {selectedNotice && ( -
-
-
-

{selectedNotice.title}

-

{selectedNotice.summary}

-
- -
-
- - Published {formatCatalogDate(selectedNotice.publishedAt)} -
-
- )} - -
-
- - -
- - Release Details -
- - Changelog-first view with impacted integrations and quick commands. - -
- - -
-
- - - Filter: - - {SCOPE_FILTERS.map((filter) => ( - - ))} - - {filteredEntries.length} integration{filteredEntries.length !== 1 ? 's' : ''} - -
- - -
-
-

- What Changed -

-
    - {selectedNotice?.highlights.map((highlight) => ( -
  • - {highlight}
  • - ))} -
-
- -
-

- Dashboard Entry Points -

-
- {selectedNotice?.routes.map((route) => ( - - {route.label} - - ))} -
-
- -
-

- Quick Commands -

-
- {selectedNotice?.commands.map((command) => ( - - {command} - - ))} -
-
- -
-

- Impacted Integrations -

- {filteredEntries.length === 0 ? ( -
- No integration entries match your current filter. -
- ) : ( -
- {filteredEntries.map((entry) => ( -
-
-
-

{entry.name}

-

{entry.summary}

-
- -
-
- - {SUPPORT_SCOPE_LABELS[entry.scope]} - - {entry.routes.map((route) => ( - - {route.label} - - ))} -
- - {entry.commands[0]} - -
- ))} -
- )} -
-
-
-
-
-
- - - - Announcement Timeline - Recent notices in chronological order. - - - -
- {SUPPORT_NOTICES.map((notice) => ( - - ))} - -
- Update source of truth:{' '} - - ui/src/lib/support-updates-catalog.ts - -
-
-
-
-
-
-
-
+ { + if (!selectedNotice) return; + setProgressMap((previous) => ({ ...previous, [selectedNotice.id]: nextState })); + }} + />
); } From ae7ab59746172419eba5b7a5ab9773e7e0c199a7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 20:07:05 +0700 Subject: [PATCH 30/31] fix(ui): tighten updates action row type narrowing --- ui/src/components/updates/updates-notice-action-row.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/ui/src/components/updates/updates-notice-action-row.tsx b/ui/src/components/updates/updates-notice-action-row.tsx index 5bbe1e37..0ac130f0 100644 --- a/ui/src/components/updates/updates-notice-action-row.tsx +++ b/ui/src/components/updates/updates-notice-action-row.tsx @@ -5,9 +5,6 @@ import { CopyButton } from '@/components/ui/copy-button'; import { type SupportNoticeAction } from '@/lib/support-updates-catalog'; export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) { - const isRouteAction = action.type === 'route' && action.path; - const isCommandAction = action.type === 'command' && action.command; - return (
@@ -16,7 +13,7 @@ export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction

{action.description}

- {isRouteAction && ( + {action.type === 'route' && action.path && (
- {isCommandAction && ( + {action.type === 'command' && action.command && (
{action.command} From 69378ada3e779e8e731b4681486648a166482beb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 20:14:41 +0700 Subject: [PATCH 31/31] fix(ui): de-surface updates center from primary navigation - remove Updates Center from sidebar general nav - remove updates spotlight banners from home, api, and cliproxy pages - adjust updates catalog copy to reflect on-demand inbox usage --- ui/src/components/layout/app-sidebar.tsx | 2 -- ui/src/lib/support-updates-catalog.ts | 19 ++++++++++--------- ui/src/pages/api.tsx | 5 ----- ui/src/pages/cliproxy.tsx | 2 -- ui/src/pages/home.tsx | 3 --- 5 files changed, 10 insertions(+), 21 deletions(-) diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index ed4631a4..47fcc1fa 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -11,7 +11,6 @@ import { BarChart3, Gauge, Github, - Megaphone, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { @@ -68,7 +67,6 @@ const navGroups: SidebarGroupDef[] = [ title: 'General', items: [ { path: '/', icon: Home, label: 'Home' }, - { path: '/updates', icon: Megaphone, label: 'Updates Center' }, { path: '/analytics', icon: BarChart3, label: 'Analytics' }, ], }, diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index d0f2d86f..aa9cee80 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -114,24 +114,25 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ }, { id: 'updates-center-launch', - title: 'Updates Center added to dashboard navigation', + title: 'Updates inbox is available for rollout tasks', summary: - 'CCS now has a dedicated updates route so support announcements are visible and reusable.', - primaryAction: 'Use this page as your action inbox, then close updates when done.', + '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 Home spotlight and Updates Center page.', - 'New support entries can be added without touching multiple pages.', - 'Catalog includes targets, CLIProxy providers, and WebSearch integrations.', + '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: 'Review new support updates', - description: 'Work through pending notices and mark them done when configured.', + label: 'Open updates inbox when needed', + description: 'Review rollout tasks only when you want guided setup changes.', type: 'route', path: '/updates', }, @@ -143,7 +144,7 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ command: 'ccs config', }, ], - routes: [{ label: 'Updates Center', path: '/updates' }], + routes: [{ label: 'Updates Inbox', path: '/updates' }], commands: ['ccs config'], }, ]; diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index dc09dfbb..1d56ac11 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -23,7 +23,6 @@ import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog import { OpenRouterBanner } from '@/components/profiles/openrouter-banner'; import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start'; import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card'; -import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; import { useOpenRouterModels } from '@/hooks/use-openrouter-models'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; @@ -97,10 +96,6 @@ export function ApiPage() { {/* OpenRouter Announcement Banner */} setCreateDialogOpen(true)} /> -
- -
- {/* Main Content */}
{/* Left Panel - Profiles List */} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index d79b3c76..e75d645c 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -17,7 +17,6 @@ import { AccountSafetyWarningCard } from '@/components/account/account-safety-wa import { ProviderEditor } from '@/components/cliproxy/provider-editor'; import { ProviderLogo } from '@/components/cliproxy/provider-logo'; import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; -import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { useCliproxy, useCliproxyAuth, @@ -398,7 +397,6 @@ export function CliproxyPage() { {/* Right Panel */}
- {showAccountSafetyWarning && } {selectedVariantData && parentAuthForVariant ? ( diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 85a1cc19..2b192021 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -7,7 +7,6 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react'; import { useOverview } from '@/hooks/use-overview'; import { useSharedSummary } from '@/hooks/use-shared'; -import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { cn } from '@/lib/utils'; import type { LucideIcon } from 'lucide-react'; @@ -166,8 +165,6 @@ export function HomePage() {
- - {/* Configuration Warning */} {shared?.symlinkStatus && !shared.symlinkStatus.valid && (