Merge pull request #630 from kaitranntt/kai/feat/ccsd-config-support

feat(target): add ccsd target config parity for API and CLIProxy
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-25 08:20:36 -05:00
committed by GitHub
57 changed files with 2579 additions and 180 deletions
+27
View File
@@ -53,6 +53,8 @@ ccs config
# Opens http://localhost:3000
```
Dashboard updates hub: `http://localhost:3000/updates`
Want to run the dashboard in Docker? See `docker/README.md`.
### 3. Configure Your Accounts
@@ -62,6 +64,7 @@ The dashboard provides visual management for all account types:
- **Claude Accounts**: Create isolated instances (work, personal, client)
- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot
- **API Profiles**: Configure GLM, Kimi with your keys
- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations)
- **Health Monitor**: Real-time status across all profiles
**Analytics Dashboard**
@@ -154,6 +157,30 @@ ccsd glm
Need additional alias names? Set `CCS_DROID_ALIASES` as a comma-separated list (for example: `CCS_DROID_ALIASES=ccs-droid,mydroid`).
### Per-Profile Target Defaults
You can pin a default target (`claude` or `droid`) per profile:
```bash
# API profile defaults to Droid
ccs api create myglm --preset glm --target droid
# CLIProxy variant defaults to Droid
ccs cliproxy create mycodex --provider codex --target droid
```
Built-in CLIProxy providers also work with Droid alias/target override:
```bash
ccsd codex
ccsd agy
ccs codex --target droid
```
Dashboard parity:
- `ccs config` -> `API Profiles` -> set **Default Target**
- `ccs config` -> `CLIProxy` -> create/edit variant -> set **Default Target**
### Kiro Auth Methods
`ccs kiro --auth` defaults to AWS Builder ID Device OAuth (best support for AWS org accounts).
+2 -1
View File
@@ -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';
+17 -1
View File
@@ -9,8 +9,18 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import type { TargetType } from '../../targets/target-adapter';
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
const VALID_TARGETS: ReadonlySet<TargetType> = new Set<TargetType>(['claude', 'droid']);
function sanitizeTarget(target: unknown): TargetType {
if (typeof target === 'string' && VALID_TARGETS.has(target as TargetType)) {
return target as TargetType;
}
return 'claude';
}
/**
* Check if API profile exists in config
*/
@@ -68,6 +78,7 @@ export function listApiProfiles(): ApiListResult {
settingsPath: profile.settings || 'config.yaml',
isConfigured: isApiProfileConfigured(name),
configSource: 'unified',
target: sanitizeTarget(profile.target),
});
}
// CLIProxy variants
@@ -80,10 +91,13 @@ export function listApiProfiles(): ApiListResult {
name,
provider,
settings: variant?.settings || '-',
target: sanitizeTarget(variant?.target),
});
}
} else {
const config = loadConfigSafe();
const legacyTargetMap = (config as { profile_targets?: Record<string, unknown> })
.profile_targets;
for (const [name, settingsPath] of Object.entries(config.profiles)) {
// Skip 'default' profile - it's the user's native Claude settings
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
@@ -94,16 +108,18 @@ export function listApiProfiles(): ApiListResult {
settingsPath: settingsPath as string,
isConfigured: isApiProfileConfigured(name),
configSource: 'legacy',
target: sanitizeTarget(legacyTargetMap?.[name]),
});
}
// CLIProxy variants
if (config.cliproxy) {
for (const [name, v] of Object.entries(config.cliproxy)) {
const variant = v as { provider: string; settings: string };
const variant = v as { provider: string; settings: string; target?: unknown };
variants.push({
name,
provider: variant.provider,
settings: variant.settings,
target: sanitizeTarget(variant.target),
});
}
}
+11
View File
@@ -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;
}
+89 -7
View File
@@ -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<string, string>; cliproxy?: Record<string, unknown> };
let config: {
profiles: Record<string, string>;
cliproxy?: Record<string, unknown>;
profile_targets?: Record<string, TargetType>;
};
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<string, string>;
cliproxy?: Record<string, unknown>;
profile_targets?: Record<string, TargetType>;
};
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch {
config = { profiles: {} };
}
if (!config.profiles[name]) {
return { success: false, error: `API profile not found: ${name}` };
}
config.profile_targets = config.profile_targets || {};
if (target === 'claude') {
delete config.profile_targets[name];
} else {
config.profile_targets[name] = target;
}
const tempPath = configPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
fs.renameSync(tempPath, configPath);
return { success: true, target };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/** Remove API profile from unified config */
function removeApiProfileUnified(name: string): void {
const config = loadOrCreateUnifiedConfig();
@@ -175,6 +251,12 @@ function removeApiProfileUnified(name: string): void {
function removeApiProfileLegacy(name: string): void {
const config = loadConfigSafe();
delete config.profiles[name];
if (config.profile_targets) {
delete config.profile_targets[name];
if (Object.keys(config.profile_targets).length === 0) {
delete config.profile_targets;
}
}
const configPath = getConfigPath();
const tempPath = configPath + '.tmp';
+7
View File
@@ -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<string, TargetType> })
.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<string, TargetType> })
.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'],
};
}
+144 -3
View File
@@ -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<void> {
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<void> {
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 <provider> --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,
+29 -40
View File
@@ -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<number>();
@@ -89,9 +73,6 @@ export function getNextAvailablePort(): number {
);
}
/**
* List variants from config
*/
export function listVariantsFromConfig(): Record<string, VariantConfig> {
try {
if (isUnifiedMode()) {
@@ -139,6 +120,7 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
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<string, VariantConfig> {
settings: single.settings,
account: single.account,
port: single.port,
target: single.target || 'claude',
};
}
} catch (error) {
@@ -171,12 +154,14 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
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<string, VariantConfig> {
}
}
/**
* 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) {
+34 -9
View File
@@ -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<Record<'opus' | 'sonnet' | 'haiku', CompositeTierConfig>>;
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) {
+17 -2
View File
@@ -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<string, string>;
}
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<string, string> | undefined;
try {
+69 -10
View File
@@ -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<string>(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<void> {
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<void> {
`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<void> {
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<void> {
// 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<void> {
// 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<void> {
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
console.log(` ${color('--model <model>', 'command')} Default model (create)`);
console.log(
` ${color('--target <cli>', '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<void> {
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')}`);
+5 -2
View File
@@ -45,10 +45,13 @@ export async function handleList(): Promise<void> {
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)`));
+1
View File
@@ -81,6 +81,7 @@ export async function showHelp(): Promise<void> {
'Options:',
[
['--backend <type>', 'Use specific backend: original | plus (default: from config)'],
['--target <cli>', 'Default target for created/edited variants: claude | droid'],
['--verbose, -v', 'Show detailed quota fetch diagnostics'],
],
],
+181 -15
View File
@@ -12,6 +12,7 @@ import { triggerOAuth } from '../../cliproxy/auth/oauth-handler';
import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog';
import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types';
import type { TargetType } from '../../targets/target-adapter';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { initUI, header, color, ok, fail, warn, info, infoBox, dim } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
@@ -32,28 +33,66 @@ interface CliproxyProfileArgs {
provider?: CLIProxyProfileName;
model?: string;
account?: string;
target?: TargetType;
force?: boolean;
yes?: boolean;
composite?: boolean;
errors: string[];
}
function parseProfileArgs(args: string[]): CliproxyProfileArgs {
const result: CliproxyProfileArgs = {};
function parseTargetValue(rawValue: string): TargetType | null {
const normalized = rawValue.trim().toLowerCase();
if (normalized === 'claude' || normalized === 'droid') {
return normalized;
}
return null;
}
export function parseProfileArgs(args: string[]): CliproxyProfileArgs {
const result: CliproxyProfileArgs = { errors: [] };
let parseOptions = true;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--provider' && args[i + 1]) {
if (parseOptions && arg === '--') {
parseOptions = false;
continue;
}
if (parseOptions && arg === '--provider' && args[i + 1]) {
result.provider = args[++i] as CLIProxyProfileName;
} else if (arg === '--model' && args[i + 1]) {
} else if (parseOptions && arg === '--model' && args[i + 1]) {
result.model = args[++i];
} else if (arg === '--account' && args[i + 1]) {
} else if (parseOptions && arg === '--account' && args[i + 1]) {
result.account = args[++i];
} else if (arg === '--force') {
} else if (parseOptions && arg === '--target') {
const rawValue = args[i + 1];
if (!rawValue || rawValue.startsWith('-')) {
result.errors.push('Missing value for --target');
} else {
i += 1;
const parsedTarget = parseTargetValue(rawValue);
if (!parsedTarget) {
result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`);
} else {
result.target = parsedTarget;
}
}
} else if (parseOptions && arg.startsWith('--target=')) {
const rawValue = arg.slice('--target='.length);
const parsedTarget = parseTargetValue(rawValue);
if (!parsedTarget) {
result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`);
} else {
result.target = parsedTarget;
}
} else if (parseOptions && arg === '--force') {
result.force = true;
} else if (arg === '--yes' || arg === '-y') {
} else if (parseOptions && (arg === '--yes' || arg === '-y')) {
result.yes = true;
} else if (arg === '--composite') {
} else if (parseOptions && arg === '--composite') {
result.composite = true;
} else if (!arg.startsWith('-') && !result.name) {
} else if ((!parseOptions || !arg.startsWith('-')) && !result.name) {
result.name = arg;
}
}
@@ -145,6 +184,11 @@ export async function handleCreate(
): Promise<void> {
await initUI();
const parsedArgs = parseProfileArgs(args);
if (parsedArgs.errors.length > 0) {
parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exitCode = 1;
return;
}
console.log(header(`Create ${getBackendLabel(backend)} Variant`));
console.log('');
@@ -168,6 +212,17 @@ export async function handleCreate(
process.exit(1);
}
let resolvedTarget: TargetType = parsedArgs.target || 'claude';
if (!parsedArgs.target && !parsedArgs.yes) {
const useDroidByDefault = await InteractivePrompt.confirm(
'Set default target to Factory Droid for this variant?',
{ default: false }
);
if (useDroidByDefault) {
resolvedTarget = 'droid';
}
}
// Composite mode: select provider+model per tier
if (parsedArgs.composite) {
console.log(info('Composite variant — select provider and model for each tier'));
@@ -203,6 +258,7 @@ export async function handleCreate(
const result = createCompositeVariant({
name,
defaultTier,
target: resolvedTarget,
tiers: { opus, sonnet, haiku },
});
@@ -217,7 +273,8 @@ export async function handleCreate(
? `Opus: ${tiers.opus.provider} / ${tiers.opus.model}\n` +
`Sonnet: ${tiers.sonnet.provider} / ${tiers.sonnet.model}\n` +
`Haiku: ${tiers.haiku.provider} / ${tiers.haiku.model}\n` +
`Default: ${defaultTier}`
`Default: ${defaultTier}\n` +
`Target: ${resolvedTarget}`
: '';
const portInfo = result.variant?.port ? `\nPort: ${result.variant.port}` : '';
console.log(
@@ -228,7 +285,24 @@ export async function handleCreate(
);
console.log('');
console.log(header('Usage'));
console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
if (resolvedTarget === 'droid') {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
);
console.log(
` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
);
console.log(
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
);
} else {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}`
);
console.log(
` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}`
);
}
console.log('');
return;
}
@@ -350,7 +424,7 @@ export async function handleCreate(
// Create variant
console.log('');
console.log(info(`Creating ${getBackendLabel(backend)} variant...`));
const result = createVariant(name, provider, model, account);
const result = createVariant(name, provider, model, account, resolvedTarget);
if (!result.success) {
console.log(fail(`Failed to create variant: ${result.error}`));
@@ -367,13 +441,30 @@ export async function handleCreate(
const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : '';
console.log(
infoBox(
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
configType
)
);
console.log('');
console.log(header('Usage'));
console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
if (resolvedTarget === 'droid') {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
);
console.log(
` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
);
console.log(
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
);
} else {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}`
);
console.log(
` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}`
);
}
console.log('');
console.log(dim('To change model later:'));
console.log(` ${color(`ccs ${name} --config`, 'command')}`);
@@ -383,6 +474,11 @@ export async function handleCreate(
export async function handleRemove(args: string[]): Promise<void> {
await initUI();
const parsedArgs = parseProfileArgs(args);
if (parsedArgs.errors.length > 0) {
parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exitCode = 1;
return;
}
const variants = listVariants();
const variantNames = Object.keys(variants);
@@ -435,6 +531,7 @@ export async function handleRemove(args: string[]): Promise<void> {
if (variant.port) {
console.log(` Port: ${variant.port}`);
}
console.log(` Target: ${variant.target || 'claude'}`);
console.log(` Settings: ${variant.settings || '-'}`);
console.log('');
@@ -461,6 +558,11 @@ export async function handleEdit(
): Promise<void> {
await initUI();
const parsedArgs = parseProfileArgs(args);
if (parsedArgs.errors.length > 0) {
parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exitCode = 1;
return;
}
const variants = listVariants();
const variantNames = Object.keys(variants);
@@ -501,12 +603,14 @@ export async function handleEdit(
// If not composite, use existing updateVariant() flow (interactive prompts)
if (variant.type !== 'composite') {
const currentTarget: TargetType = variant.target || 'claude';
console.log(header(`Edit Variant: ${name}`));
console.log('');
console.log(`Current provider: ${variant.provider}`);
if (variant.model) {
console.log(`Current model: ${variant.model}`);
}
console.log(`Current target: ${currentTarget}`);
console.log('');
const changeProvider = await InteractivePrompt.confirm('Change provider?', { default: false });
@@ -552,6 +656,22 @@ export async function handleEdit(
}
}
let newTarget: TargetType | undefined = parsedArgs.target;
if (!parsedArgs.target) {
const changeTarget = await InteractivePrompt.confirm('Change default target?', {
default: false,
});
if (changeTarget) {
const targetOptions = [
{ id: 'claude', label: 'Claude Code' },
{ id: 'droid', label: 'Factory Droid' },
];
newTarget = (await InteractivePrompt.selectFromList('Select target:', targetOptions, {
defaultIndex: currentTarget === 'droid' ? 1 : 0,
})) as TargetType;
}
}
console.log('');
console.log(info(`Updating ${getBackendLabel(backend)} variant...`));
// Use existing updateVariant from variant-service for single-provider variants
@@ -559,6 +679,7 @@ export async function handleEdit(
const result = updateVariant(name, {
provider: newProvider,
model: changeModel ? newModel : undefined,
target: newTarget,
});
if (!result.success) {
@@ -566,13 +687,35 @@ export async function handleEdit(
process.exit(1);
}
const resolvedTarget = result.variant?.target || currentTarget;
console.log('');
console.log(ok(`Variant updated: ${name}`));
console.log('');
console.log(header('Usage'));
if (resolvedTarget === 'droid') {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
);
console.log(
` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
);
console.log(
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
);
} else {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}`
);
console.log(
` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}`
);
}
console.log('');
return;
}
// Composite variant edit flow
const compositeCurrentTarget: TargetType = variant.target || 'claude';
console.log(header(`Edit Composite Variant: ${name}`));
console.log('');
if (!variant.tiers) {
@@ -585,6 +728,7 @@ export async function handleEdit(
console.log(` Sonnet: ${variant.tiers.sonnet.provider} / ${variant.tiers.sonnet.model}`);
console.log(` Haiku: ${variant.tiers.haiku.provider} / ${variant.tiers.haiku.model}`);
console.log(` Default: ${variant.default_tier}`);
console.log(` Target: ${compositeCurrentTarget}`);
console.log('');
const verbose = args.includes('--verbose');
@@ -634,11 +778,32 @@ export async function handleEdit(
)) as 'opus' | 'sonnet' | 'haiku';
}
let newCompositeTarget: TargetType | undefined = parsedArgs.target;
if (!parsedArgs.target) {
const changeTarget = await InteractivePrompt.confirm('Change default target?', {
default: false,
});
if (changeTarget) {
const targetOptions = [
{ id: 'claude', label: 'Claude Code' },
{ id: 'droid', label: 'Factory Droid' },
];
newCompositeTarget = (await InteractivePrompt.selectFromList(
'Select target:',
targetOptions,
{
defaultIndex: compositeCurrentTarget === 'droid' ? 1 : 0,
}
)) as TargetType;
}
}
console.log('');
console.log(info(`Updating composite ${getBackendLabel(backend)} variant...`));
const result = updateCompositeVariant(name, {
tiers: updatedTiers,
defaultTier: changeDefault ? newDefaultTier : undefined,
target: newCompositeTarget,
});
if (!result.success) {
@@ -653,7 +818,8 @@ export async function handleEdit(
`Opus: ${finalVariant.tiers.opus.provider} / ${finalVariant.tiers.opus.model}\n` +
`Sonnet: ${finalVariant.tiers.sonnet.provider} / ${finalVariant.tiers.sonnet.model}\n` +
`Haiku: ${finalVariant.tiers.haiku.provider} / ${finalVariant.tiers.haiku.model}\n` +
`Default: ${finalVariant.default_tier}`;
`Default: ${finalVariant.default_tier}\n` +
`Target: ${finalVariant.target || compositeCurrentTarget}`;
const portInfo = finalVariant.port ? `\nPort: ${finalVariant.port}` : '';
console.log(
infoBox(
+9 -1
View File
@@ -326,6 +326,12 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
printSubSection('Multi-Target', [
['ccs glm --target droid', 'Run GLM profile on Droid CLI'],
['ccsd glm', 'Same as above (alias)'],
['ccsd codex', 'Run built-in CLIProxy Codex profile on Droid'],
['ccsd agy', 'Run built-in CLIProxy Antigravity profile on Droid'],
[
'ccs cliproxy create my-codex --provider codex --target droid',
'Create CLIProxy variant with Droid as default target',
],
['ccs glm', 'Run GLM profile on Claude Code (default)'],
]);
@@ -347,7 +353,9 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['', ''], // Spacer
['ccs cliproxy pause <p> <a>', 'Pause account from rotation'],
['ccs cliproxy resume <p> <a>', '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 <name>', 'Show quota/tier/pause status for one provider'],
]);
// CLI Proxy configuration flags (new)
+4 -6
View File
@@ -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';
}
}
+3 -3
View File
@@ -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`
);
}
}
+7 -3
View File
@@ -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)
+5
View File
@@ -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<string, TargetType>;
/** User-defined CLIProxy profile variants (optional) */
cliproxy?: CLIProxyVariantsConfig;
}
+86 -12
View File
@@ -7,12 +7,34 @@
import { Router, Request, Response } from 'express';
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer';
import {
createApiProfile,
removeApiProfile,
updateApiProfileTarget,
} from '../../api/services/profile-writer';
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
import type { TargetType } from '../../targets/target-adapter';
import { updateSettingsFile } from './route-helpers';
const router = Router();
export function parseTarget(rawTarget: unknown): TargetType | null {
if (rawTarget === undefined || rawTarget === null || rawTarget === '') {
return null;
}
if (typeof rawTarget !== 'string') {
return null;
}
const normalized = rawTarget.trim().toLowerCase();
if (normalized === 'claude' || normalized === 'droid') {
return normalized;
}
return null;
}
// ==================== Profile CRUD ====================
/**
@@ -26,6 +48,7 @@ router.get('/', (_req: Request, res: Response): void => {
name: p.name,
settingsPath: p.settingsPath,
configured: p.isConfigured,
target: p.target,
}));
res.json({ profiles });
} catch (error) {
@@ -37,7 +60,13 @@ router.get('/', (_req: Request, res: Response): void => {
* POST /api/profiles - Create new profile
*/
router.post('/', (req: Request, res: Response): void => {
const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body;
const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
const parsedTarget = parseTarget(target);
if (target !== undefined && parsedTarget === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
if (!name || !baseUrl || !apiKey) {
res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' });
@@ -60,19 +89,29 @@ router.post('/', (req: Request, res: Response): void => {
}
// Create profile using unified-config-aware service
const result = createApiProfile(name, baseUrl, apiKey, {
default: model || '',
opus: opusModel || model || '',
sonnet: sonnetModel || model || '',
haiku: haikuModel || model || '',
});
const result = createApiProfile(
name,
baseUrl,
apiKey,
{
default: model || '',
opus: opusModel || model || '',
sonnet: sonnetModel || model || '',
haiku: haikuModel || model || '',
},
parsedTarget || 'claude'
);
if (!result.success) {
res.status(500).json({ error: result.error || 'Failed to create profile' });
return;
}
res.status(201).json({ name, settingsPath: result.settingsFile });
res.status(201).json({
name,
settingsPath: result.settingsFile,
target: parsedTarget || 'claude',
});
});
/**
@@ -80,7 +119,13 @@ router.post('/', (req: Request, res: Response): void => {
*/
router.put('/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body;
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
const parsedTarget = parseTarget(target);
if (target !== undefined && parsedTarget === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
// Check if profile exists (uses unified config when available)
if (!apiProfileExists(name)) {
@@ -99,8 +144,37 @@ router.put('/:name', (req: Request, res: Response): void => {
}
try {
updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel });
res.json({ name, updated: true });
const hasSettingsUpdates =
baseUrl !== undefined ||
apiKey !== undefined ||
model !== undefined ||
opusModel !== undefined ||
sonnetModel !== undefined ||
haikuModel !== undefined;
const hasTargetUpdate = target !== undefined;
if (!hasSettingsUpdates && !hasTargetUpdate) {
res.status(400).json({ error: 'No updates provided' });
return;
}
if (hasSettingsUpdates) {
updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel });
}
if (hasTargetUpdate && parsedTarget) {
const targetUpdate = updateApiProfileTarget(name, parsedTarget);
if (!targetUpdate.success) {
res.status(500).json({ error: targetUpdate.error || 'Failed to update target' });
return;
}
}
res.json({
name,
updated: true,
...(hasTargetUpdate && parsedTarget && { target: parsedTarget }),
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
+61 -6
View File
@@ -7,6 +7,7 @@
import { Router, Request, Response } from 'express';
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import type { CLIProxyProvider } from '../../cliproxy/types';
import type { TargetType } from '../../targets/target-adapter';
import {
createVariant,
removeVariant,
@@ -23,6 +24,23 @@ import {
const router = Router();
export function parseTarget(rawTarget: unknown): TargetType | null {
if (rawTarget === undefined || rawTarget === null || rawTarget === '') {
return null;
}
if (typeof rawTarget !== 'string') {
return null;
}
const normalized = rawTarget.trim().toLowerCase();
if (normalized === 'claude' || normalized === 'droid') {
return normalized;
}
return null;
}
/**
* GET /api/cliproxy - List cliproxy variants
* Uses variant-service for consistent behavior with CLI
@@ -36,6 +54,7 @@ router.get('/', (_req: Request, res: Response) => {
account: variant.account || 'default',
port: variant.port, // Include port for port isolation
model: variant.model,
target: variant.target || 'claude',
type: variant.type,
default_tier: variant.default_tier,
tiers: variant.tiers,
@@ -50,6 +69,12 @@ router.get('/', (_req: Request, res: Response) => {
*/
router.post('/', (req: Request, res: Response): void => {
const { name, provider, model, account, type, default_tier, tiers } = req.body;
const parsedTarget = parseTarget(req.body.target);
if (req.body.target !== undefined && parsedTarget === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
if (!name) {
res.status(400).json({ error: 'Missing required field: name' });
@@ -91,7 +116,12 @@ router.post('/', (req: Request, res: Response): void => {
let result;
try {
result = createCompositeVariant({ name, defaultTier: default_tier, tiers });
result = createCompositeVariant({
name,
defaultTier: default_tier,
target: parsedTarget || 'claude',
tiers,
});
} catch (error) {
res.status(400).json({ error: (error as Error).message });
return;
@@ -109,6 +139,7 @@ router.post('/', (req: Request, res: Response): void => {
tiers,
settings: result.settingsPath,
port: result.variant?.port,
target: result.variant?.target || 'claude',
});
return;
}
@@ -126,7 +157,13 @@ router.post('/', (req: Request, res: Response): void => {
}
// Use variant-service for proper port allocation
const result = createVariant(name, provider as CLIProxyProvider, model, account);
const result = createVariant(
name,
provider as CLIProxyProvider,
model,
account,
parsedTarget || 'claude'
);
if (!result.success) {
res.status(409).json({ error: result.error });
@@ -140,6 +177,7 @@ router.post('/', (req: Request, res: Response): void => {
account: account || 'default',
port: result.variant?.port,
model: result.variant?.model,
target: result.variant?.target || 'claude',
});
});
@@ -154,6 +192,12 @@ router.put('/:name', (req: Request, res: Response): void => {
try {
const { name } = req.params;
const { provider, account, model, default_tier, tiers } = req.body;
const parsedTarget = parseTarget(req.body.target);
if (req.body.target !== undefined && parsedTarget === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
// Check if variant is composite - use updateCompositeVariant if so
const variants = listVariants();
@@ -165,8 +209,8 @@ router.put('/:name', (req: Request, res: Response): void => {
}
if (existing.type === 'composite') {
if (!default_tier && !tiers) {
res.status(400).json({ error: 'Must provide at least default_tier or tiers' });
if (!default_tier && !tiers && req.body.target === undefined) {
res.status(400).json({ error: 'Must provide at least default_tier, tiers, or target' });
return;
}
@@ -189,7 +233,11 @@ router.put('/:name', (req: Request, res: Response): void => {
}
}
const result = updateCompositeVariant(name, { defaultTier: default_tier, tiers });
const result = updateCompositeVariant(name, {
defaultTier: default_tier,
tiers,
target: req.body.target !== undefined && parsedTarget ? parsedTarget : undefined,
});
if (!result.success) {
const status = result.error?.includes('not found') ? 404 : 400;
@@ -207,13 +255,19 @@ router.put('/:name', (req: Request, res: Response): void => {
tiers: persisted?.tiers,
settings: persisted?.settings,
port: persisted?.port,
target: persisted?.target || 'claude',
updated: true,
});
return;
}
// Use variant-service for proper update handling (single provider)
const result = updateVariant(name, { provider, account, model });
const result = updateVariant(name, {
provider,
account,
model,
target: req.body.target !== undefined && parsedTarget ? parsedTarget : undefined,
});
if (!result.success) {
const status = result.error?.includes('not found') ? 404 : 400;
@@ -227,6 +281,7 @@ router.put('/:name', (req: Request, res: Response): void => {
account: result.variant?.account || 'default',
settings: result.variant?.settings,
port: result.variant?.port,
target: result.variant?.target || 'claude',
updated: true,
});
} catch (error) {
@@ -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', () => {
@@ -35,4 +35,49 @@ describe('api-command arg parser', () => {
expect(parsed.yes).toBe(true);
expect(parsed.name).toBe('-my-api');
});
test('parses --target for default profile target', () => {
const parsed = parseApiCommandArgs(['my-api', '--target', 'droid']);
expect(parsed.name).toBe('my-api');
expect(parsed.target).toBe('droid');
expect(parsed.errors).toEqual([]);
});
test('parses --target=value for default profile target', () => {
const parsed = parseApiCommandArgs(['my-api', '--target=droid']);
expect(parsed.name).toBe('my-api');
expect(parsed.target).toBe('droid');
expect(parsed.errors).toEqual([]);
});
test('validates invalid --target values', () => {
const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']);
expect(parsed.target).toBeUndefined();
expect(parsed.errors).toEqual(['Invalid --target value "invalid-target". Use: claude or droid']);
});
test('collects missing-value error for --target with no value', () => {
const parsed = parseApiCommandArgs(['my-api', '--target']);
expect(parsed.target).toBeUndefined();
expect(parsed.errors).toEqual(['Missing value for --target']);
});
test('treats empty --target=value as missing value', () => {
const parsed = parseApiCommandArgs(['my-api', '--target=']);
expect(parsed.target).toBeUndefined();
expect(parsed.errors).toEqual(['Missing value for --target']);
});
test('uses last --target value when repeated', () => {
const parsed = parseApiCommandArgs(['my-api', '--target', 'claude', '--target=droid']);
expect(parsed.name).toBe('my-api');
expect(parsed.target).toBe('droid');
expect(parsed.errors).toEqual([]);
});
});
@@ -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([]);
});
});
@@ -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 <name>')).toBe(true);
});
});
+2 -2
View File
@@ -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);
});
@@ -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');
@@ -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();
});
});
+9
View File
@@ -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() {
</Suspense>
}
/>
<Route
path="/updates"
element={
<Suspense fallback={<PageLoader />}>
<UpdatesPage />
</Suspense>
}
/>
<Route
path="/providers"
element={
@@ -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']),
});
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']),
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<SingleProviderFormData>({
resolver: zodResolver(singleProviderSchema),
defaultValues: { target: 'claude' },
});
const compositeForm = useForm<CompositeFormData>({
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) {
<Input id="model" {...singleForm.register('model')} placeholder="gemini-2.5-pro" />
</div>
<div>
<Label htmlFor="target">Default Target</Label>
<select
id="target"
{...singleForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
@@ -288,6 +305,18 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
</select>
</div>
<div>
<Label htmlFor="composite-target">Default Target</Label>
<select
id="composite-target"
{...compositeForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
@@ -14,16 +14,18 @@ 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' }),
model: z.string().optional(),
account: z.string().optional(),
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']),
tiers: z.object({
opus: z.object({
provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }),
@@ -57,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';
@@ -81,6 +140,7 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
});
compositeForm.reset({
default_tier: variant.default_tier,
target: variant.target || 'claude',
tiers: {
opus: mapTier(variant.tiers.opus),
sonnet: mapTier(variant.tiers.sonnet),
@@ -92,16 +152,47 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
provider: variant.provider,
model: variant.model ?? undefined,
account: variant.account ?? undefined,
target: variant.target || 'claude',
});
}
}, [variant, isComposite, singleForm, compositeForm]);
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);
@@ -112,13 +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<UpdateVariant['tiers']> = {
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,
tiers: data.tiers,
},
data: payload,
});
onOpenChange(false);
} catch (error) {
@@ -202,6 +333,18 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
</select>
</div>
<div>
<Label htmlFor="edit-composite-target">Default Target</Label>
<select
id="edit-composite-target"
{...compositeForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
@@ -242,6 +385,18 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
/>
</div>
<div>
<Label htmlFor="edit-target">Default Target</Label>
<select
id="edit-target"
{...singleForm.register('target')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="claude">Claude Code</option>
<option value="droid">Factory Droid</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
@@ -69,6 +69,15 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
);
},
},
{
accessorKey: 'target',
header: 'Target',
cell: ({ row }) => (
<Badge variant="outline" className="text-[10px] h-5 px-1.5 uppercase">
{row.original.target || 'claude'}
</Badge>
),
},
{
accessorKey: 'settings',
header: 'Settings Path',
@@ -35,6 +35,7 @@ export function ProviderEditor({
baseProvider,
isRemoteMode,
port,
defaultTarget,
onAddAccount,
onSetDefault,
onRemoveAccount,
@@ -244,6 +245,7 @@ export function ProviderEditor({
<ProviderInfoTab
provider={provider}
displayName={displayName}
defaultTarget={defaultTarget}
data={data}
authStatus={authStatus}
/>
@@ -9,16 +9,26 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Info, Shield } from 'lucide-react';
import { UsageCommand } from './usage-command';
import type { SettingsResponse } from './types';
import type { AuthStatus } from '@/lib/api-client';
import type { AuthStatus, CliTarget } from '@/lib/api-client';
interface ProviderInfoTabProps {
provider: string;
displayName: string;
defaultTarget?: CliTarget;
data?: SettingsResponse;
authStatus: AuthStatus;
}
export function ProviderInfoTab({ provider, displayName, data, authStatus }: ProviderInfoTabProps) {
export function ProviderInfoTab({
provider,
displayName,
defaultTarget,
data,
authStatus,
}: ProviderInfoTabProps) {
const resolvedTarget = defaultTarget || 'claude';
const isDroidTarget = resolvedTarget === 'droid';
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-6">
@@ -66,6 +76,10 @@ export function ProviderInfoTab({ provider, displayName, data, authStatus }: Pro
</Badge>
)}
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Default Target</span>
<span className="font-mono">{resolvedTarget}</span>
</div>
</div>
</div>
@@ -74,6 +88,18 @@ export function ProviderInfoTab({ provider, displayName, data, authStatus }: Pro
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<UsageCommand label="Run with prompt" command={`ccs ${provider} "your prompt"`} />
<UsageCommand
label={isDroidTarget ? 'Droid alias (explicit)' : 'Run on Droid'}
command={
isDroidTarget
? `ccsd ${provider} "your prompt"`
: `ccs ${provider} --target droid "your prompt"`
}
/>
<UsageCommand
label={isDroidTarget ? 'Override to Claude' : 'Override target'}
command={`ccs ${provider} --target claude "your prompt"`}
/>
<UsageCommand label="Change model" command={`ccs ${provider} --config`} />
<UsageCommand label="Add account" command={`ccs ${provider} --add`} />
<UsageCommand label="List accounts" command={`ccs ${provider} --accounts`} />
@@ -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;
+27 -32
View File
@@ -170,38 +170,33 @@ export function AppSidebar() {
defaultOpen={isParentActive(item.children) || isRouteActive(item.path)}
className="group/collapsible"
>
<SidebarMenuItem>
{/* Click navigates to overview AND opens submenu */}
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={getItemLabel(item)}
isActive={isParentActive(item.children)}
onClick={() => navigate(item.path)}
>
{renderMenuIcon(item)}
<span className="group-data-[collapsible=icon]:hidden">
{getItemLabel(item)}
</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.children.map((child) => (
<SidebarMenuSubItem key={child.path}>
<SidebarMenuSubButton
asChild
isActive={isRouteActive(child.path)}
>
<Link to={child.path}>
<span>{child.label}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
{/* Click navigates to overview AND opens submenu */}
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={getItemLabel(item)}
isActive={isParentActive(item.children)}
onClick={() => navigate(item.path)}
>
{renderMenuIcon(item)}
<span className="group-data-[collapsible=icon]:hidden">
{getItemLabel(item)}
</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.children.map((child) => (
<SidebarMenuSubItem key={child.path}>
<SidebarMenuSubButton asChild isActive={isRouteActive(child.path)}>
<Link to={child.path}>
<span>{child.label}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
) : (
<SidebarMenuButton
@@ -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"
>
<InfoSection profileName={profileName} data={data} />
<InfoSection profileName={profileName} target={target} data={data} />
</TabsContent>
</div>
</Tabs>
@@ -5,19 +5,30 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react';
import { OpenRouterBadge } from '@/components/profiles/openrouter-badge';
import { isOpenRouterProfile } from './utils';
import type { Settings } from './types';
import type { CliTarget } from '@/lib/api-client';
interface HeaderSectionProps {
profileName: string;
target: CliTarget;
data: { path?: string; mtime: number } | undefined;
settings?: Settings;
isLoading: boolean;
isSaving: boolean;
isTargetSaving: boolean;
hasChanges: boolean;
isRawJsonValid: boolean;
onTargetChange: (target: CliTarget) => void;
onRefresh: () => void;
onDelete?: () => void;
onSave: () => void;
@@ -25,16 +36,22 @@ interface HeaderSectionProps {
export function HeaderSection({
profileName,
target,
data,
settings,
isLoading,
isSaving,
isTargetSaving,
hasChanges,
isRawJsonValid,
onTargetChange,
onRefresh,
onDelete,
onSave,
}: HeaderSectionProps) {
const isMutating = isSaving || isTargetSaving;
const disableHeaderActions = isLoading || isMutating;
return (
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
<div>
@@ -52,17 +69,37 @@ export function HeaderSection({
Last modified: {new Date(data.mtime).toLocaleString()}
</p>
)}
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground">Default target:</span>
<Select
value={target}
onValueChange={(value) => {
if (disableHeaderActions) return;
onTargetChange(value as CliTarget);
}}
disabled={disableHeaderActions}
>
<SelectTrigger className="h-7 w-[170px] text-xs" disabled={disableHeaderActions}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude Code</SelectItem>
<SelectItem value="droid">Factory Droid</SelectItem>
</SelectContent>
</Select>
{isTargetSaving && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={isLoading}>
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={disableHeaderActions}>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
{onDelete && (
<Button variant="ghost" size="sm" onClick={onDelete}>
<Button variant="ghost" size="sm" onClick={onDelete} disabled={isMutating}>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
)}
<Button size="sm" onClick={onSave} disabled={isSaving || !hasChanges || !isRawJsonValid}>
<Button size="sm" onClick={onSave} disabled={isMutating || !hasChanges || !isRawJsonValid}>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
+42 -3
View File
@@ -15,8 +15,14 @@ import { HeaderSection } from './header-section';
import { FriendlyUISection } from './friendly-ui-section';
import { RawEditorSection } from './raw-editor-section';
import type { ProfileEditorProps, Settings, SettingsResponse } from './types';
import { api, type CliTarget } from '@/lib/api-client';
export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: ProfileEditorProps) {
export function ProfileEditor({
profileName,
profileTarget,
onDelete,
onHasChangesUpdate,
}: ProfileEditorProps) {
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
const [conflictDialog, setConflictDialog] = useState(false);
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
@@ -145,6 +151,25 @@ export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: Pro
},
});
const targetMutation = useMutation<CliTarget, Error, CliTarget>({
mutationFn: async (target: CliTarget) => {
await api.profiles.update(profileName, { target });
return target;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Default target updated');
},
onError: (error: Error, target: CliTarget) => {
const targetLabel = target === 'droid' ? 'Factory Droid' : 'Claude Code';
const suffix = error.message.trim() ? `: ${error.message}` : '';
toast.error(`Failed to update default target to ${targetLabel}${suffix}`);
},
});
const resolvedTarget: CliTarget = profileTarget || 'claude';
const isHeaderMutationPending = saveMutation.isPending || targetMutation.isPending;
const handleConflictResolve = async (overwrite: boolean) => {
setConflictDialog(false);
if (overwrite) {
@@ -160,15 +185,28 @@ export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: Pro
<div key={profileName} className="flex-1 flex flex-col overflow-hidden">
<HeaderSection
profileName={profileName}
target={resolvedTarget}
data={data}
settings={currentSettings}
isLoading={isLoading}
isSaving={saveMutation.isPending}
isTargetSaving={targetMutation.isPending}
hasChanges={computedHasChanges}
isRawJsonValid={computedIsRawJsonValid}
onRefresh={() => refetch()}
onTargetChange={(target) => {
if (isHeaderMutationPending) return;
if (target === resolvedTarget) return;
targetMutation.mutate(target);
}}
onRefresh={() => {
if (isHeaderMutationPending) return;
refetch();
}}
onDelete={onDelete}
onSave={() => saveMutation.mutate()}
onSave={() => {
if (isHeaderMutationPending) return;
saveMutation.mutate();
}}
/>
{isLoading ? (
@@ -191,6 +229,7 @@ export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: Pro
<div className="flex flex-col overflow-hidden bg-muted/5 min-w-0">
<FriendlyUISection
profileName={profileName}
target={resolvedTarget}
data={data}
currentSettings={currentSettings}
newEnvKey={newEnvKey}
@@ -8,13 +8,17 @@ import { Label } from '@/components/ui/label';
import { CopyButton } from '@/components/ui/copy-button';
import { Info } from 'lucide-react';
import type { SettingsResponse } from './types';
import type { CliTarget } from '@/lib/api-client';
interface InfoSectionProps {
profileName: string;
target: CliTarget;
data: SettingsResponse | undefined;
}
export function InfoSection({ profileName, data }: InfoSectionProps) {
export function InfoSection({ profileName, target, data }: InfoSectionProps) {
const isDroidTarget = target === 'droid';
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-6">
@@ -44,6 +48,10 @@ export function InfoSection({ profileName, data }: InfoSectionProps) {
<span className="font-medium text-muted-foreground">Last Modified</span>
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Default Target</span>
<span className="font-mono">{target}</span>
</div>
</>
)}
</div>
@@ -62,6 +70,42 @@ export function InfoSection({ profileName, data }: InfoSectionProps) {
<CopyButton value={`ccs ${profileName} "prompt"`} size="icon" className="h-6 w-6" />
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">
{isDroidTarget ? 'Droid alias (explicit)' : 'Run on Droid'}
</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
{isDroidTarget
? `ccsd ${profileName} "prompt"`
: `ccs ${profileName} --target droid "prompt"`}
</code>
<CopyButton
value={
isDroidTarget
? `ccsd ${profileName} "prompt"`
: `ccs ${profileName} --target droid "prompt"`
}
size="icon"
className="h-6 w-6"
/>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">
{isDroidTarget ? 'Override to Claude' : 'Override to Claude (explicit)'}
</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs {profileName} --target claude "prompt"
</code>
<CopyButton
value={`ccs ${profileName} --target claude "prompt"`}
size="icon"
className="h-6 w-6"
/>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Set as default</Label>
<div className="mt-1 flex gap-2">
@@ -2,6 +2,8 @@
* Types for Profile Editor
*/
import type { CliTarget } from '@/lib/api-client';
export interface Settings {
env?: Record<string, string>;
}
@@ -15,6 +17,7 @@ export interface SettingsResponse {
export interface ProfileEditorProps {
profileName: string;
profileTarget?: CliTarget;
onDelete?: () => void;
onHasChangesUpdate?: (hasChanges: boolean) => void;
}
@@ -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<typeof schema>;
@@ -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({
)
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="target">Default Target CLI</Label>
<Select
value={targetValue}
onValueChange={(value) => setValue('target', value as CliTarget)}
>
<SelectTrigger id="target">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude Code (default)</SelectItem>
<SelectItem value="droid">Factory Droid</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Run with{' '}
<code className="bg-muted px-1 rounded text-[10px]">
{targetValue === 'droid' ? 'ccsd' : 'ccs'}
</code>{' '}
by default. You can still override each run with{' '}
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
</p>
</div>
</TabsContent>
<TabsContent value="models" className="p-6 mt-0 space-y-4">
@@ -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 (
<Badge
variant="outline"
className={cn('gap-1.5 border text-[10px] font-medium', meta.className, className)}
>
{meta.showDot && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
{meta.label}
</Badge>
);
}
@@ -0,0 +1,96 @@
import { Link } from 'react-router-dom';
import { ArrowUpRight } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
SUPPORT_SCOPE_LABELS,
type CliSupportEntry,
type SupportScope,
} from '@/lib/support-updates-catalog';
import { SupportStatusBadge } from './support-status-badge';
const PILLAR_LABELS: { key: keyof CliSupportEntry['pillars']; label: string }[] = [
{ key: 'baseUrl', label: 'Base URL' },
{ key: 'auth', label: 'Auth' },
{ key: 'model', label: 'Model' },
];
const SCOPE_STYLES: Record<SupportScope, string> = {
target:
'border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/50 dark:bg-violet-900/20 dark:text-violet-300',
cliproxy:
'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/50 dark:bg-sky-900/20 dark:text-sky-300',
'api-profiles':
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-900/20 dark:text-emerald-300',
websearch:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-300',
};
export function SupportEntryCard({ entry }: { entry: CliSupportEntry }) {
return (
<Card className="h-full gap-4">
<CardHeader className="gap-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<CardTitle className="text-base leading-tight">{entry.name}</CardTitle>
<CardDescription>{entry.summary}</CardDescription>
</div>
<SupportStatusBadge status={entry.status} />
</div>
<Badge variant="outline" className={SCOPE_STYLES[entry.scope]}>
{SUPPORT_SCOPE_LABELS[entry.scope]}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 sm:grid-cols-3">
{PILLAR_LABELS.map((pillar) => (
<div key={pillar.key} className="rounded-md border bg-muted/30 p-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{pillar.label}
</p>
<p className="text-xs font-medium leading-relaxed">{entry.pillars[pillar.key]}</p>
</div>
))}
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Dashboard
</p>
<div className="flex flex-wrap gap-2">
{entry.routes.map((route) => (
<Link
key={`${entry.id}-${route.path}`}
to={route.path}
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs hover:bg-muted"
>
{route.label}
<ArrowUpRight className="h-3 w-3" />
</Link>
))}
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
CLI Usage
</p>
<div className="space-y-1">
{entry.commands.slice(0, 2).map((command) => (
<code
key={`${entry.id}-${command}`}
className="block rounded bg-muted px-2 py-1 text-[11px] leading-relaxed"
>
{command}
</code>
))}
</div>
</div>
{entry.notes && <p className="text-xs text-muted-foreground">{entry.notes}</p>}
</CardContent>
</Card>
);
}
@@ -0,0 +1,31 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { SupportStatus } from '@/lib/support-updates-catalog';
const STATUS_LABELS: Record<SupportStatus, string> = {
new: 'New',
stable: 'Stable',
planned: 'Planned',
};
const STATUS_STYLES: Record<SupportStatus, string> = {
new: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-300',
stable:
'border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-300',
planned:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-300',
};
export function SupportStatusBadge({
status,
className,
}: {
status: SupportStatus;
className?: string;
}) {
return (
<Badge variant="outline" className={cn('font-medium', STATUS_STYLES[status], className)}>
{STATUS_LABELS[status]}
</Badge>
);
}
@@ -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 (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed bg-muted/20 p-6">
<p className="text-sm text-muted-foreground">No updates available.</p>
</div>
);
}
return (
<div className="flex-1 min-w-0 bg-background grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
<div className="border-b bg-background px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h2 className="text-base font-semibold leading-tight">{notice.title}</h2>
<p className="text-sm text-muted-foreground">{notice.summary}</p>
</div>
<div className="flex items-center gap-2">
{progress && <NoticeProgressBadge state={progress} />}
<SupportStatusBadge status={notice.status} />
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<CalendarClock className="h-3.5 w-3.5" />
<span>Published {formatCatalogDate(notice.publishedAt)}</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" onClick={() => onUpdateProgress('done')}>
<CheckCircle2 className="h-4 w-4" />
Mark done
</Button>
<Button size="sm" variant="outline" onClick={() => onUpdateProgress('dismissed')}>
<EyeOff className="h-4 w-4" />
Dismiss
</Button>
<Button size="sm" variant="ghost" onClick={() => onUpdateProgress('new')}>
<RotateCcw className="h-4 w-4" />
Reopen
</Button>
</div>
</div>
<div className="min-h-0 p-4">
<div className="grid h-full gap-4 xl:grid-cols-[minmax(0,1.5fr)_minmax(320px,1fr)] overflow-hidden">
<Card className="h-full overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Do Next</CardTitle>
</div>
<CardDescription>{notice.primaryAction}</CardDescription>
</CardHeader>
<CardContent className="min-h-0">
<ScrollArea className="h-full pr-2">
<div className="space-y-3">
{notice.actions.map((action) => (
<UpdatesNoticeActionRow key={`${notice.id}-${action.id}`} action={action} />
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<div className="grid h-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 overflow-hidden">
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-base">Impacted Integrations</CardTitle>
<CardDescription>Related areas based on update scope and routing.</CardDescription>
</CardHeader>
<CardContent className="min-h-0">
<ScrollArea className="h-full pr-2">
<div className="space-y-2">
{relatedEntries.map((entry) => (
<div key={entry.id} className="rounded-md border bg-muted/20 p-2.5">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium">{entry.name}</p>
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{SUPPORT_SCOPE_LABELS[entry.scope]}
</Badge>
</div>
<div className="mt-2 flex items-center gap-2">
{entry.routes[0] && (
<Button size="sm" variant="outline" asChild>
<Link to={entry.routes[0].path}>{entry.routes[0].label}</Link>
</Button>
)}
{entry.commands[0] && (
<div className="ml-auto flex min-w-0 items-center gap-1.5">
<code className="truncate rounded bg-background px-1.5 py-0.5 text-[11px]">
{entry.commands[0]}
</code>
<CopyButton value={entry.commands[0]} />
</div>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<CardTitle className="text-base">Why It Matters</CardTitle>
<CardDescription>
Short context only, no wall-of-text release notes.
</CardDescription>
</CardHeader>
<CardContent className="min-h-0">
<ScrollArea className="h-full pr-2">
<ul className="space-y-2 text-sm text-muted-foreground">
{notice.highlights.map((highlight) => (
<li key={`${notice.id}-${highlight}`}>- {highlight}</li>
))}
</ul>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
@@ -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 (
<button
type="button"
onClick={onSelect}
className={cn(
'w-full rounded-lg border px-3 py-3 text-left transition-colors',
selected
? 'border-primary/30 bg-primary/10'
: 'border-transparent bg-background/40 hover:border-border hover:bg-muted/70'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-medium">{notice.title}</p>
<p className="line-clamp-2 text-xs text-muted-foreground">{notice.primaryAction}</p>
</div>
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground">
{formatCatalogDate(notice.publishedAt)}
</span>
<NoticeProgressBadge state={progress} />
</div>
</button>
);
}
@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom';
import { ArrowUpRight, Terminal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CopyButton } from '@/components/ui/copy-button';
import { type SupportNoticeAction } from '@/lib/support-updates-catalog';
export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) {
return (
<div className="rounded-md border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-medium">{action.label}</p>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
{action.type === 'route' && action.path && (
<Button size="sm" asChild>
<Link to={action.path}>
Open
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</Button>
)}
</div>
{action.type === 'command' && action.command && (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-background px-2 py-1.5">
<Terminal className="h-3.5 w-3.5 text-muted-foreground" />
<code className="min-w-0 flex-1 truncate text-[11px]">{action.command}</code>
<CopyButton value={action.command} />
</div>
)}
</div>
);
}
@@ -0,0 +1,51 @@
import { Link } from 'react-router-dom';
import { BellRing, ExternalLink } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { formatCatalogDate, getLatestSupportNotice } from '@/lib/support-updates-catalog';
import { cn } from '@/lib/utils';
export function UpdatesSpotlight({
className,
compact = false,
}: {
className?: string;
compact?: boolean;
}) {
const latest = getLatestSupportNotice();
if (!latest) {
return null;
}
const primaryCommand = latest.commands[0];
return (
<Alert variant="info" className={cn('border-dashed', className)}>
<BellRing className="h-4 w-4" />
<AlertTitle className="flex flex-wrap items-center gap-2">
<span>{latest.title}</span>
<span className="text-xs font-normal text-blue-700/80 dark:text-blue-300/80">
{formatCatalogDate(latest.publishedAt)}
</span>
</AlertTitle>
<AlertDescription className="space-y-2">
<p>{latest.summary}</p>
<div className="flex flex-wrap items-center gap-3 text-xs">
<Link
to="/updates"
className="inline-flex items-center gap-1 font-medium text-blue-700 hover:underline dark:text-blue-300"
>
Open Updates Center
<ExternalLink className="h-3.5 w-3.5" />
</Link>
{!compact && primaryCommand && (
<code className="rounded bg-blue-100/70 px-1.5 py-0.5 font-mono text-blue-800 dark:bg-blue-900/40 dark:text-blue-200">
{primaryCommand}
</code>
)}
</div>
</AlertDescription>
</Alert>
);
}
+39 -4
View File
@@ -30,6 +30,29 @@ const MAX_LOGS = 500;
const FLUSH_INTERVAL = 100; // ms
const POLL_INTERVAL = 1000; // ms
interface CliproxyErrorLogFile {
name: string;
size: number;
modified: number;
statusCode?: number;
model?: string;
}
function toTimestampMs(modified: number): number {
return modified > 1_000_000_000_000 ? modified : modified * 1000;
}
function buildLogMessage(file: CliproxyErrorLogFile): string {
const parts = [file.name];
if (typeof file.statusCode === 'number') {
parts.push(`HTTP ${file.statusCode}`);
}
if (file.model) {
parts.push(file.model);
}
return parts.join(' | ');
}
export function useCliproxyLogs() {
const [state, setState] = useState<LogsState>({
logs: [],
@@ -86,15 +109,27 @@ export function useCliproxyLogs() {
if (isPausedRef.current) return;
try {
const after = lastTimestampRef.current ?? new Date(Date.now() - 60000).toISOString();
const response = await fetch(`/api/cliproxy/logs?after=${encodeURIComponent(after)}`);
const afterIso = lastTimestampRef.current ?? new Date(Date.now() - 60000).toISOString();
const parsedAfterMs = Date.parse(afterIso);
const afterMs = Number.isNaN(parsedAfterMs) ? Date.now() - 60000 : parsedAfterMs;
const response = await fetch('/api/cliproxy/error-logs');
if (!response.ok) {
throw new Error('Failed to fetch logs');
}
const data = await response.json();
const entries: LogEntry[] = data.logs ?? [];
const data = (await response.json()) as { files?: CliproxyErrorLogFile[] };
const entries: LogEntry[] = (data.files ?? [])
.filter((file) => toTimestampMs(file.modified) > afterMs)
.sort((a, b) => toTimestampMs(a.modified) - toTimestampMs(b.modified))
.map((file) => ({
id: file.name,
timestamp: new Date(toTimestampMs(file.modified)).toISOString(),
level: 'error' as const,
message: buildLogMessage(file),
provider: 'cliproxy',
requestId: file.name,
}));
if (entries.length > 0) {
bufferRef.current.push(...entries);
+8
View File
@@ -94,10 +94,13 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
}
// 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?: {
+298
View File
@@ -0,0 +1,298 @@
export type SupportStatus = 'new' | 'stable' | 'planned';
export type SupportScope = 'target' | 'cliproxy' | 'api-profiles' | 'websearch';
export interface SupportRouteHint {
label: string;
path: string;
}
export interface SupportNoticeAction {
id: string;
label: string;
description: string;
type: 'route' | 'command';
path?: string;
command?: string;
}
export interface SupportNotice {
id: string;
title: string;
summary: string;
primaryAction: string;
publishedAt: string;
status: SupportStatus;
scopes: SupportScope[];
entryIds: string[];
highlights: string[];
actions: SupportNoticeAction[];
routes: SupportRouteHint[];
commands: string[];
}
export interface CliSupportEntry {
id: string;
name: string;
scope: SupportScope;
status: SupportStatus;
summary: string;
pillars: {
baseUrl: string;
auth: string;
model: string;
};
routes: SupportRouteHint[];
commands: string[];
notes?: string;
}
export const SUPPORT_SCOPE_LABELS: Record<SupportScope, string> = {
target: 'Target CLI',
cliproxy: 'CLIProxy Provider',
'api-profiles': 'API Profile',
websearch: 'WebSearch',
};
export const SUPPORT_NOTICES: SupportNotice[] = [
{
id: 'droid-target-support',
title: 'Factory Droid support is live',
summary:
'API Profiles and CLIProxy variants now support Droid as a first-class execution target.',
primaryAction: 'Set Droid as your default execution target for non-Claude workflows.',
publishedAt: '2026-02-25',
status: 'new',
scopes: ['target', 'api-profiles', 'cliproxy'],
entryIds: ['droid-target', 'custom-api-profiles', 'codex-cliproxy', 'agy-cliproxy'],
highlights: [
'Set default target to Droid when creating or editing API Profiles.',
'Set default target to Droid for CLIProxy variants, including Codex and Antigravity flows.',
'Use ccsd alias or --target droid for one-off target overrides.',
],
actions: [
{
id: 'open-api-profiles',
label: 'Set default target in API Profiles',
description:
'Open API Profiles and set Default Target to Droid for profiles you run often.',
type: 'route',
path: '/providers',
},
{
id: 'open-cliproxy',
label: 'Set default target in CLIProxy variants',
description:
'Open CLIProxy variants and set target to Droid for Codex/Antigravity or custom variants.',
type: 'route',
path: '/cliproxy',
},
{
id: 'copy-ccsd-command',
label: 'Run once with Droid alias',
description: 'Use ccsd to force Droid target with your current profile.',
type: 'command',
command: 'ccsd glm',
},
{
id: 'copy-target-override',
label: 'Run once with --target override',
description: 'Keep your default profile but force Droid for a single command.',
type: 'command',
command: 'ccs codex --target droid "your prompt"',
},
],
routes: [
{ label: 'API Profiles', path: '/providers' },
{ label: 'CLIProxy', path: '/cliproxy' },
],
commands: [
'ccsd glm',
'ccs codex --target droid "your prompt"',
'ccs cliproxy create mycodex --provider codex --target droid',
],
},
{
id: 'updates-center-launch',
title: 'Updates inbox is available for rollout tasks',
summary:
'A focused updates inbox exists for setup tasks and rollout guidance when you need it.',
primaryAction:
'Use this page only when needed for rollout tasks, then return to your normal workflow.',
publishedAt: '2026-02-25',
status: 'new',
scopes: ['target', 'cliproxy', 'api-profiles', 'websearch'],
entryIds: ['droid-target', 'codex-cliproxy', 'custom-api-profiles', 'opencode-websearch'],
highlights: [
'Single data source powers update content and integration mapping.',
'Notices can be tracked as new, seen, done, or dismissed.',
'Catalog covers target CLI, CLIProxy providers, and WebSearch integrations.',
],
actions: [
{
id: 'open-updates-page',
label: 'Open updates inbox when needed',
description: 'Review rollout tasks only when you want guided setup changes.',
type: 'route',
path: '/updates',
},
{
id: 'copy-open-dashboard',
label: 'Open dashboard from terminal',
description: 'Re-open config dashboard anytime from CLI.',
type: 'command',
command: 'ccs config',
},
],
routes: [{ label: 'Updates Inbox', path: '/updates' }],
commands: ['ccs config'],
},
];
export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [
{
id: 'claude-target',
name: 'Claude Code',
scope: 'target',
status: 'stable',
summary: 'Default runtime target for all CCS profile types.',
pillars: {
baseUrl: 'From profile settings (ANTHROPIC_BASE_URL)',
auth: 'From profile settings (ANTHROPIC_AUTH_TOKEN)',
model: 'From profile settings (ANTHROPIC_MODEL)',
},
routes: [
{ label: 'API Profiles', path: '/providers' },
{ label: 'CLIProxy', path: '/cliproxy' },
],
commands: ['ccs', 'ccs glm "your prompt"'],
},
{
id: 'droid-target',
name: 'Factory Droid',
scope: 'target',
status: 'new',
summary: 'First-class target for API Profiles and CLIProxy variants.',
pillars: {
baseUrl: 'From profile or variant settings',
auth: 'From profile or variant settings',
model: 'From profile or variant settings',
},
routes: [
{ label: 'API Profiles', path: '/providers' },
{ label: 'CLIProxy', path: '/cliproxy' },
],
commands: ['ccsd glm', 'ccs km --target droid', 'ccs codex --target droid'],
notes: 'Use ccsd alias for automatic Droid target selection.',
},
{
id: 'codex-cliproxy',
name: 'Codex via CLIProxy',
scope: 'cliproxy',
status: 'stable',
summary: 'OAuth-backed provider with configurable variant model and target.',
pillars: {
baseUrl: 'Managed by CLIProxy backend',
auth: 'OAuth account via CLIProxy auth flow',
model: 'Selectable per provider or variant',
},
routes: [
{ label: 'CLIProxy', path: '/cliproxy' },
{ label: 'Control Panel', path: '/cliproxy/control-panel' },
],
commands: ['ccs codex', 'ccs cliproxy create mycodex --provider codex'],
},
{
id: 'gemini-cliproxy',
name: 'Gemini via CLIProxy',
scope: 'cliproxy',
status: 'stable',
summary: 'OAuth-backed Gemini provider with multi-account management.',
pillars: {
baseUrl: 'Managed by CLIProxy backend',
auth: 'OAuth account via CLIProxy auth flow',
model: 'Selectable per provider or variant',
},
routes: [
{ label: 'CLIProxy', path: '/cliproxy' },
{ label: 'Control Panel', path: '/cliproxy/control-panel' },
],
commands: ['ccs gemini', 'ccs cliproxy create mygem --provider gemini'],
},
{
id: 'agy-cliproxy',
name: 'Antigravity via CLIProxy',
scope: 'cliproxy',
status: 'stable',
summary: 'OAuth-backed Antigravity provider with variant target controls.',
pillars: {
baseUrl: 'Managed by CLIProxy backend',
auth: 'OAuth account via CLIProxy auth flow',
model: 'Selectable per provider or variant',
},
routes: [
{ label: 'CLIProxy', path: '/cliproxy' },
{ label: 'Control Panel', path: '/cliproxy/control-panel' },
],
commands: ['ccs agy', 'ccs cliproxy create myagy --provider agy --target droid'],
},
{
id: 'custom-api-profiles',
name: 'Custom API Profiles',
scope: 'api-profiles',
status: 'stable',
summary: 'Any Anthropic-compatible endpoint with per-profile target and model mapping.',
pillars: {
baseUrl: 'User-defined endpoint',
auth: 'User-defined token/key',
model: 'User-defined model identifier',
},
routes: [{ label: 'API Profiles', path: '/providers' }],
commands: ['ccs api create myprofile', 'ccs myprofile "your prompt"'],
},
{
id: 'opencode-websearch',
name: 'OpenCode WebSearch',
scope: 'websearch',
status: 'stable',
summary: 'WebSearch provider surfaced in Settings for third-party profile workflows.',
pillars: {
baseUrl: 'Managed by OpenCode CLI integration',
auth: 'Provider-specific (managed externally)',
model: 'Configurable in WebSearch settings',
},
routes: [{ label: 'Settings', path: '/settings' }],
commands: ['ccs config', 'ccs codex "your prompt"'],
notes: 'Enable OpenCode in Settings > WebSearch to activate fallback search.',
},
];
const SUPPORT_ENTRY_LOOKUP = new Map(CLI_SUPPORT_ENTRIES.map((entry) => [entry.id, entry]));
export function getSupportEntriesForNotice(notice: SupportNotice): CliSupportEntry[] {
return notice.entryIds
.map((entryId) => SUPPORT_ENTRY_LOOKUP.get(entryId))
.filter((entry): entry is CliSupportEntry => Boolean(entry));
}
export function getLatestSupportNotice(): SupportNotice | null {
if (SUPPORT_NOTICES.length === 0) {
return null;
}
return [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))[0];
}
export function formatCatalogDate(value: string): string {
const parsed = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
}).format(parsed);
}
+63
View File
@@ -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<string, NoticeProgressState>;
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<SupportNotice, 'id' | 'status'>,
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));
}
+8 -1
View File
@@ -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() {
<ProfileEditor
key={selectedProfileData.name}
profileName={selectedProfileData.name}
profileTarget={selectedProfileData.target}
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
onHasChangesUpdate={setEditorHasChanges}
/>
@@ -308,7 +310,12 @@ function ProfileListItem({
{/* Profile info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{profile.name}</div>
<div className="flex items-center gap-2 min-w-0">
<div className="font-medium text-sm truncate">{profile.name}</div>
<Badge variant="outline" className="text-[10px] h-4 px-1.5 uppercase">
{profile.target || 'claude'}
</Badge>
</div>
<div className="flex items-center gap-1.5 min-w-0">
<div className="text-xs text-muted-foreground truncate flex-1">
{profile.settingsPath}
+4
View File
@@ -120,6 +120,9 @@ function VariantSidebarItem({
<Badge variant="outline" className="text-[9px] h-4 px-1">
variant
</Badge>
<Badge variant="outline" className="text-[9px] h-4 px-1 uppercase">
{variant.target || 'claude'}
</Badge>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{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={() =>
+2
View File
@@ -15,3 +15,5 @@ export { SharedPage } from './shared';
export { AnalyticsPage } from './analytics';
export { CursorPage } from './cursor';
export { UpdatesPage } from './updates';
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useMemo, useState } from 'react';
import { Megaphone, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { UpdatesDetailsPanel } from '@/components/updates/updates-details-panel';
import { UpdatesInboxItem } from '@/components/updates/updates-inbox-item';
import {
SUPPORT_NOTICES,
getSupportEntriesForNotice,
type SupportNotice,
} from '@/lib/support-updates-catalog';
import {
getNoticeProgress,
isActionableNoticeState,
readNoticeProgressMap,
writeNoticeProgressMap,
type NoticeProgressMap,
} from '@/lib/updates-notice-state';
type NoticeViewMode = 'inbox' | 'done' | 'all';
const NOTICE_VIEW_MODES: { id: NoticeViewMode; label: string }[] = [
{ id: 'inbox', label: 'Action Required' },
{ id: 'done', label: 'Done' },
{ id: 'all', label: 'All' },
];
function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean {
if (!queryValue) {
return true;
}
const haystack = [
notice.title,
notice.summary,
notice.primaryAction,
...notice.highlights,
...notice.commands,
...notice.actions.map(
(action) => `${action.label} ${action.description} ${action.command || ''}`
),
...notice.routes.map((route) => route.label),
]
.join(' ')
.toLowerCase();
return haystack.includes(queryValue);
}
export function UpdatesPage() {
const notices = useMemo(
() => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)),
[]
);
const [viewMode, setViewMode] = useState<NoticeViewMode>('inbox');
const [query, setQuery] = useState('');
const [progressMap, setProgressMap] = useState<NoticeProgressMap>(() => readNoticeProgressMap());
const [selectedNoticeId, setSelectedNoticeId] = useState<string | null>(null);
useEffect(() => {
writeNoticeProgressMap(progressMap);
}, [progressMap]);
const visibleNotices = useMemo(() => {
const queryValue = query.trim().toLowerCase();
return notices.filter((notice) => {
const progress = getNoticeProgress(notice, progressMap);
const matchesQuery = noticeMatchesQuery(notice, queryValue);
if (!matchesQuery) return false;
if (viewMode === 'done') return progress === 'done';
if (viewMode === 'inbox') return isActionableNoticeState(progress);
return true;
});
}, [notices, progressMap, query, viewMode]);
const selectedNotice = useMemo(() => {
const selectionPool = viewMode === 'all' ? notices : visibleNotices;
return (
selectionPool.find((notice) => notice.id === selectedNoticeId) ?? selectionPool[0] ?? null
);
}, [notices, selectedNoticeId, viewMode, visibleNotices]);
const handleSelectNotice = (notice: SupportNotice) => {
setSelectedNoticeId(notice.id);
setProgressMap((previous) => {
const progress = getNoticeProgress(notice, previous);
if (progress !== 'new') {
return previous;
}
return { ...previous, [notice.id]: 'seen' };
});
};
const pendingCount = useMemo(
() =>
notices.filter((notice) => isActionableNoticeState(getNoticeProgress(notice, progressMap)))
.length,
[notices, progressMap]
);
const doneCount = useMemo(
() => notices.filter((notice) => getNoticeProgress(notice, progressMap) === 'done').length,
[notices, progressMap]
);
return (
<div className="h-[calc(100vh-100px)] flex overflow-hidden">
<div className="w-80 border-r bg-muted/30 flex flex-col overflow-hidden">
<div className="p-4 border-b bg-background space-y-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-primary" />
<h1 className="font-semibold">Updates Inbox</h1>
</div>
<p className="text-xs text-muted-foreground">
Focus on actions, then mark updates done or dismissed.
</p>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border bg-background px-2 py-1.5">
<p className="text-muted-foreground">Needs Action</p>
<p className="text-base font-semibold">{pendingCount}</p>
</div>
<div className="rounded-md border bg-background px-2 py-1.5">
<p className="text-muted-foreground">Done</p>
<p className="text-base font-semibold">{doneCount}</p>
</div>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search actions or commands"
className="h-9 pl-8"
/>
</div>
<div className="flex flex-wrap gap-1.5">
{NOTICE_VIEW_MODES.map((mode) => (
<Button
key={mode.id}
size="sm"
variant={viewMode === mode.id ? 'default' : 'outline'}
onClick={() => setViewMode(mode.id)}
>
{mode.label}
</Button>
))}
</div>
</div>
<ScrollArea className="flex-1">
<div className="space-y-2 p-2">
{visibleNotices.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
No notices match this view.
</div>
) : (
visibleNotices.map((notice) => (
<UpdatesInboxItem
key={notice.id}
notice={notice}
progress={getNoticeProgress(notice, progressMap)}
selected={selectedNotice?.id === notice.id}
onSelect={() => handleSelectNotice(notice)}
/>
))
)}
</div>
</ScrollArea>
</div>
<UpdatesDetailsPanel
notice={selectedNotice}
progress={selectedNotice ? getNoticeProgress(selectedNotice, progressMap) : null}
relatedEntries={selectedNotice ? getSupportEntriesForNotice(selectedNotice) : []}
onUpdateProgress={(nextState) => {
if (!selectedNotice) return;
setProgressMap((previous) => ({ ...previous, [selectedNotice.id]: nextState }));
}}
/>
</div>
);
}