feat(api): unify profile management with config-aware services

- profile-reader: fix isApiProfileConfigured to check settings.json fallback
- profile-reader: exclude 'default' profile (native Claude) from listings
- profile-routes: use createApiProfile/removeApiProfile services
- provider-presets: add category field (recommended/alternative)
- recovery-manager: remove auto-creation of GLM/GLMT/Kimi profiles
This commit is contained in:
kaitranntt
2025-12-20 21:07:27 -05:00
parent de45fa0da9
commit 4c74e92cc4
4 changed files with 86 additions and 64 deletions
+37 -8
View File
@@ -33,14 +33,32 @@ export function apiProfileExists(name: string): boolean {
*/
export function isApiProfileConfigured(apiName: string): boolean {
try {
if (isUnifiedMode()) {
const secrets = getProfileSecrets(apiName);
const token = secrets?.ANTHROPIC_AUTH_TOKEN || '';
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
}
// Legacy: check settings.json file
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${apiName}.settings.json`);
if (isUnifiedMode()) {
// Check secrets.yaml first
const secrets = getProfileSecrets(apiName);
const secretToken = secrets?.ANTHROPIC_AUTH_TOKEN || '';
if (
secretToken.length > 0 &&
!secretToken.includes('YOUR_') &&
!secretToken.includes('your-')
) {
return true;
}
// Fallback: check settings.json file (profiles created via UI store keys here)
if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || '';
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
}
return false;
}
// Legacy: check settings.json file
if (!fs.existsSync(settingsPath)) return false;
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
@@ -53,6 +71,9 @@ export function isApiProfileConfigured(apiName: string): boolean {
/**
* List all API profiles
*
* Note: The 'default' profile (pointing to ~/.claude/settings.json) is excluded
* as it represents the user's native Claude subscription, not an API profile.
*/
export function listApiProfiles(): ApiListResult {
const profiles: ApiProfileInfo[] = [];
@@ -60,10 +81,14 @@ export function listApiProfiles(): ApiListResult {
if (isUnifiedMode()) {
const unifiedConfig = loadOrCreateUnifiedConfig();
for (const name of Object.keys(unifiedConfig.profiles)) {
for (const [name, profile] of Object.entries(unifiedConfig.profiles)) {
// Skip 'default' profile - it's the user's native Claude settings
if (name === 'default' && profile.settings?.includes('.claude/settings.json')) {
continue;
}
profiles.push({
name,
settingsPath: 'config.yaml',
settingsPath: profile.settings || 'config.yaml',
isConfigured: isApiProfileConfigured(name),
configSource: 'unified',
});
@@ -79,6 +104,10 @@ export function listApiProfiles(): ApiListResult {
} else {
const config = loadConfig();
for (const [name, settingsPath] of Object.entries(config.profiles)) {
// Skip 'default' profile - it's the user's native Claude settings
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
continue;
}
profiles.push({
name,
settingsPath: settingsPath as string,
+9
View File
@@ -5,6 +5,8 @@
* Mirrors the UI presets in ui/src/lib/provider-presets.ts
*/
export type PresetCategory = 'recommended' | 'alternative';
export interface ProviderPreset {
id: string;
name: string;
@@ -14,6 +16,7 @@ export interface ProviderPreset {
defaultModel: string;
apiKeyPlaceholder: string;
apiKeyHint: string;
category: PresetCategory;
/** Additional env vars for thinking mode, etc. */
extraEnv?: Record<string, string>;
/** Enable always thinking mode */
@@ -28,6 +31,7 @@ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
* NOTE: Keep in sync with ui/src/lib/provider-presets.ts
*/
export const PROVIDER_PRESETS: ProviderPreset[] = [
// Recommended
{
id: 'openrouter',
name: 'OpenRouter',
@@ -37,7 +41,9 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
defaultModel: 'anthropic/claude-sonnet-4',
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
category: 'recommended',
},
// Alternative providers
{
id: 'glm',
name: 'GLM',
@@ -47,6 +53,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
defaultModel: 'glm-4.6',
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Get your API key from Z.AI',
category: 'alternative',
},
{
id: 'glmt',
@@ -57,6 +64,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
defaultModel: 'glm-4.6',
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Same API key as GLM',
category: 'alternative',
extraEnv: {
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
@@ -76,6 +84,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
defaultModel: 'kimi-k2-thinking-turbo',
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Moonshot AI',
category: 'alternative',
alwaysThinkingEnabled: true,
},
];
+6 -11
View File
@@ -69,14 +69,9 @@ class RecoveryManager {
}
// Create default config (matches postinstall.js)
// NOTE: No 'default' entry - when no profile specified, CCS passes through
// to Claude's native auth without --settings flag
// NOTE: Empty profiles - users create profiles via `ccs api create` or UI
const defaultConfig = {
profiles: {
glm: '~/.ccs/glm.settings.json',
glmt: '~/.ccs/glmt.settings.json',
kimi: '~/.ccs/kimi.settings.json',
},
profiles: {},
};
const tmpPath = `${configPath}.tmp`;
@@ -274,6 +269,9 @@ class RecoveryManager {
/**
* Run all recovery operations (lazy initialization)
* Mirrors postinstall.js behavior
*
* NOTE: GLM/GLMT/Kimi profiles are NOT auto-created.
* Users should create them via `ccs api create --preset glm` or the UI.
*/
recoverAll(): boolean {
this.recovered = [];
@@ -283,11 +281,8 @@ class RecoveryManager {
this.ensureSharedDirectories();
this.ensureClaudeSettings();
// Config files
// Config files (core only - no GLM/GLMT/Kimi auto-creation)
this.ensureConfigJson();
this.ensureGlmSettings();
this.ensureGlmtSettings();
this.ensureKimiSettings();
// Shell completions
this.ensureShellCompletions();
+34 -45
View File
@@ -1,5 +1,7 @@
/**
* Profile Routes - CRUD operations for user profiles and accounts
*
* Uses unified config (config.yaml) when available, falls back to legacy (config.json).
*/
import { Router, Request, Response } from 'express';
@@ -7,13 +9,9 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import {
readConfigSafe,
writeConfig,
isConfigured,
createSettingsFile,
updateSettingsFile,
} from './route-helpers';
import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer';
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
import { updateSettingsFile } from './route-helpers';
const router = Router();
@@ -23,13 +21,13 @@ const router = Router();
* GET /api/profiles - List all profiles
*/
router.get('/', (_req: Request, res: Response) => {
const config = readConfigSafe();
const profiles = Object.entries(config.profiles).map(([name, settingsPath]) => ({
name,
settingsPath,
configured: isConfigured(name, config),
const result = listApiProfiles();
// Map isConfigured -> configured for UI compatibility
const profiles = result.profiles.map((p) => ({
name: p.name,
settingsPath: p.settingsPath,
configured: p.isConfigured,
}));
res.json({ profiles });
});
@@ -53,31 +51,26 @@ router.post('/', (req: Request, res: Response): void => {
return;
}
const config = readConfigSafe();
if (config.profiles[name]) {
// Check if profile already exists (uses unified config when available)
if (apiProfileExists(name)) {
res.status(409).json({ error: 'Profile already exists' });
return;
}
// Ensure .ccs directory exists
if (!fs.existsSync(getCcsDir())) {
fs.mkdirSync(getCcsDir(), { recursive: true });
}
// Create settings file with model mapping
const settingsPath = createSettingsFile(name, baseUrl, apiKey, {
model,
opusModel,
sonnetModel,
haikuModel,
// Create profile using unified-config-aware service
const result = createApiProfile(name, baseUrl, apiKey, {
default: model || '',
opus: opusModel || model || '',
sonnet: sonnetModel || model || '',
haiku: haikuModel || model || '',
});
// Update config
config.profiles[name] = settingsPath;
writeConfig(config);
if (!result.success) {
res.status(500).json({ error: result.error || 'Failed to create profile' });
return;
}
res.status(201).json({ name, settingsPath });
res.status(201).json({ name, settingsPath: result.settingsFile });
});
/**
@@ -87,9 +80,8 @@ router.put('/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body;
const config = readConfigSafe();
if (!config.profiles[name]) {
// Check if profile exists (uses unified config when available)
if (!apiProfileExists(name)) {
res.status(404).json({ error: 'Profile not found' });
return;
}
@@ -108,22 +100,19 @@ router.put('/:name', (req: Request, res: Response): void => {
router.delete('/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const config = readConfigSafe();
if (!config.profiles[name]) {
// Check if profile exists (uses unified config when available)
if (!apiProfileExists(name)) {
res.status(404).json({ error: 'Profile not found' });
return;
}
// Delete settings file
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
}
// Remove profile using unified-config-aware service
const result = removeApiProfile(name);
// Remove from config
delete config.profiles[name];
writeConfig(config);
if (!result.success) {
res.status(500).json({ error: result.error || 'Failed to delete profile' });
return;
}
res.json({ name, deleted: true });
});