mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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')}`);
|
||||
|
||||
@@ -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)`));
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -15,3 +15,5 @@ export { SharedPage } from './shared';
|
||||
export { AnalyticsPage } from './analytics';
|
||||
|
||||
export { CursorPage } from './cursor';
|
||||
|
||||
export { UpdatesPage } from './updates';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user