mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 10:19:37 +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 glm # GLM (API key)
|
||||||
ccs km # Kimi API profile (API key)
|
ccs km # Kimi API profile (API key)
|
||||||
ccs api create --preset alibaba-coding-plan # Alibaba Coding Plan profile
|
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)
|
### Droid Alias (`argv[0]` pattern)
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ export {
|
|||||||
type CreateApiProfileResult,
|
type CreateApiProfileResult,
|
||||||
type RemoveApiProfileResult,
|
type RemoveApiProfileResult,
|
||||||
type UpdateApiProfileTargetResult,
|
type UpdateApiProfileTargetResult,
|
||||||
|
type ProfileValidationIssue,
|
||||||
|
type ProfileValidationSummary,
|
||||||
|
type ApiProfileOrphanCandidate,
|
||||||
|
type DiscoverApiProfileOrphansResult,
|
||||||
|
type RegisterApiProfileOrphansResult,
|
||||||
|
type CopyApiProfileResult,
|
||||||
|
type ApiProfileExportBundle,
|
||||||
|
type ExportApiProfileResult,
|
||||||
|
type ImportApiProfileResult,
|
||||||
} from './profile-types';
|
} from './profile-types';
|
||||||
|
|
||||||
// Profile read operations
|
// Profile read operations
|
||||||
@@ -30,6 +39,16 @@ export {
|
|||||||
// Profile write operations
|
// Profile write operations
|
||||||
export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer';
|
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
|
// OpenRouter catalog and picker
|
||||||
export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog';
|
export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog';
|
||||||
export { pickOpenRouterModel, type OpenRouterSelection } from './openrouter-picker';
|
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;
|
target?: TargetType;
|
||||||
error?: string;
|
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/.
|
* Business logic delegated to src/api/services/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
initUI,
|
initUI,
|
||||||
header,
|
header,
|
||||||
@@ -39,6 +41,11 @@ import {
|
|||||||
getPresetById,
|
getPresetById,
|
||||||
getPresetAliases,
|
getPresetAliases,
|
||||||
getPresetIds,
|
getPresetIds,
|
||||||
|
discoverApiProfileOrphans,
|
||||||
|
registerApiProfileOrphans,
|
||||||
|
copyApiProfile,
|
||||||
|
exportApiProfile,
|
||||||
|
importApiProfileBundle,
|
||||||
type ModelMapping,
|
type ModelMapping,
|
||||||
type ProviderPreset,
|
type ProviderPreset,
|
||||||
} from '../api/services';
|
} from '../api/services';
|
||||||
@@ -140,6 +147,30 @@ function parseTargetValue(value: string): TargetType | null {
|
|||||||
return 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 */
|
/** Parse command line arguments for api commands */
|
||||||
export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
|
export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
|
||||||
const result: ApiCommandArgs = {
|
const result: ApiCommandArgs = {
|
||||||
@@ -611,6 +642,246 @@ async function handleRemove(args: string[]): Promise<void> {
|
|||||||
console.log('');
|
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 */
|
/** Show help for api commands */
|
||||||
async function showHelp(): Promise<void> {
|
async function showHelp(): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
@@ -628,6 +899,16 @@ async function showHelp(): Promise<void> {
|
|||||||
console.log(subheader('Commands'));
|
console.log(subheader('Commands'));
|
||||||
console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`);
|
console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`);
|
||||||
console.log(` ${color('list', 'command')} List all API profiles`);
|
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(` ${color('remove <name>', 'command')} Remove an API profile`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(subheader('Options'));
|
console.log(subheader('Options'));
|
||||||
@@ -640,6 +921,11 @@ async function showHelp(): Promise<void> {
|
|||||||
console.log(
|
console.log(
|
||||||
` ${color('--target <cli>', 'command')} Default target: claude or droid (create)`
|
` ${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('--force', 'command')} Overwrite existing (create)`);
|
||||||
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`);
|
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`);
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -670,6 +956,17 @@ async function showHelp(): Promise<void> {
|
|||||||
console.log(` ${dim('# Remove API profile')}`);
|
console.log(` ${dim('# Remove API profile')}`);
|
||||||
console.log(` ${color('ccs api remove myapi', 'command')}`);
|
console.log(` ${color('ccs api remove myapi', 'command')}`);
|
||||||
console.log('');
|
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(` ${dim('# Show all API profiles')}`);
|
||||||
console.log(` ${color('ccs api list', 'command')}`);
|
console.log(` ${color('ccs api list', 'command')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -691,6 +988,18 @@ export async function handleApiCommand(args: string[]): Promise<void> {
|
|||||||
case 'list':
|
case 'list':
|
||||||
await handleList();
|
await handleList();
|
||||||
break;
|
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 'remove':
|
||||||
case 'delete':
|
case 'delete':
|
||||||
case 'rm':
|
case 'rm':
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
|||||||
['ccs ollama-cloud', 'Ollama Cloud (API key required)'],
|
['ccs ollama-cloud', 'Ollama Cloud (API key required)'],
|
||||||
['', ''], // Spacer
|
['', ''], // Spacer
|
||||||
['ccs api create', 'Create custom API profile'],
|
['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 remove', 'Remove an API profile'],
|
||||||
['ccs api list', 'List all API profiles'],
|
['ccs api list', 'List all API profiles'],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,8 +11,15 @@ import {
|
|||||||
createApiProfile,
|
createApiProfile,
|
||||||
removeApiProfile,
|
removeApiProfile,
|
||||||
updateApiProfileTarget,
|
updateApiProfileTarget,
|
||||||
} from '../../api/services/profile-writer';
|
discoverApiProfileOrphans,
|
||||||
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
|
registerApiProfileOrphans,
|
||||||
|
copyApiProfile,
|
||||||
|
exportApiProfile,
|
||||||
|
importApiProfileBundle,
|
||||||
|
apiProfileExists,
|
||||||
|
listApiProfiles,
|
||||||
|
validateApiName,
|
||||||
|
} from '../../api/services';
|
||||||
import { normalizeDroidProvider } from '../../targets/droid-provider';
|
import { normalizeDroidProvider } from '../../targets/droid-provider';
|
||||||
import { updateSettingsFile, parseTarget } from './route-helpers';
|
import { updateSettingsFile, parseTarget } from './route-helpers';
|
||||||
|
|
||||||
@@ -22,6 +29,29 @@ function isDenylistError(message: string | undefined): boolean {
|
|||||||
return typeof message === 'string' && message.toLowerCase().includes('denylist');
|
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 ====================
|
// ==================== Profile CRUD ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +77,23 @@ router.get('/', (_req: Request, res: Response): void => {
|
|||||||
* POST /api/profiles - Create new profile
|
* POST /api/profiles - Create new profile
|
||||||
*/
|
*/
|
||||||
router.post('/', (req: Request, res: Response): void => {
|
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 { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
|
||||||
const providerHint = req.body?.droidProvider ?? req.body?.provider;
|
const providerHint = req.body?.droidProvider ?? req.body?.provider;
|
||||||
const parsedProvider = normalizeDroidProvider(providerHint);
|
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
|
* PUT /api/profiles/:name - Update profile
|
||||||
*/
|
*/
|
||||||
router.put('/:name', (req: Request, res: Response): void => {
|
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 { name } = req.params;
|
||||||
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
|
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
|
||||||
const providerHint = req.body?.droidProvider ?? req.body?.provider;
|
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 { 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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function useProfiles() {
|
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;
|
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 {
|
export interface Variant {
|
||||||
name: string;
|
name: string;
|
||||||
provider: CLIProxyProvider;
|
provider: CLIProxyProvider;
|
||||||
@@ -634,6 +704,27 @@ export const api = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
delete: (name: string) => request(`/profiles/${name}`, { method: 'DELETE' }),
|
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: {
|
cliproxy: {
|
||||||
list: () => request<{ variants: Variant[] }>('/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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
FileJson,
|
FileJson,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ProfileEditor } from '@/components/profile-editor';
|
import { ProfileEditor } from '@/components/profile-editor';
|
||||||
import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog';
|
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 { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start';
|
||||||
import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card';
|
import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card';
|
||||||
import { AlibabaCodingPlanPromoCard } from '@/components/profiles/alibaba-coding-plan-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 { useOpenRouterModels } from '@/hooks/use-openrouter-models';
|
||||||
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
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 { cn } from '@/lib/utils';
|
||||||
import { CopyButton } from '@/components/ui/copy-button';
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function ApiPage() {
|
export function ApiPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isError, refetch } = useProfiles();
|
const { data, isLoading, isError, refetch } = useProfiles();
|
||||||
const deleteMutation = useDeleteProfile();
|
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 [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
@@ -40,6 +57,7 @@ export function ApiPage() {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
const [editorHasChanges, setEditorHasChanges] = useState(false);
|
const [editorHasChanges, setEditorHasChanges] = useState(false);
|
||||||
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
|
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
|
||||||
|
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useOpenRouterModels();
|
useOpenRouterModels();
|
||||||
const profiles = useMemo(() => data?.profiles || [], [data?.profiles]);
|
const profiles = useMemo(() => data?.profiles || [], [data?.profiles]);
|
||||||
@@ -50,6 +68,15 @@ export function ApiPage() {
|
|||||||
const selectedProfileData = selectedProfile
|
const selectedProfileData = selectedProfile
|
||||||
? profiles.find((p) => p.name === selectedProfile)
|
? profiles.find((p) => p.name === selectedProfile)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const switchToProfile = (name: string) => {
|
||||||
|
if (editorHasChanges && selectedProfile !== name) {
|
||||||
|
setPendingSwitch(name);
|
||||||
|
} else {
|
||||||
|
setSelectedProfile(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = (name: string) => {
|
const handleDelete = (name: string) => {
|
||||||
deleteMutation.mutate(name, {
|
deleteMutation.mutate(name, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -63,17 +90,106 @@ export function ApiPage() {
|
|||||||
|
|
||||||
const handleCreateSuccess = (name: string) => {
|
const handleCreateSuccess = (name: string) => {
|
||||||
setCreateDialogOpen(false);
|
setCreateDialogOpen(false);
|
||||||
if (editorHasChanges && selectedProfile !== null) {
|
switchToProfile(name);
|
||||||
setPendingSwitch(name);
|
|
||||||
} else {
|
|
||||||
setSelectedProfile(name);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const handleProfileSelect = (name: string) => {
|
const handleProfileSelect = (name: string) => {
|
||||||
if (editorHasChanges && selectedProfile !== name) {
|
switchToProfile(name);
|
||||||
setPendingSwitch(name);
|
};
|
||||||
} else {
|
|
||||||
setSelectedProfile(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" />
|
<Server className="w-5 h-5 text-primary" />
|
||||||
<h1 className="font-semibold">{t('apiProfiles.title')}</h1>
|
<h1 className="font-semibold">{t('apiProfiles.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => {
|
size="sm"
|
||||||
setCreateDialogOpen(true);
|
variant="outline"
|
||||||
}}
|
onClick={() => void handleDiscoverOrphans()}
|
||||||
>
|
disabled={discoverOrphansMutation.isPending || registerOrphansMutation.isPending}
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
>
|
||||||
{t('apiProfiles.new')}
|
<RefreshCw
|
||||||
</Button>
|
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>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -204,13 +340,35 @@ export function ApiPage() {
|
|||||||
|
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
{selectedProfileData ? (
|
{selectedProfileData ? (
|
||||||
<ProfileEditor
|
<>
|
||||||
key={selectedProfileData.name}
|
<div className="px-4 py-2 border-b bg-background flex items-center justify-end gap-2">
|
||||||
profileName={selectedProfileData.name}
|
<Button
|
||||||
profileTarget={selectedProfileData.target}
|
size="sm"
|
||||||
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
|
variant="outline"
|
||||||
onHasChangesUpdate={setEditorHasChanges}
|
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
|
<OpenRouterQuickStart
|
||||||
onOpenRouterClick={() => {
|
onOpenRouterClick={() => {
|
||||||
@@ -230,6 +388,14 @@ export function ApiPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={importFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => void handleImportFileChange(event)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ProfileCreateDialog
|
<ProfileCreateDialog
|
||||||
open={isCreateDialogOpen}
|
open={isCreateDialogOpen}
|
||||||
onOpenChange={setCreateDialogOpen}
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user