mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
461 lines
12 KiB
TypeScript
461 lines
12 KiB
TypeScript
/**
|
|
* CLI Settings File Manager
|
|
* Manages Claude CLI settings files for endpoint configuration
|
|
*
|
|
* Storage: ~/.ccw/cli-settings/{endpoint-id}.json
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import * as os from 'os';
|
|
import { getCCWHome, ensureStorageDir } from './storage-paths.js';
|
|
import {
|
|
ClaudeCliSettings,
|
|
EndpointSettings,
|
|
SettingsListResponse,
|
|
SettingsOperationResult,
|
|
SaveEndpointRequest,
|
|
validateSettings,
|
|
createDefaultSettings
|
|
} from '../types/cli-settings.js';
|
|
import {
|
|
addClaudeCustomEndpoint,
|
|
removeClaudeCustomEndpoint
|
|
} from '../tools/claude-cli-tools.js';
|
|
|
|
/**
|
|
* Get CLI settings directory path
|
|
*/
|
|
export function getCliSettingsDir(): string {
|
|
return join(getCCWHome(), 'cli-settings');
|
|
}
|
|
|
|
/**
|
|
* Get settings file path for an endpoint
|
|
*/
|
|
export function getSettingsFilePath(endpointId: string): string {
|
|
return join(getCliSettingsDir(), `${endpointId}.json`);
|
|
}
|
|
|
|
/**
|
|
* Get index file path (stores endpoint metadata)
|
|
*/
|
|
function getIndexFilePath(): string {
|
|
return join(getCliSettingsDir(), '_index.json');
|
|
}
|
|
|
|
/**
|
|
* Ensure settings directory exists
|
|
*/
|
|
export function ensureSettingsDir(): void {
|
|
ensureStorageDir(getCliSettingsDir());
|
|
}
|
|
|
|
/**
|
|
* Load endpoint index (metadata only, not settings content)
|
|
*/
|
|
function loadIndex(): Map<string, Omit<EndpointSettings, 'settings'>> {
|
|
const indexPath = getIndexFilePath();
|
|
if (!existsSync(indexPath)) {
|
|
return new Map();
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
return new Map(Object.entries(data));
|
|
} catch {
|
|
return new Map();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save endpoint index
|
|
*/
|
|
function saveIndex(index: Map<string, Omit<EndpointSettings, 'settings'>>): void {
|
|
ensureSettingsDir();
|
|
const indexPath = getIndexFilePath();
|
|
const data = Object.fromEntries(index);
|
|
writeFileSync(indexPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Generate unique endpoint ID
|
|
*/
|
|
function generateEndpointId(): string {
|
|
const timestamp = Date.now().toString(36);
|
|
const random = Math.random().toString(36).substring(2, 8);
|
|
return `ep-${timestamp}-${random}`;
|
|
}
|
|
|
|
/**
|
|
* Save endpoint settings to file
|
|
*/
|
|
export function saveEndpointSettings(request: SaveEndpointRequest): SettingsOperationResult {
|
|
try {
|
|
ensureSettingsDir();
|
|
|
|
const now = new Date().toISOString();
|
|
const index = loadIndex();
|
|
|
|
// Determine endpoint ID
|
|
const endpointId = request.id || generateEndpointId();
|
|
|
|
// Check if updating existing or creating new
|
|
const existing = index.get(endpointId);
|
|
|
|
// Create endpoint metadata
|
|
const metadata: Omit<EndpointSettings, 'settings'> = {
|
|
id: endpointId,
|
|
name: request.name,
|
|
description: request.description,
|
|
enabled: request.enabled ?? true,
|
|
createdAt: existing?.createdAt || now,
|
|
updatedAt: now
|
|
};
|
|
|
|
// Save settings file
|
|
const settingsPath = getSettingsFilePath(endpointId);
|
|
writeFileSync(settingsPath, JSON.stringify(request.settings, null, 2), 'utf-8');
|
|
|
|
// Update index
|
|
index.set(endpointId, metadata);
|
|
saveIndex(index);
|
|
|
|
// Sync with cli-tools.json for ccw cli --tool integration
|
|
// CLI Settings endpoints are added as tools with type: 'cli-wrapper'
|
|
// Usage: ccw cli -p "..." --tool <name> --mode analysis
|
|
try {
|
|
const projectDir = os.homedir(); // Use home dir as base for global config
|
|
addClaudeCustomEndpoint(projectDir, {
|
|
id: endpointId,
|
|
name: request.name,
|
|
enabled: request.enabled ?? true,
|
|
tags: ['cli-wrapper'] // cli-wrapper tag -> registers as type: 'cli-wrapper'
|
|
});
|
|
console.log(`[CliSettings] Synced endpoint ${endpointId} to cli-tools.json tools (cli-wrapper)`);
|
|
} catch (syncError) {
|
|
console.warn(`[CliSettings] Failed to sync with cli-tools.json: ${syncError}`);
|
|
// Non-fatal: continue even if sync fails
|
|
}
|
|
|
|
// Return full endpoint settings
|
|
const endpoint: EndpointSettings = {
|
|
...metadata,
|
|
settings: request.settings
|
|
};
|
|
|
|
return {
|
|
success: true,
|
|
message: existing ? 'Endpoint updated' : 'Endpoint created',
|
|
endpoint,
|
|
filePath: settingsPath
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
message: `Failed to save endpoint settings: ${error instanceof Error ? error.message : String(error)}`
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load endpoint settings from file
|
|
*/
|
|
export function loadEndpointSettings(endpointId: string): EndpointSettings | null {
|
|
try {
|
|
const index = loadIndex();
|
|
const metadata = index.get(endpointId);
|
|
|
|
if (!metadata) {
|
|
return null;
|
|
}
|
|
|
|
const settingsPath = getSettingsFilePath(endpointId);
|
|
if (!existsSync(settingsPath)) {
|
|
return null;
|
|
}
|
|
|
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
|
|
if (!validateSettings(settings)) {
|
|
console.error(`[CliSettings] Invalid settings format for ${endpointId}`);
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...metadata,
|
|
settings
|
|
};
|
|
} catch (e) {
|
|
console.error(`[CliSettings] Failed to load settings for ${endpointId}:`, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete endpoint settings
|
|
*/
|
|
export function deleteEndpointSettings(endpointId: string): SettingsOperationResult {
|
|
const index = loadIndex();
|
|
|
|
if (!index.has(endpointId)) {
|
|
return {
|
|
success: false,
|
|
message: 'Endpoint not found'
|
|
};
|
|
}
|
|
|
|
const settingsPath = getSettingsFilePath(endpointId);
|
|
|
|
try {
|
|
// Step 1: Delete file first
|
|
if (existsSync(settingsPath)) {
|
|
unlinkSync(settingsPath);
|
|
}
|
|
|
|
// Step 2: Only update index after successful file deletion
|
|
index.delete(endpointId);
|
|
saveIndex(index);
|
|
|
|
// Step 3: Remove from cli-tools.json tools (api-endpoint type)
|
|
try {
|
|
const projectDir = os.homedir();
|
|
removeClaudeCustomEndpoint(projectDir, endpointId);
|
|
console.log(`[CliSettings] Removed endpoint ${endpointId} from cli-tools.json tools`);
|
|
} catch (syncError) {
|
|
console.warn(`[CliSettings] Failed to remove from cli-tools.json: ${syncError}`);
|
|
// Non-fatal: continue even if sync fails
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Endpoint deleted'
|
|
};
|
|
} catch (error) {
|
|
// If deletion fails, index remains unchanged for consistency
|
|
return {
|
|
success: false,
|
|
message: `Failed to delete endpoint file: ${error instanceof Error ? error.message : String(error)}`
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all endpoint settings
|
|
*/
|
|
export function listAllSettings(): SettingsListResponse {
|
|
try {
|
|
const index = loadIndex();
|
|
const endpoints: EndpointSettings[] = [];
|
|
|
|
for (const [endpointId, metadata] of index) {
|
|
const settingsPath = getSettingsFilePath(endpointId);
|
|
|
|
if (!existsSync(settingsPath)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
|
|
if (validateSettings(settings)) {
|
|
endpoints.push({
|
|
...metadata,
|
|
settings
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// Skip invalid settings files, but log the error for debugging
|
|
console.error(`[CliSettings] Failed to load or parse settings for ${endpointId}:`, e);
|
|
}
|
|
}
|
|
|
|
return {
|
|
endpoints,
|
|
total: endpoints.length
|
|
};
|
|
} catch (e) {
|
|
console.error('[CliSettings] Failed to list settings:', e);
|
|
return {
|
|
endpoints: [],
|
|
total: 0
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle endpoint enabled status
|
|
*/
|
|
export function toggleEndpointEnabled(endpointId: string, enabled: boolean): SettingsOperationResult {
|
|
try {
|
|
const index = loadIndex();
|
|
const metadata = index.get(endpointId);
|
|
|
|
if (!metadata) {
|
|
return {
|
|
success: false,
|
|
message: 'Endpoint not found'
|
|
};
|
|
}
|
|
|
|
metadata.enabled = enabled;
|
|
metadata.updatedAt = new Date().toISOString();
|
|
index.set(endpointId, metadata);
|
|
saveIndex(index);
|
|
|
|
// Sync enabled status with cli-tools.json tools (cli-wrapper type)
|
|
try {
|
|
const projectDir = os.homedir();
|
|
addClaudeCustomEndpoint(projectDir, {
|
|
id: endpointId,
|
|
name: metadata.name,
|
|
enabled: enabled,
|
|
tags: ['cli-wrapper'] // cli-wrapper tag -> registers as type: 'cli-wrapper'
|
|
});
|
|
console.log(`[CliSettings] Synced endpoint ${endpointId} enabled=${enabled} to cli-tools.json tools`);
|
|
} catch (syncError) {
|
|
console.warn(`[CliSettings] Failed to sync enabled status to cli-tools.json: ${syncError}`);
|
|
}
|
|
|
|
// Load full settings for response
|
|
const endpoint = loadEndpointSettings(endpointId);
|
|
|
|
return {
|
|
success: true,
|
|
message: enabled ? 'Endpoint enabled' : 'Endpoint disabled',
|
|
endpoint: endpoint || undefined
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
message: `Failed to toggle endpoint: ${error instanceof Error ? error.message : String(error)}`
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get settings file path for CLI execution
|
|
* Returns null if endpoint not found or disabled
|
|
*/
|
|
export function getExecutableSettingsPath(endpointId: string): string | null {
|
|
const endpoint = loadEndpointSettings(endpointId);
|
|
|
|
if (!endpoint || !endpoint.enabled) {
|
|
return null;
|
|
}
|
|
|
|
const settingsPath = getSettingsFilePath(endpointId);
|
|
return existsSync(settingsPath) ? settingsPath : null;
|
|
}
|
|
|
|
/**
|
|
* Create settings from LiteLLM provider configuration
|
|
*/
|
|
export function createSettingsFromProvider(provider: {
|
|
apiKey?: string;
|
|
apiBase?: string;
|
|
name?: string;
|
|
}, options?: {
|
|
model?: string;
|
|
includeCoAuthoredBy?: boolean;
|
|
}): ClaudeCliSettings {
|
|
const settings = createDefaultSettings();
|
|
|
|
// Map provider credentials to env
|
|
if (provider.apiKey) {
|
|
settings.env.ANTHROPIC_AUTH_TOKEN = provider.apiKey;
|
|
}
|
|
if (provider.apiBase) {
|
|
settings.env.ANTHROPIC_BASE_URL = provider.apiBase;
|
|
}
|
|
|
|
// Apply options
|
|
if (options?.model) {
|
|
settings.model = options.model;
|
|
}
|
|
if (options?.includeCoAuthoredBy !== undefined) {
|
|
settings.includeCoAuthoredBy = options.includeCoAuthoredBy;
|
|
}
|
|
|
|
return settings;
|
|
}
|
|
|
|
/**
|
|
* Validate and sanitize endpoint ID
|
|
*/
|
|
export function sanitizeEndpointId(id: string): string {
|
|
// Remove special characters, keep alphanumeric and hyphens
|
|
return id.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
/**
|
|
* Check if endpoint ID exists
|
|
*/
|
|
export function endpointExists(endpointId: string): boolean {
|
|
const index = loadIndex();
|
|
return index.has(endpointId);
|
|
}
|
|
|
|
/**
|
|
* Get enabled endpoints only
|
|
*/
|
|
export function getEnabledEndpoints(): EndpointSettings[] {
|
|
const { endpoints } = listAllSettings();
|
|
return endpoints.filter(ep => ep.enabled);
|
|
}
|
|
|
|
/**
|
|
* Find endpoint by name (case-insensitive)
|
|
* Useful for CLI where user types --tool doubao instead of --tool ep-xxx
|
|
*/
|
|
export function findEndpointByName(name: string): EndpointSettings | null {
|
|
const { endpoints } = listAllSettings();
|
|
const lowerName = name.toLowerCase();
|
|
return endpoints.find(ep => ep.name.toLowerCase() === lowerName) || null;
|
|
}
|
|
|
|
/**
|
|
* Find endpoint by ID or name
|
|
* First tries exact ID match, then falls back to name match
|
|
*/
|
|
export function findEndpoint(idOrName: string): EndpointSettings | null {
|
|
// Try by ID first
|
|
const byId = loadEndpointSettings(idOrName);
|
|
if (byId) return byId;
|
|
|
|
// Try by name
|
|
return findEndpointByName(idOrName);
|
|
}
|
|
|
|
/**
|
|
* Validate endpoint name for CLI compatibility
|
|
* Name must be: lowercase, alphanumeric, hyphens allowed, no spaces or special chars
|
|
*/
|
|
export function validateEndpointName(name: string): { valid: boolean; error?: string } {
|
|
if (!name || name.trim().length === 0) {
|
|
return { valid: false, error: 'Name is required' };
|
|
}
|
|
|
|
// Check for valid characters: a-z, 0-9, hyphen, underscore
|
|
const validPattern = /^[a-z][a-z0-9_-]*$/;
|
|
if (!validPattern.test(name.toLowerCase())) {
|
|
return {
|
|
valid: false,
|
|
error: 'Name must start with a letter and contain only letters, numbers, hyphens, and underscores'
|
|
};
|
|
}
|
|
|
|
// Check length
|
|
if (name.length > 32) {
|
|
return { valid: false, error: 'Name must be 32 characters or less' };
|
|
}
|
|
|
|
// Check if name conflicts with built-in tools
|
|
const builtinTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode', 'litellm'];
|
|
if (builtinTools.includes(name.toLowerCase())) {
|
|
return { valid: false, error: `Name "${name}" conflicts with a built-in tool` };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|