mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat: add API profile lifecycle discover/copy/export/import parity
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user