feat: add API profile lifecycle discover/copy/export/import parity

This commit is contained in:
Tam Nhu Tran
2026-03-05 18:30:23 +07:00
parent 8403284cd2
commit 02c81743a0
13 changed files with 1732 additions and 31 deletions
+4
View File
@@ -152,6 +152,10 @@ ccs ollama # Local Ollama (no API key needed)
ccs glm # GLM (API key)
ccs km # Kimi API profile (API key)
ccs api create --preset alibaba-coding-plan # Alibaba Coding Plan profile
ccs api discover --register # Auto-register orphan *.settings.json
ccs api copy glm glm-backup # Duplicate profile config + settings
ccs api export glm --out ./glm.ccs-profile.json # Export for cross-device transfer
ccs api import ./glm.ccs-profile.json # Import exported profile bundle
```
### Droid Alias (`argv[0]` pattern)
+19
View File
@@ -16,6 +16,15 @@ export {
type CreateApiProfileResult,
type RemoveApiProfileResult,
type UpdateApiProfileTargetResult,
type ProfileValidationIssue,
type ProfileValidationSummary,
type ApiProfileOrphanCandidate,
type DiscoverApiProfileOrphansResult,
type RegisterApiProfileOrphansResult,
type CopyApiProfileResult,
type ApiProfileExportBundle,
type ExportApiProfileResult,
type ImportApiProfileResult,
} from './profile-types';
// Profile read operations
@@ -30,6 +39,16 @@ export {
// Profile write operations
export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer';
// Lifecycle validation and operations
export { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
export {
discoverApiProfileOrphans,
registerApiProfileOrphans,
copyApiProfile,
exportApiProfile,
importApiProfileBundle,
} from './profile-lifecycle-service';
// OpenRouter catalog and picker
export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog';
export { pickOpenRouterModel, type OpenRouterSelection } from './openrouter-picker';
@@ -0,0 +1,380 @@
/**
* API profile lifecycle service.
*
* Discovery, registration, copy, export, and import for API profiles.
*/
import * as fs from 'fs';
import * as path from 'path';
import type { Config, Settings } from '../../types';
import type { TargetType } from '../../targets/target-adapter';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import { isSensitiveKey } from '../../utils/sensitive-keys';
import { isReservedName } from '../../config/reserved-names';
import {
isUnifiedMode,
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
} from '../../config/unified-config-loader';
import { validateApiName } from './validation-service';
import { listApiProfiles } from './profile-reader';
import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
import type {
ApiProfileExportBundle,
CopyApiProfileResult,
DiscoverApiProfileOrphansResult,
ExportApiProfileResult,
ImportApiProfileResult,
RegisterApiProfileOrphansResult,
} from './profile-types';
const SETTINGS_FILE_SUFFIX = '.settings.json';
const REDACTED_TOKEN_SENTINEL = '__CCS_REDACTED__';
function validateProfileNameForPath(name: string, label: string): string | null {
const validationError = validateApiName(name);
if (validationError) {
return `Invalid ${label} profile name "${name}": ${validationError}`;
}
return null;
}
function getProfileSettingsPath(name: string): string {
return path.join(getCcsDir(), `${name}${SETTINGS_FILE_SUFFIX}`);
}
function writeJsonObjectAtomically(filePath: string, value: unknown): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const tempPath = `${filePath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + '\n', 'utf8');
fs.renameSync(tempPath, filePath);
}
function registerApiProfileInConfig(name: string, target: TargetType, force = false): void {
if (isUnifiedMode()) {
const config = loadOrCreateUnifiedConfig();
if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`);
}
config.profiles[name] = {
type: 'api',
settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`,
...(target !== 'claude' && { target }),
};
saveUnifiedConfig(config);
return;
}
const configPath = getConfigPath();
const config = loadConfigSafe() as Config;
if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`);
}
config.profiles[name] = `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`;
config.profile_targets = config.profile_targets || {};
if (target === 'claude') {
delete config.profile_targets[name];
} else {
config.profile_targets[name] = target;
}
writeJsonObjectAtomically(configPath, config);
}
function getRegisteredSettingsFileNames(): Set<string> {
const { profiles, variants } = listApiProfiles();
const names = new Set<string>();
for (const profile of profiles) {
names.add(`${profile.name}${SETTINGS_FILE_SUFFIX}`);
}
for (const variant of variants) {
if (!variant.settings || variant.settings === '-') continue;
names.add(path.basename(variant.settings.replace(/^~\/\.ccs\//, '')));
}
return names;
}
function getProfileTarget(name: string): TargetType {
const { profiles } = listApiProfiles();
return profiles.find((profile) => profile.name === name)?.target || 'claude';
}
function readJsonObject(filePath: string): Record<string, unknown> {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Settings file must contain a JSON object.');
}
return parsed as Record<string, unknown>;
}
function rollbackSettingsFile(
filePath: string,
previousContent: string | null,
existedBefore: boolean
): void {
if (existedBefore && previousContent !== null) {
fs.writeFileSync(filePath, previousContent, 'utf8');
return;
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
export function discoverApiProfileOrphans(): DiscoverApiProfileOrphansResult {
const ccsDir = getCcsDir();
if (!fs.existsSync(ccsDir)) {
return { orphans: [] };
}
const registeredSettings = getRegisteredSettingsFileNames();
const files = fs.readdirSync(ccsDir).filter((file) => file.endsWith(SETTINGS_FILE_SUFFIX));
const ignoredNames = new Set(['cursor.settings.json']);
const orphans = files
.filter((file) => !registeredSettings.has(file))
.filter((file) => !file.startsWith('base-'))
.filter((file) => !ignoredNames.has(file))
.filter((file) => !isReservedName(file.slice(0, -SETTINGS_FILE_SUFFIX.length)))
.map((file) => {
const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length);
const settingsPath = path.join(ccsDir, file);
try {
const settings = readJsonObject(settingsPath);
return {
name,
settingsPath,
validation: validateApiProfileSettingsPayload(settings),
};
} catch (error) {
return {
name,
settingsPath,
validation: {
valid: false,
issues: [
{
level: 'error' as const,
code: 'invalid_json',
message: (error as Error).message,
field: 'settings',
hint: 'Fix JSON syntax before registration.',
},
],
},
};
}
});
return { orphans };
}
export function registerApiProfileOrphans(options?: {
names?: string[];
target?: TargetType;
force?: boolean;
}): RegisterApiProfileOrphansResult {
const discovered = discoverApiProfileOrphans();
const selected =
options?.names === undefined
? discovered.orphans
: discovered.orphans.filter((orphan) => options.names?.includes(orphan.name));
const result: RegisterApiProfileOrphansResult = { registered: [], skipped: [] };
for (const orphan of selected) {
if (!options?.force && !orphan.validation.valid) {
result.skipped.push({
name: orphan.name,
reason: 'Validation failed. Use --force to register.',
});
continue;
}
try {
registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false);
ensureProfileHooks(orphan.name);
result.registered.push(orphan.name);
} catch (error) {
result.skipped.push({ name: orphan.name, reason: (error as Error).message });
}
}
return result;
}
export function copyApiProfile(
source: string,
destination: string,
options?: { target?: TargetType; force?: boolean }
): CopyApiProfileResult {
const sourceError = validateProfileNameForPath(source, 'source');
if (sourceError) return { success: false, error: sourceError };
const destinationError = validateApiName(destination);
if (destinationError) return { success: false, error: destinationError };
const sourceSettingsPath = getProfileSettingsPath(source);
if (!fs.existsSync(sourceSettingsPath)) {
return { success: false, error: `Source profile settings not found: ${source}` };
}
const destinationSettingsPath = getProfileSettingsPath(destination);
if (fs.existsSync(destinationSettingsPath) && !options?.force) {
return { success: false, error: `Destination settings already exist: ${destination}` };
}
try {
const sourceSettings = readJsonObject(sourceSettingsPath) as Settings;
const validation = validateApiProfileSettingsPayload(sourceSettings);
if (!validation.valid && !options?.force) {
return {
success: false,
error: 'Source profile has validation errors. Use --force to copy.',
};
}
const destinationExisted = fs.existsSync(destinationSettingsPath);
const previousDestinationContent = destinationExisted
? fs.readFileSync(destinationSettingsPath, 'utf8')
: null;
writeJsonObjectAtomically(destinationSettingsPath, sourceSettings);
ensureProfileHooks(destination);
try {
registerApiProfileInConfig(
destination,
options?.target || getProfileTarget(source),
options?.force
);
} catch (registrationError) {
rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted);
throw registrationError;
}
return {
success: true,
name: destination,
settingsPath: destinationSettingsPath,
warnings: validation.issues
.filter((issue) => issue.level === 'warning')
.map((issue) => issue.message),
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
export function exportApiProfile(name: string, includeSecrets = false): ExportApiProfileResult {
const nameError = validateProfileNameForPath(name, 'profile');
if (nameError) return { success: false, error: nameError };
const settingsPath = getProfileSettingsPath(name);
if (!fs.existsSync(settingsPath)) {
return { success: false, error: `Profile settings not found: ${name}` };
}
try {
const settings = readJsonObject(settingsPath);
let redacted = false;
if (!includeSecrets) {
const env = settings.env;
if (typeof env === 'object' && env !== null) {
for (const [key, value] of Object.entries(env as Record<string, unknown>)) {
if (!isSensitiveKey(key) || typeof value !== 'string') continue;
(env as Record<string, unknown>)[key] = REDACTED_TOKEN_SENTINEL;
redacted = true;
}
}
}
const bundle: ApiProfileExportBundle = {
schemaVersion: 1,
exportedAt: new Date().toISOString(),
profile: {
name,
target: getProfileTarget(name),
},
settings,
};
return { success: true, bundle, redacted };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
export function importApiProfileBundle(
bundle: unknown,
options?: { name?: string; target?: TargetType; force?: boolean }
): ImportApiProfileResult {
if (typeof bundle !== 'object' || bundle === null || Array.isArray(bundle)) {
return { success: false, error: 'Import bundle must be a JSON object.' };
}
const input = bundle as Partial<ApiProfileExportBundle>;
if (input.schemaVersion !== 1 || !input.profile || !input.settings) {
return {
success: false,
error: 'Invalid bundle schema. Expected schemaVersion=1 with profile and settings.',
};
}
const name = options?.name || input.profile.name;
const nameError = validateApiName(name);
if (nameError) return { success: false, error: nameError };
const settings = JSON.parse(JSON.stringify(input.settings)) as Record<string, unknown>;
const env = settings.env as Record<string, unknown> | undefined;
const warnings: string[] = [];
if (env?.ANTHROPIC_AUTH_TOKEN === REDACTED_TOKEN_SENTINEL) {
env.ANTHROPIC_AUTH_TOKEN = '';
warnings.push('Imported bundle had redacted token. Set ANTHROPIC_AUTH_TOKEN before use.');
}
const validation = validateApiProfileSettingsPayload(settings);
if (!validation.valid && !options?.force) {
return { success: false, error: 'Import validation failed.', validation };
}
const settingsPath = getProfileSettingsPath(name);
try {
const settingsExisted = fs.existsSync(settingsPath);
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
writeJsonObjectAtomically(settingsPath, settings);
ensureProfileHooks(name);
try {
registerApiProfileInConfig(
name,
options?.target || input.profile.target || 'claude',
options?.force
);
} catch (registrationError) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw registrationError;
}
warnings.push(
...validation.issues
.filter((issue) => issue.level === 'warning')
.map((issue) => issue.message)
);
return { success: true, name, warnings, validation };
} catch (error) {
return { success: false, error: (error as Error).message, validation };
}
}
@@ -0,0 +1,150 @@
/**
* Profile lifecycle validation helpers.
*
* Shared by orphan discovery, copy, export/import, and dashboard routes.
*/
import {
extractProviderFromPathname,
getDeniedModelIdReasonForProvider,
} from '../../cliproxy/model-id-normalizer';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import type { CLIProxyProvider } from '../../cliproxy/types';
import type { ProfileValidationIssue, ProfileValidationSummary } from './profile-types';
const MODEL_ENV_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
const ALLOWED_ANTHROPIC_ENV_KEYS = new Set<string>([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_API_KEY',
...MODEL_ENV_KEYS,
]);
function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null {
if (!baseUrl.trim()) return null;
try {
const parsed = new URL(baseUrl);
const extracted = extractProviderFromPathname(parsed.pathname);
return extracted ? mapExternalProviderName(extracted) : null;
} catch {
const extracted = extractProviderFromPathname(baseUrl);
return extracted ? mapExternalProviderName(extracted) : null;
}
}
function pushIssue(
issues: ProfileValidationIssue[],
level: ProfileValidationIssue['level'],
code: string,
message: string,
field?: string,
hint?: string
): void {
issues.push({ level, code, message, field, hint });
}
function asObject(value: unknown): Record<string, unknown> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
/**
* Validate an API profile settings payload and return actionable diagnostics.
*/
export function validateApiProfileSettingsPayload(settings: unknown): ProfileValidationSummary {
const issues: ProfileValidationIssue[] = [];
const settingsObj = asObject(settings);
if (!settingsObj) {
pushIssue(
issues,
'error',
'invalid_settings_type',
'Settings payload must be a JSON object.',
'settings',
'Expected object like { "env": { ... } }'
);
return { valid: false, issues };
}
const envObj = asObject(settingsObj.env);
if (!envObj) {
pushIssue(
issues,
'error',
'missing_env_object',
'settings.env must be a JSON object.',
'settings.env',
'Add env keys such as ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN.'
);
return { valid: false, issues };
}
const baseUrl =
typeof envObj.ANTHROPIC_BASE_URL === 'string' ? envObj.ANTHROPIC_BASE_URL.trim() : '';
if (!baseUrl) {
pushIssue(
issues,
'error',
'missing_base_url',
'ANTHROPIC_BASE_URL is required.',
'env.ANTHROPIC_BASE_URL',
'Example: https://api.openai.com/v1 or provider-specific endpoint.'
);
}
const authToken =
typeof envObj.ANTHROPIC_AUTH_TOKEN === 'string' ? envObj.ANTHROPIC_AUTH_TOKEN.trim() : '';
if (!authToken) {
pushIssue(
issues,
'warning',
'missing_auth_token',
'ANTHROPIC_AUTH_TOKEN is empty; profile may not run until token is configured.',
'env.ANTHROPIC_AUTH_TOKEN',
'Set token after import if exported in redacted mode.'
);
}
const provider = resolveProviderFromBaseUrl(baseUrl);
for (const modelKey of MODEL_ENV_KEYS) {
const value = envObj[modelKey];
if (typeof value !== 'string' || value.trim().length === 0) continue;
const denyReason = getDeniedModelIdReasonForProvider(value, provider);
if (denyReason) {
pushIssue(
issues,
'error',
'model_denylisted',
`${modelKey}: ${denyReason}`,
`env.${modelKey}`,
'Choose a supported model for the provider endpoint.'
);
}
}
for (const key of Object.keys(envObj)) {
if (key.startsWith('ANTHROPIC_') && !ALLOWED_ANTHROPIC_ENV_KEYS.has(key)) {
pushIssue(
issues,
'warning',
'unknown_anthropic_env_key',
`Unknown ANTHROPIC env key: ${key}`,
`env.${key}`,
'Check for typos or provider-unsupported settings.'
);
}
}
const hasErrors = issues.some((issue) => issue.level === 'error');
return { valid: !hasErrors, issues };
}
+73
View File
@@ -56,3 +56,76 @@ export interface UpdateApiProfileTargetResult {
target?: TargetType;
error?: string;
}
/** Validation severity for profile lifecycle checks */
export type ProfileValidationLevel = 'error' | 'warning';
/** Field-level validation issue emitted by lifecycle operations */
export interface ProfileValidationIssue {
level: ProfileValidationLevel;
code: string;
message: string;
field?: string;
hint?: string;
}
/** Validation summary for settings payload */
export interface ProfileValidationSummary {
valid: boolean;
issues: ProfileValidationIssue[];
}
/** Orphan settings file candidate discovered on disk */
export interface ApiProfileOrphanCandidate {
name: string;
settingsPath: string;
validation: ProfileValidationSummary;
}
/** Discovery result for orphan settings files */
export interface DiscoverApiProfileOrphansResult {
orphans: ApiProfileOrphanCandidate[];
}
/** Registration result for orphan settings files */
export interface RegisterApiProfileOrphansResult {
registered: string[];
skipped: Array<{ name: string; reason: string }>;
}
/** Copy result for API profile duplication */
export interface CopyApiProfileResult {
success: boolean;
name?: string;
settingsPath?: string;
warnings?: string[];
error?: string;
}
/** Portable export bundle schema */
export interface ApiProfileExportBundle {
schemaVersion: 1;
exportedAt: string;
profile: {
name: string;
target: TargetType;
};
settings: Record<string, unknown>;
}
/** Export operation result */
export interface ExportApiProfileResult {
success: boolean;
bundle?: ApiProfileExportBundle;
redacted?: boolean;
error?: string;
}
/** Import operation result */
export interface ImportApiProfileResult {
success: boolean;
name?: string;
warnings?: string[];
validation?: ProfileValidationSummary;
error?: string;
}
+309
View File
@@ -8,6 +8,8 @@
* Business logic delegated to src/api/services/.
*/
import * as fs from 'fs';
import * as path from 'path';
import {
initUI,
header,
@@ -39,6 +41,11 @@ import {
getPresetById,
getPresetAliases,
getPresetIds,
discoverApiProfileOrphans,
registerApiProfileOrphans,
copyApiProfile,
exportApiProfile,
importApiProfileBundle,
type ModelMapping,
type ProviderPreset,
} from '../api/services';
@@ -140,6 +147,30 @@ function parseTargetValue(value: string): TargetType | null {
return null;
}
function parseOptionalTargetFlag(
args: string[],
knownFlags: readonly string[]
): { target?: TargetType; remainingArgs: string[]; errors: string[] } {
const extracted = extractOption(args, ['--target'], {
allowDashValue: true,
knownFlags,
});
if (!extracted.found) {
return { remainingArgs: args, errors: [] };
}
if (extracted.missingValue || !extracted.value) {
return { remainingArgs: extracted.remainingArgs, errors: ['Missing value for --target'] };
}
const target = parseTargetValue(extracted.value);
if (!target) {
return {
remainingArgs: extracted.remainingArgs,
errors: [`Invalid --target value "${extracted.value}". Use: claude or droid`],
};
}
return { target, remainingArgs: extracted.remainingArgs, errors: [] };
}
/** Parse command line arguments for api commands */
export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
const result: ApiCommandArgs = {
@@ -611,6 +642,246 @@ async function handleRemove(args: string[]): Promise<void> {
console.log('');
}
/** Handle 'ccs api discover' command */
async function handleDiscover(args: string[]): Promise<void> {
await initUI();
const register = hasAnyFlag(args, ['--register']);
const jsonOutput = hasAnyFlag(args, ['--json']);
const force = hasAnyFlag(args, ['--force']);
const targetParsed = parseOptionalTargetFlag(args, [...API_KNOWN_FLAGS, '--register', '--json']);
if (targetParsed.errors.length > 0) {
targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const result = discoverApiProfileOrphans();
if (jsonOutput) {
console.log(JSON.stringify(result, null, 2));
return;
}
console.log(header('Discover Orphan API Profiles'));
console.log('');
if (result.orphans.length === 0) {
console.log(ok('No orphan settings files found.'));
console.log('');
return;
}
const rows = result.orphans.map((orphan) => {
const status = orphan.validation.valid ? color('[OK]', 'success') : color('[X]', 'error');
const issueSummary =
orphan.validation.issues.length > 0
? orphan.validation.issues[0].message
: 'Ready to register';
return [orphan.name, status, issueSummary];
});
console.log(
table(rows, {
head: ['Profile', 'Status', 'Validation'],
colWidths: [20, 10, 64],
})
);
console.log('');
if (!register) {
console.log(info('To register discovered profiles:'));
console.log(` ${color('ccs api discover --register', 'command')}`);
console.log('');
return;
}
const registration = registerApiProfileOrphans({
target: targetParsed.target || 'claude',
force,
});
console.log(ok(`Registered: ${registration.registered.length}`));
if (registration.skipped.length > 0) {
console.log(warn(`Skipped: ${registration.skipped.length}`));
registration.skipped.forEach((item) => {
console.log(` - ${item.name}: ${item.reason}`);
});
}
console.log('');
}
/** Handle 'ccs api copy' command */
async function handleCopy(args: string[]): Promise<void> {
await initUI();
const parsedArgs = parseApiCommandArgs(args);
if (parsedArgs.errors.length > 0) {
parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const positionals = extractPositionalArgs(args);
const source = positionals[0];
let destination = positionals[1];
if (!source) {
console.log(fail('Source profile is required. Usage: ccs api copy <source> <destination>'));
process.exit(1);
}
if (!destination) {
destination = await InteractivePrompt.input('Destination profile name');
}
if (!parsedArgs.yes) {
const confirmed = await InteractivePrompt.confirm(
`Copy profile "${source}" to "${destination}"?`,
{ default: true }
);
if (!confirmed) {
console.log(info('Cancelled'));
process.exit(0);
}
}
const result = copyApiProfile(source, destination, {
target: parsedArgs.target,
force: parsedArgs.force,
});
if (!result.success) {
console.log(fail(result.error || 'Failed to copy profile'));
process.exit(1);
}
console.log(ok(`Profile copied: ${source} -> ${destination}`));
if (result.warnings && result.warnings.length > 0) {
result.warnings.forEach((warningMessage) => console.log(warn(warningMessage)));
}
console.log('');
}
/** Handle 'ccs api export' command */
async function handleExport(args: string[]): Promise<void> {
await initUI();
const includeSecrets = hasAnyFlag(args, ['--include-secrets']);
const outExtracted = extractOption(args, ['--out'], {
allowDashValue: true,
knownFlags: [...API_KNOWN_FLAGS, '--out', '--include-secrets'],
});
if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) {
console.log(fail('Missing value for --out'));
process.exit(1);
}
const outPath = outExtracted.value;
const positionals = extractPositionalArgs(outExtracted.remainingArgs);
const name = positionals[0];
if (!name) {
console.log(fail('Profile name is required. Usage: ccs api export <name> [--out <file>]'));
process.exit(1);
}
const result = exportApiProfile(name, includeSecrets);
if (!result.success || !result.bundle) {
console.log(fail(result.error || 'Failed to export profile'));
process.exit(1);
}
const resolvedOutputPath = path.resolve(outPath || `${name}.ccs-profile.json`);
fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
fs.writeFileSync(resolvedOutputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8');
console.log(ok(`Profile exported to: ${resolvedOutputPath}`));
if (result.redacted) {
console.log(warn('Token was redacted in export. Use --include-secrets to include it.'));
}
console.log('');
}
/** Handle 'ccs api import' command */
async function handleImport(args: string[]): Promise<void> {
await initUI();
const force = hasAnyFlag(args, ['--force']);
const yes = hasAnyFlag(args, ['--yes', '-y']);
const nameExtracted = extractOption(args, ['--name'], {
allowDashValue: true,
knownFlags: [...API_KNOWN_FLAGS, '--name'],
});
if (nameExtracted.found && (nameExtracted.missingValue || !nameExtracted.value)) {
console.log(fail('Missing value for --name'));
process.exit(1);
}
const targetParsed = parseOptionalTargetFlag(nameExtracted.remainingArgs, [
...API_KNOWN_FLAGS,
'--name',
]);
if (targetParsed.errors.length > 0) {
targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const positionals = extractPositionalArgs(targetParsed.remainingArgs);
const importPath = positionals[0];
if (!importPath) {
console.log(
fail('Import file path is required. Usage: ccs api import <file> [--name <new-name>]')
);
process.exit(1);
}
if (!fs.existsSync(importPath)) {
console.log(fail(`File not found: ${importPath}`));
process.exit(1);
}
const raw = fs.readFileSync(importPath, 'utf8');
let bundle: unknown;
try {
bundle = JSON.parse(raw);
} catch (error) {
console.log(fail(`Invalid JSON file: ${(error as Error).message}`));
process.exit(1);
}
if (!yes) {
const confirmed = await InteractivePrompt.confirm(
`Import profile bundle from "${importPath}"?`,
{
default: true,
}
);
if (!confirmed) {
console.log(info('Cancelled'));
process.exit(0);
}
}
const result = importApiProfileBundle(bundle, {
name: nameExtracted.value,
target: targetParsed.target,
force,
});
if (!result.success) {
console.log(fail(result.error || 'Failed to import profile'));
if (result.validation?.issues?.length) {
console.log('');
result.validation.issues.forEach((issue) => {
const indicator = issue.level === 'error' ? color('[X]', 'error') : color('[!]', 'warning');
console.log(`${indicator} ${issue.message}`);
});
}
process.exit(1);
}
console.log(ok(`Profile imported: ${result.name}`));
if (result.warnings && result.warnings.length > 0) {
result.warnings.forEach((warningMessage) => console.log(warn(warningMessage)));
}
console.log('');
}
/** Show help for api commands */
async function showHelp(): Promise<void> {
await initUI();
@@ -628,6 +899,16 @@ async function showHelp(): Promise<void> {
console.log(subheader('Commands'));
console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`);
console.log(` ${color('list', 'command')} List all API profiles`);
console.log(
` ${color('discover', 'command')} Discover orphan *.settings.json and register`
);
console.log(` ${color('copy <src> <dest>', 'command')} Duplicate API profile settings + config`);
console.log(
` ${color('export <name>', 'command')} Export profile bundle for cross-device transfer`
);
console.log(
` ${color('import <file>', 'command')} Import profile bundle and register profile`
);
console.log(` ${color('remove <name>', 'command')} Remove an API profile`);
console.log('');
console.log(subheader('Options'));
@@ -640,6 +921,11 @@ async function showHelp(): Promise<void> {
console.log(
` ${color('--target <cli>', 'command')} Default target: claude or droid (create)`
);
console.log(` ${color('--register', 'command')} Register discovered orphan settings`);
console.log(` ${color('--json', 'command')} JSON output for discover command`);
console.log(` ${color('--out <file>', 'command')} Export bundle output path`);
console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`);
console.log(` ${color('--name <name>', 'command')} Override profile name during import`);
console.log(` ${color('--force', 'command')} Overwrite existing (create)`);
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`);
console.log('');
@@ -670,6 +956,17 @@ async function showHelp(): Promise<void> {
console.log(` ${dim('# Remove API profile')}`);
console.log(` ${color('ccs api remove myapi', 'command')}`);
console.log('');
console.log(` ${dim('# Discover and register orphan settings files')}`);
console.log(` ${color('ccs api discover', 'command')}`);
console.log(` ${color('ccs api discover --register', 'command')}`);
console.log('');
console.log(` ${dim('# Duplicate an existing API profile')}`);
console.log(` ${color('ccs api copy glm glm-backup', 'command')}`);
console.log('');
console.log(` ${dim('# Export and import across devices')}`);
console.log(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`);
console.log(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`);
console.log('');
console.log(` ${dim('# Show all API profiles')}`);
console.log(` ${color('ccs api list', 'command')}`);
console.log('');
@@ -691,6 +988,18 @@ export async function handleApiCommand(args: string[]): Promise<void> {
case 'list':
await handleList();
break;
case 'discover':
await handleDiscover(args.slice(1));
break;
case 'copy':
await handleCopy(args.slice(1));
break;
case 'export':
await handleExport(args.slice(1));
break;
case 'import':
await handleImport(args.slice(1));
break;
case 'remove':
case 'delete':
case 'rm':
+4
View File
@@ -141,6 +141,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs ollama-cloud', 'Ollama Cloud (API key required)'],
['', ''], // Spacer
['ccs api create', 'Create custom API profile'],
['ccs api discover --register', 'Discover/register orphan settings files'],
['ccs api copy <src> <dest>', 'Duplicate API profile'],
['ccs api export <name>', 'Export profile bundle'],
['ccs api import <file>', 'Import profile bundle'],
['ccs api remove', 'Remove an API profile'],
['ccs api list', 'List all API profiles'],
]
+208 -2
View File
@@ -11,8 +11,15 @@ import {
createApiProfile,
removeApiProfile,
updateApiProfileTarget,
} from '../../api/services/profile-writer';
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
discoverApiProfileOrphans,
registerApiProfileOrphans,
copyApiProfile,
exportApiProfile,
importApiProfileBundle,
apiProfileExists,
listApiProfiles,
validateApiName,
} from '../../api/services';
import { normalizeDroidProvider } from '../../targets/droid-provider';
import { updateSettingsFile, parseTarget } from './route-helpers';
@@ -22,6 +29,29 @@ function isDenylistError(message: string | undefined): boolean {
return typeof message === 'string' && message.toLowerCase().includes('denylist');
}
function getUnknownKeys(
payload: Record<string, unknown>,
allowedKeys: readonly string[]
): string[] {
const allowed = new Set(allowedKeys);
return Object.keys(payload).filter((key) => !allowed.has(key));
}
function validatePayloadShape(
body: unknown,
allowedKeys: readonly string[]
): { ok: true; payload: Record<string, unknown> } | { ok: false; error: string } {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return { ok: false, error: 'Request body must be a JSON object' };
}
const payload = body as Record<string, unknown>;
const unknownKeys = getUnknownKeys(payload, allowedKeys);
if (unknownKeys.length > 0) {
return { ok: false, error: `Unknown profile field(s): ${unknownKeys.join(', ')}` };
}
return { ok: true, payload };
}
// ==================== Profile CRUD ====================
/**
@@ -47,6 +77,23 @@ router.get('/', (_req: Request, res: Response): void => {
* POST /api/profiles - Create new profile
*/
router.post('/', (req: Request, res: Response): void => {
const shape = validatePayloadShape(req.body, [
'name',
'baseUrl',
'apiKey',
'model',
'opusModel',
'sonnetModel',
'haikuModel',
'target',
'droidProvider',
'provider',
]);
if (!shape.ok) {
res.status(400).json({ error: shape.error });
return;
}
const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
const providerHint = req.body?.droidProvider ?? req.body?.provider;
const parsedProvider = normalizeDroidProvider(providerHint);
@@ -111,10 +158,169 @@ router.post('/', (req: Request, res: Response): void => {
});
});
/**
* GET /api/profiles/orphans - Discover orphan ~/.ccs/*.settings.json files
*/
router.get('/orphans', (_req: Request, res: Response): void => {
try {
const result = discoverApiProfileOrphans();
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/profiles/orphans/register - Register discovered orphan settings
*/
router.post('/orphans/register', (req: Request, res: Response): void => {
const shape = validatePayloadShape(req.body ?? {}, ['names', 'target', 'force']);
if (!shape.ok) {
res.status(400).json({ error: shape.error });
return;
}
const payload = shape.payload;
const names = Array.isArray(payload.names)
? payload.names.filter((value): value is string => typeof value === 'string')
: undefined;
const target = parseTarget(payload.target);
const force = payload.force === true;
if (payload.target !== undefined && target === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
try {
const result = registerApiProfileOrphans({
names,
target: target || 'claude',
force,
});
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/profiles/:name/copy - Duplicate an API profile
*/
router.post('/:name/copy', (req: Request, res: Response): void => {
const shape = validatePayloadShape(req.body, ['destination', 'target', 'force']);
if (!shape.ok) {
res.status(400).json({ error: shape.error });
return;
}
const { name } = req.params;
const sourceNameError = validateApiName(name);
if (sourceNameError) {
res.status(400).json({ error: sourceNameError });
return;
}
const destination = shape.payload.destination;
const target = parseTarget(shape.payload.target);
const force = shape.payload.force === true;
if (typeof destination !== 'string' || destination.trim().length === 0) {
res.status(400).json({ error: 'destination is required' });
return;
}
if (shape.payload.target !== undefined && target === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
const result = copyApiProfile(name, destination.trim(), { target: target || undefined, force });
if (!result.success) {
res.status(400).json({ error: result.error || 'Failed to copy profile' });
return;
}
res.status(201).json(result);
});
/**
* POST /api/profiles/:name/export - Export profile as a portable bundle
*/
router.post('/:name/export', (req: Request, res: Response): void => {
const shape = validatePayloadShape(req.body ?? {}, ['includeSecrets']);
if (!shape.ok) {
res.status(400).json({ error: shape.error });
return;
}
const { name } = req.params;
const profileNameError = validateApiName(name);
if (profileNameError) {
res.status(400).json({ error: profileNameError });
return;
}
const includeSecrets = shape.payload.includeSecrets === true;
const result = exportApiProfile(name, includeSecrets);
if (!result.success || !result.bundle) {
res.status(400).json({ error: result.error || 'Failed to export profile' });
return;
}
res.json(result);
});
/**
* POST /api/profiles/import - Import profile bundle into local registry
*/
router.post('/import', (req: Request, res: Response): void => {
const shape = validatePayloadShape(req.body, ['bundle', 'name', 'target', 'force']);
if (!shape.ok) {
res.status(400).json({ error: shape.error });
return;
}
const target = parseTarget(shape.payload.target);
if (shape.payload.target !== undefined && target === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
const result = importApiProfileBundle(shape.payload.bundle, {
name: typeof shape.payload.name === 'string' ? shape.payload.name : undefined,
target: target || undefined,
force: shape.payload.force === true,
});
if (!result.success) {
res.status(400).json({
error: result.error || 'Failed to import profile',
validation: result.validation,
});
return;
}
res.status(201).json(result);
});
/**
* PUT /api/profiles/:name - Update profile
*/
router.put('/:name', (req: Request, res: Response): void => {
const shape = validatePayloadShape(req.body, [
'baseUrl',
'apiKey',
'model',
'opusModel',
'sonnetModel',
'haikuModel',
'target',
'droidProvider',
'provider',
]);
if (!shape.ok) {
res.status(400).json({ error: shape.error });
return;
}
const { name } = req.params;
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
const providerHint = req.body?.droidProvider ?? req.body?.provider;
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
copyApiProfile,
discoverApiProfileOrphans,
exportApiProfile,
registerApiProfileOrphans,
} from '../../../src/api/services/profile-lifecycle-service';
describe('profile lifecycle service', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-lifecycle-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('discovers only API profile orphans (skips registered and reserved names)', () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { glm: '~/.ccs/glm.settings.json' } }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'glm.settings.json'),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
'\n'
);
fs.writeFileSync(
path.join(ccsDir, 'extra.settings.json'),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
'\n'
);
fs.writeFileSync(
path.join(ccsDir, 'gemini.settings.json'),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
'\n'
);
const result = discoverApiProfileOrphans();
expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']);
});
it('treats explicit empty names list as no-op during orphan registration', () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'lonely.settings.json'),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
'\n'
);
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
const result = registerApiProfileOrphans({ names: [] });
expect(result.registered).toEqual([]);
expect(result.skipped).toEqual([]);
});
it('redacts all sensitive env values during export when includeSecrets=false', () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { glm: '~/.ccs/glm.settings.json' } }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'glm.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.example.com',
ANTHROPIC_AUTH_TOKEN: 'token-1',
OPENROUTER_API_KEY: 'token-2',
},
},
null,
2
) + '\n'
);
const result = exportApiProfile('glm', false);
expect(result.success).toBe(true);
expect(result.bundle?.settings).toBeDefined();
const env = (result.bundle?.settings.env as Record<string, unknown>) || {};
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('__CCS_REDACTED__');
expect(env.OPENROUTER_API_KEY).toBe('__CCS_REDACTED__');
});
it('rejects invalid source profile names in copy flow', () => {
const result = copyApiProfile('../escape', 'safe-name');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid source profile name');
});
});
@@ -0,0 +1,110 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
import profileRoutes from '../../../src/web-server/routes/profile-routes';
describe('profile-routes lifecycle endpoints', () => {
let server: Server;
let baseUrl = '';
let tempHome = '';
let originalCcsHome: string | undefined;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use('/api/profiles', profileRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-lifecycle-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('rejects unknown fields on profile create payload', async () => {
const response = await fetch(`${baseUrl}/api/profiles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'demo',
baseUrl: 'https://api.example.com',
apiKey: 'token',
unknownField: true,
}),
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error: string };
expect(body.error).toContain('Unknown profile field(s)');
});
it('does not register all orphans when names=[] is explicitly passed', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
fs.writeFileSync(
path.join(ccsDir, 'lonely.settings.json'),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
'\n'
);
const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ names: [] }),
});
expect(response.status).toBe(200);
const body = (await response.json()) as { registered: string[]; skipped: Array<unknown> };
expect(body.registered).toEqual([]);
expect(body.skipped).toEqual([]);
});
it('validates source profile name on export endpoint', async () => {
const response = await fetch(`${baseUrl}/api/profiles/1invalid/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error: string };
expect(body.error).toContain('API name must start with letter');
});
});
+73 -1
View File
@@ -4,7 +4,14 @@
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, type CreateProfile, type UpdateProfile } from '@/lib/api-client';
import {
api,
type CreateProfile,
type UpdateProfile,
type RegisterProfileOrphansRequest,
type CopyProfileRequest,
type ImportProfileRequest,
} from '@/lib/api-client';
import { toast } from 'sonner';
export function useProfiles() {
@@ -59,3 +66,68 @@ export function useDeleteProfile() {
},
});
}
export function useDiscoverProfileOrphans() {
return useMutation({
mutationFn: () => api.profiles.discoverOrphans(),
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useRegisterProfileOrphans() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: RegisterProfileOrphansRequest) => api.profiles.registerOrphans(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Orphan profiles registration complete');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useCopyProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, data }: { name: string; data: CopyProfileRequest }) =>
api.profiles.copy(name, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Profile copied successfully');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useExportProfile() {
return useMutation({
mutationFn: ({ name, includeSecrets }: { name: string; includeSecrets?: boolean }) =>
api.profiles.export(name, includeSecrets ?? false),
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useImportProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ImportProfileRequest) => api.profiles.import(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Profile imported successfully');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
+91
View File
@@ -124,6 +124,76 @@ export interface UpdateProfile {
target?: CliTarget;
}
export interface ProfileValidationIssue {
level: 'error' | 'warning';
code: string;
message: string;
field?: string;
hint?: string;
}
export interface ProfileValidationSummary {
valid: boolean;
issues: ProfileValidationIssue[];
}
export interface ApiProfileOrphanCandidate {
name: string;
settingsPath: string;
validation: ProfileValidationSummary;
}
export interface DiscoverProfileOrphansResponse {
orphans: ApiProfileOrphanCandidate[];
}
export interface RegisterProfileOrphansRequest {
names?: string[];
target?: CliTarget;
force?: boolean;
}
export interface RegisterProfileOrphansResponse {
registered: string[];
skipped: Array<{ name: string; reason: string }>;
}
export interface CopyProfileRequest {
destination: string;
target?: CliTarget;
force?: boolean;
}
export interface ApiProfileExportBundle {
schemaVersion: 1;
exportedAt: string;
profile: {
name: string;
target: CliTarget;
};
settings: Record<string, unknown>;
}
export interface ExportProfileResponse {
success: boolean;
bundle: ApiProfileExportBundle;
redacted?: boolean;
}
export interface ImportProfileRequest {
bundle: ApiProfileExportBundle;
name?: string;
target?: CliTarget;
force?: boolean;
}
export interface ImportProfileResponse {
success: boolean;
name?: string;
warnings?: string[];
validation?: ProfileValidationSummary;
}
export interface Variant {
name: string;
provider: CLIProxyProvider;
@@ -634,6 +704,27 @@ export const api = {
body: JSON.stringify(data),
}),
delete: (name: string) => request(`/profiles/${name}`, { method: 'DELETE' }),
discoverOrphans: () => request<DiscoverProfileOrphansResponse>('/profiles/orphans'),
registerOrphans: (data: RegisterProfileOrphansRequest) =>
request<RegisterProfileOrphansResponse>('/profiles/orphans/register', {
method: 'POST',
body: JSON.stringify(data),
}),
copy: (name: string, data: CopyProfileRequest) =>
request(`/profiles/${name}/copy`, {
method: 'POST',
body: JSON.stringify(data),
}),
export: (name: string, includeSecrets = false) =>
request<ExportProfileResponse>(`/profiles/${name}/export`, {
method: 'POST',
body: JSON.stringify({ includeSecrets }),
}),
import: (data: ImportProfileRequest) =>
request<ImportProfileResponse>('/profiles/import', {
method: 'POST',
body: JSON.stringify(data),
}),
},
cliproxy: {
list: () => request<{ variants: Variant[] }>('/cliproxy'),
+194 -28
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { type ChangeEvent, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
@@ -12,6 +12,9 @@ import {
Server,
FileJson,
RefreshCw,
Copy,
Download,
Upload,
} from 'lucide-react';
import { ProfileEditor } from '@/components/profile-editor';
import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog';
@@ -19,18 +22,32 @@ import { OpenRouterBanner } from '@/components/profiles/openrouter-banner';
import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start';
import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card';
import { AlibabaCodingPlanPromoCard } from '@/components/profiles/alibaba-coding-plan-promo-card';
import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
import {
useProfiles,
useDeleteProfile,
useDiscoverProfileOrphans,
useRegisterProfileOrphans,
useCopyProfile,
useExportProfile,
useImportProfile,
} from '@/hooks/use-profiles';
import { useOpenRouterModels } from '@/hooks/use-openrouter-models';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import type { Profile } from '@/lib/api-client';
import type { ApiProfileExportBundle, Profile } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { CopyButton } from '@/components/ui/copy-button';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
export function ApiPage() {
const { t } = useTranslation();
const { data, isLoading, isError, refetch } = useProfiles();
const deleteMutation = useDeleteProfile();
const discoverOrphansMutation = useDiscoverProfileOrphans();
const registerOrphansMutation = useRegisterProfileOrphans();
const copyProfileMutation = useCopyProfile();
const exportProfileMutation = useExportProfile();
const importProfileMutation = useImportProfile();
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
@@ -40,6 +57,7 @@ export function ApiPage() {
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [editorHasChanges, setEditorHasChanges] = useState(false);
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
const importFileInputRef = useRef<HTMLInputElement>(null);
useOpenRouterModels();
const profiles = useMemo(() => data?.profiles || [], [data?.profiles]);
@@ -50,6 +68,15 @@ export function ApiPage() {
const selectedProfileData = selectedProfile
? profiles.find((p) => p.name === selectedProfile)
: null;
const switchToProfile = (name: string) => {
if (editorHasChanges && selectedProfile !== name) {
setPendingSwitch(name);
} else {
setSelectedProfile(name);
}
};
const handleDelete = (name: string) => {
deleteMutation.mutate(name, {
onSuccess: () => {
@@ -63,17 +90,106 @@ export function ApiPage() {
const handleCreateSuccess = (name: string) => {
setCreateDialogOpen(false);
if (editorHasChanges && selectedProfile !== null) {
setPendingSwitch(name);
} else {
setSelectedProfile(name);
}
switchToProfile(name);
};
const handleProfileSelect = (name: string) => {
if (editorHasChanges && selectedProfile !== name) {
setPendingSwitch(name);
} else {
setSelectedProfile(name);
switchToProfile(name);
};
const triggerDownload = (filename: string, bundle: ApiProfileExportBundle) => {
const content = JSON.stringify(bundle, null, 2) + '\n';
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
const handleDiscoverOrphans = async () => {
try {
const result = await discoverOrphansMutation.mutateAsync();
if (result.orphans.length === 0) {
toast.success('No orphan profile settings found');
return;
}
const validCount = result.orphans.filter((orphan) => orphan.validation.valid).length;
const shouldRegister = window.confirm(
`Found ${result.orphans.length} orphan settings file(s). Register ${validCount} valid profile(s) now?`
);
if (!shouldRegister) return;
const registration = await registerOrphansMutation.mutateAsync({});
const skippedMessage =
registration.skipped.length > 0 ? `, skipped ${registration.skipped.length}` : '';
toast.success(`Registered ${registration.registered.length} profile(s)${skippedMessage}`);
} catch (error) {
toast.error((error as Error).message);
}
};
const handleCopySelectedProfile = async () => {
if (!selectedProfileData) return;
const destination = window.prompt(
`Copy profile "${selectedProfileData.name}" to new profile name:`,
`${selectedProfileData.name}-copy`
);
if (!destination) return;
try {
await copyProfileMutation.mutateAsync({
name: selectedProfileData.name,
data: { destination: destination.trim() },
});
switchToProfile(destination.trim());
} catch (error) {
toast.error((error as Error).message);
}
};
const handleExportSelectedProfile = async () => {
if (!selectedProfileData) return;
try {
const result = await exportProfileMutation.mutateAsync({ name: selectedProfileData.name });
triggerDownload(`${selectedProfileData.name}.ccs-profile.json`, result.bundle);
if (result.redacted) {
toast.info(
'Export created with redacted token. Use include-secrets flow in CLI if needed.'
);
} else {
toast.success('Profile export downloaded');
}
} catch (error) {
toast.error((error as Error).message);
}
};
const handleImportClick = () => {
importFileInputRef.current?.click();
};
const handleImportFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
try {
const rawText = await file.text();
const bundle = JSON.parse(rawText) as ApiProfileExportBundle;
const result = await importProfileMutation.mutateAsync({ bundle });
if (result.name) {
switchToProfile(result.name);
}
if (result.warnings && result.warnings.length > 0) {
toast.info(result.warnings.join('\n'));
}
} catch (error) {
toast.error((error as Error).message || 'Failed to import profile bundle');
}
};
@@ -88,15 +204,35 @@ export function ApiPage() {
<Server className="w-5 h-5 text-primary" />
<h1 className="font-semibold">{t('apiProfiles.title')}</h1>
</div>
<Button
size="sm"
onClick={() => {
setCreateDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
{t('apiProfiles.new')}
</Button>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
onClick={() => void handleDiscoverOrphans()}
disabled={discoverOrphansMutation.isPending || registerOrphansMutation.isPending}
>
<RefreshCw
className={`w-4 h-4 ${discoverOrphansMutation.isPending ? 'animate-spin' : ''}`}
/>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleImportClick}
disabled={importProfileMutation.isPending}
>
<Upload className="w-4 h-4" />
</Button>
<Button
size="sm"
onClick={() => {
setCreateDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
{t('apiProfiles.new')}
</Button>
</div>
</div>
<div className="relative">
@@ -204,13 +340,35 @@ export function ApiPage() {
<div className="flex-1 flex flex-col min-w-0">
{selectedProfileData ? (
<ProfileEditor
key={selectedProfileData.name}
profileName={selectedProfileData.name}
profileTarget={selectedProfileData.target}
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
onHasChangesUpdate={setEditorHasChanges}
/>
<>
<div className="px-4 py-2 border-b bg-background flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void handleCopySelectedProfile()}
disabled={copyProfileMutation.isPending}
>
<Copy className="w-4 h-4 mr-1" />
Copy
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void handleExportSelectedProfile()}
disabled={exportProfileMutation.isPending}
>
<Download className="w-4 h-4 mr-1" />
Export
</Button>
</div>
<ProfileEditor
key={selectedProfileData.name}
profileName={selectedProfileData.name}
profileTarget={selectedProfileData.target}
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
onHasChangesUpdate={setEditorHasChanges}
/>
</>
) : (
<OpenRouterQuickStart
onOpenRouterClick={() => {
@@ -230,6 +388,14 @@ export function ApiPage() {
</div>
</div>
<input
ref={importFileInputRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={(event) => void handleImportFileChange(event)}
/>
<ProfileCreateDialog
open={isCreateDialogOpen}
onOpenChange={setCreateDialogOpen}