mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-06 01:54:11 +08:00
feat(cli-settings): Implement CLI settings management and routes
- Added CLI settings file manager to handle endpoint configurations. - Introduced API routes for creating, updating, deleting, and listing CLI settings. - Enhanced session discovery for OpenCode with improved storage structure. - Updated command building logic for OpenCode and Claude to support new settings. - Added validation and sanitization for endpoint IDs and settings. - Implemented functionality to toggle endpoint enabled status and retrieve executable settings paths.
This commit is contained in:
@@ -561,11 +561,6 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
||||
} else if (optionPrompt) {
|
||||
// Use --prompt/-p option (preferred for multi-line)
|
||||
finalPrompt = optionPrompt;
|
||||
const promptLineCount = optionPrompt.split(/\r?\n/).length;
|
||||
if (promptLineCount > 3) {
|
||||
console.log(chalk.dim(' 💡 Tip: Use --file option to avoid shell escaping issues with multi-line prompts'));
|
||||
console.log(chalk.dim(' Example: ccw cli -f prompt.txt --tool gemini'));
|
||||
}
|
||||
} else {
|
||||
// Fall back to positional argument
|
||||
finalPrompt = positionalPrompt;
|
||||
|
||||
359
ccw/src/config/cli-settings-manager.ts
Normal file
359
ccw/src/config/cli-settings-manager.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 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 { getCCWHome, ensureStorageDir } from './storage-paths.js';
|
||||
import {
|
||||
ClaudeCliSettings,
|
||||
EndpointSettings,
|
||||
SettingsListResponse,
|
||||
SettingsOperationResult,
|
||||
SaveEndpointRequest,
|
||||
validateSettings,
|
||||
createDefaultSettings
|
||||
} from '../types/cli-settings.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);
|
||||
|
||||
// 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);
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
232
ccw/src/core/routes/cli-settings-routes.ts
Normal file
232
ccw/src/core/routes/cli-settings-routes.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* CLI Settings Routes Module
|
||||
* Handles Claude CLI settings file management API endpoints
|
||||
*/
|
||||
|
||||
import type { RouteContext } from './types.js';
|
||||
import {
|
||||
saveEndpointSettings,
|
||||
loadEndpointSettings,
|
||||
deleteEndpointSettings,
|
||||
listAllSettings,
|
||||
toggleEndpointEnabled,
|
||||
getSettingsFilePath,
|
||||
ensureSettingsDir,
|
||||
sanitizeEndpointId
|
||||
} from '../../config/cli-settings-manager.js';
|
||||
import type { SaveEndpointRequest } from '../../types/cli-settings.js';
|
||||
import { validateSettings } from '../../types/cli-settings.js';
|
||||
|
||||
/**
|
||||
* Handle CLI Settings routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleCliSettingsRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res, handlePostRequest, broadcastToClients } = ctx;
|
||||
|
||||
// Ensure settings directory exists
|
||||
ensureSettingsDir();
|
||||
|
||||
// ========== LIST ALL SETTINGS ==========
|
||||
// GET /api/cli/settings
|
||||
if (pathname === '/api/cli/settings' && req.method === 'GET') {
|
||||
try {
|
||||
const result = listAllSettings();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== CREATE/UPDATE SETTINGS ==========
|
||||
// POST /api/cli/settings
|
||||
if (pathname === '/api/cli/settings' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
try {
|
||||
const request = body as SaveEndpointRequest;
|
||||
|
||||
// Validate required fields
|
||||
if (!request.name) {
|
||||
return { error: 'name is required', status: 400 };
|
||||
}
|
||||
if (!request.settings || !request.settings.env) {
|
||||
return { error: 'settings.env is required', status: 400 };
|
||||
}
|
||||
// Deep validation of settings object
|
||||
if (!validateSettings(request.settings)) {
|
||||
return { error: 'Invalid settings object format', status: 400 };
|
||||
}
|
||||
|
||||
const result = saveEndpointSettings(request);
|
||||
|
||||
if (result.success) {
|
||||
// Broadcast settings created/updated event
|
||||
broadcastToClients({
|
||||
type: 'CLI_SETTINGS_UPDATED',
|
||||
payload: {
|
||||
endpoint: result.endpoint,
|
||||
filePath: result.filePath,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
return result;
|
||||
} else {
|
||||
return { error: result.message, status: 500 };
|
||||
}
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== GET SINGLE SETTINGS ==========
|
||||
// GET /api/cli/settings/:id
|
||||
const getMatch = pathname.match(/^\/api\/cli\/settings\/([^/]+)$/);
|
||||
if (getMatch && req.method === 'GET') {
|
||||
const endpointId = sanitizeEndpointId(getMatch[1]);
|
||||
try {
|
||||
const endpoint = loadEndpointSettings(endpointId);
|
||||
|
||||
if (!endpoint) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Endpoint not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
endpoint,
|
||||
filePath: getSettingsFilePath(endpointId)
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== UPDATE SETTINGS ==========
|
||||
// PUT /api/cli/settings/:id
|
||||
const putMatch = pathname.match(/^\/api\/cli\/settings\/([^/]+)$/);
|
||||
if (putMatch && req.method === 'PUT') {
|
||||
const endpointId = sanitizeEndpointId(putMatch[1]);
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
try {
|
||||
const request = body as Partial<SaveEndpointRequest>;
|
||||
|
||||
// Check if just toggling enabled status
|
||||
if (Object.keys(request).length === 1 && 'enabled' in request) {
|
||||
const result = toggleEndpointEnabled(endpointId, request.enabled as boolean);
|
||||
|
||||
if (result.success) {
|
||||
broadcastToClients({
|
||||
type: 'CLI_SETTINGS_TOGGLED',
|
||||
payload: {
|
||||
endpointId,
|
||||
enabled: request.enabled,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Full update
|
||||
const existing = loadEndpointSettings(endpointId);
|
||||
if (!existing) {
|
||||
return { error: 'Endpoint not found', status: 404 };
|
||||
}
|
||||
|
||||
const updateRequest: SaveEndpointRequest = {
|
||||
id: endpointId,
|
||||
name: request.name || existing.name,
|
||||
description: request.description ?? existing.description,
|
||||
settings: request.settings || existing.settings,
|
||||
enabled: request.enabled ?? existing.enabled
|
||||
};
|
||||
|
||||
const result = saveEndpointSettings(updateRequest);
|
||||
|
||||
if (result.success) {
|
||||
broadcastToClients({
|
||||
type: 'CLI_SETTINGS_UPDATED',
|
||||
payload: {
|
||||
endpoint: result.endpoint,
|
||||
filePath: result.filePath,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== DELETE SETTINGS ==========
|
||||
// DELETE /api/cli/settings/:id
|
||||
const deleteMatch = pathname.match(/^\/api\/cli\/settings\/([^/]+)$/);
|
||||
if (deleteMatch && req.method === 'DELETE') {
|
||||
const endpointId = sanitizeEndpointId(deleteMatch[1]);
|
||||
try {
|
||||
const result = deleteEndpointSettings(endpointId);
|
||||
|
||||
if (result.success) {
|
||||
broadcastToClients({
|
||||
type: 'CLI_SETTINGS_DELETED',
|
||||
payload: {
|
||||
endpointId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
} else {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
}
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== GET SETTINGS FILE PATH ==========
|
||||
// GET /api/cli/settings/:id/path
|
||||
const pathMatch = pathname.match(/^\/api\/cli\/settings\/([^/]+)\/path$/);
|
||||
if (pathMatch && req.method === 'GET') {
|
||||
const endpointId = sanitizeEndpointId(pathMatch[1]);
|
||||
try {
|
||||
const endpoint = loadEndpointSettings(endpointId);
|
||||
|
||||
if (!endpoint) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Endpoint not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = getSettingsFilePath(endpointId);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
endpointId,
|
||||
filePath,
|
||||
enabled: endpoint.enabled
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { resolvePath, getRecentPaths, normalizePathForDisplay } from '../utils/p
|
||||
// Import route handlers
|
||||
import { handleStatusRoutes } from './routes/status-routes.js';
|
||||
import { handleCliRoutes } from './routes/cli-routes.js';
|
||||
import { handleCliSettingsRoutes } from './routes/cli-settings-routes.js';
|
||||
import { handleMemoryRoutes } from './routes/memory-routes.js';
|
||||
import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js';
|
||||
import { handleMcpRoutes } from './routes/mcp-routes.js';
|
||||
@@ -441,6 +442,8 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
|
||||
// CLI routes (/api/cli/*)
|
||||
if (pathname.startsWith('/api/cli/')) {
|
||||
// CLI Settings routes first (more specific path /api/cli/settings/*)
|
||||
if (await handleCliSettingsRoutes(routeContext)) return;
|
||||
if (await handleCliRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,17 @@ let selectedProviderId = null;
|
||||
let providerSearchQuery = '';
|
||||
let activeModelTab = 'llm';
|
||||
let expandedModelGroups = new Set();
|
||||
let activeSidebarTab = 'providers'; // 'providers' | 'endpoints' | 'cache' | 'embedding-pool'
|
||||
let activeSidebarTab = 'providers'; // 'providers' | 'endpoints' | 'cache' | 'embedding-pool' | 'cli-settings'
|
||||
|
||||
// Embedding Pool state
|
||||
let embeddingPoolConfig = null;
|
||||
let embeddingPoolAvailableModels = [];
|
||||
let embeddingPoolDiscoveredProviders = [];
|
||||
|
||||
// CLI Settings state
|
||||
let cliSettingsData = null;
|
||||
let selectedCliSettingsId = null;
|
||||
|
||||
// Cache for ccw-litellm status (frontend cache with TTL)
|
||||
let ccwLitellmStatusCache = null;
|
||||
let ccwLitellmStatusCacheTime = 0;
|
||||
@@ -106,6 +110,95 @@ async function loadEmbeddingPoolConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load CLI Settings endpoints
|
||||
*/
|
||||
async function loadCliSettings(forceRefresh = false) {
|
||||
if (!forceRefresh && cliSettingsData) {
|
||||
return cliSettingsData;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/cli/settings');
|
||||
if (!response.ok) throw new Error('Failed to load CLI settings');
|
||||
cliSettingsData = await response.json();
|
||||
return cliSettingsData;
|
||||
} catch (err) {
|
||||
console.error('Failed to load CLI settings:', err);
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
return { endpoints: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save CLI Settings endpoint
|
||||
*/
|
||||
async function saveCliSettingsEndpoint(data) {
|
||||
try {
|
||||
const method = data.id ? 'PUT' : 'POST';
|
||||
const url = data.id ? '/api/cli/settings/' + data.id : '/api/cli/settings';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || 'Failed to save settings');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
showRefreshToast(t('apiSettings.settingsSaved'), 'success');
|
||||
|
||||
// Refresh data and re-render
|
||||
await loadCliSettings(true);
|
||||
renderCliSettingsList();
|
||||
if (result.endpoint) {
|
||||
selectCliSettings(result.endpoint.id);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Failed to save CLI settings:', err);
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete CLI Settings endpoint
|
||||
*/
|
||||
async function deleteCliSettingsEndpoint(endpointId) {
|
||||
if (!confirm(t('apiSettings.confirmDeleteSettings'))) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/cli/settings/' + endpointId, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || 'Failed to delete settings');
|
||||
}
|
||||
|
||||
showRefreshToast(t('apiSettings.settingsDeleted'), 'success');
|
||||
|
||||
// Refresh data and re-render
|
||||
await loadCliSettings(true);
|
||||
selectedCliSettingsId = null;
|
||||
renderCliSettingsList();
|
||||
renderCliSettingsEmptyState();
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to delete CLI settings:', err);
|
||||
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover providers for a specific target model
|
||||
*/
|
||||
@@ -999,6 +1092,9 @@ async function renderApiSettings() {
|
||||
'<button class="sidebar-tab' + (activeSidebarTab === 'endpoints' ? ' active' : '') + '" onclick="switchSidebarTab(\'endpoints\')">' +
|
||||
'<i data-lucide="link"></i> ' + t('apiSettings.endpoints') +
|
||||
'</button>' +
|
||||
'<button class="sidebar-tab' + (activeSidebarTab === 'cli-settings' ? ' active' : '') + '" onclick="switchSidebarTab(\'cli-settings\')">' +
|
||||
'<i data-lucide="settings"></i> ' + t('apiSettings.cliSettings') +
|
||||
'</button>' +
|
||||
'<button class="sidebar-tab' + (activeSidebarTab === 'embedding-pool' ? ' active' : '') + '" onclick="switchSidebarTab(\'embedding-pool\')">' +
|
||||
'<i data-lucide="repeat"></i> ' + t('apiSettings.embeddingPool') +
|
||||
'</button>' +
|
||||
@@ -1035,6 +1131,15 @@ async function renderApiSettings() {
|
||||
sidebarContentHtml = '<div class="cache-sidebar-info" style="padding: 1rem; color: var(--text-secondary); font-size: 0.875rem;">' +
|
||||
'<p>' + t('apiSettings.cacheTabHint') + '</p>' +
|
||||
'</div>';
|
||||
} else if (activeSidebarTab === 'cli-settings') {
|
||||
// Load CLI settings first if not already loaded
|
||||
if (!cliSettingsData) {
|
||||
await loadCliSettings();
|
||||
}
|
||||
sidebarContentHtml = '<div class="cli-settings-list" id="cli-settings-list"></div>';
|
||||
addButtonHtml = '<button class="btn btn-primary btn-full" onclick="showAddCliSettingsModal()">' +
|
||||
'<i data-lucide="plus"></i> ' + t('apiSettings.addCliSettings') +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
// Build split layout
|
||||
@@ -1074,6 +1179,16 @@ async function renderApiSettings() {
|
||||
renderEmbeddingPoolMainPanel();
|
||||
} else if (activeSidebarTab === 'cache') {
|
||||
renderCacheMainPanel();
|
||||
} else if (activeSidebarTab === 'cli-settings') {
|
||||
renderCliSettingsList();
|
||||
// Auto-select first settings if exists
|
||||
if (!selectedCliSettingsId && cliSettingsData && cliSettingsData.endpoints && cliSettingsData.endpoints.length > 0) {
|
||||
selectCliSettings(cliSettingsData.endpoints[0].id);
|
||||
} else if (selectedCliSettingsId) {
|
||||
renderCliSettingsDetail(selectedCliSettingsId);
|
||||
} else {
|
||||
renderCliSettingsEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
// Check and render ccw-litellm status
|
||||
@@ -3479,6 +3594,302 @@ window.renderCcwLitellmStatusCard = renderCcwLitellmStatusCard;
|
||||
window.installCcwLitellm = installCcwLitellm;
|
||||
window.uninstallCcwLitellm = uninstallCcwLitellm;
|
||||
|
||||
// ========== CLI Settings Functions ==========
|
||||
|
||||
/**
|
||||
* Render CLI Settings list in sidebar
|
||||
*/
|
||||
function renderCliSettingsList() {
|
||||
var container = document.getElementById('cli-settings-list');
|
||||
if (!container) return;
|
||||
|
||||
var endpoints = (cliSettingsData && cliSettingsData.endpoints) ? cliSettingsData.endpoints : [];
|
||||
|
||||
if (endpoints.length === 0) {
|
||||
container.innerHTML = '<div class="provider-list-empty">' +
|
||||
'<p>' + t('apiSettings.noCliSettings') + '</p>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
endpoints.forEach(function(endpoint) {
|
||||
var isSelected = endpoint.id === selectedCliSettingsId;
|
||||
html += '<div class="provider-item' + (isSelected ? ' selected' : '') + '" onclick="selectCliSettings(\'' + endpoint.id + '\')">' +
|
||||
'<div class="provider-item-content">' +
|
||||
'<div class="provider-icon">' +
|
||||
'<i data-lucide="settings"></i>' +
|
||||
'</div>' +
|
||||
'<div class="provider-info">' +
|
||||
'<div class="provider-name">' + escapeHtml(endpoint.name) + '</div>' +
|
||||
'<div class="provider-type">' + (endpoint.settings.model || 'sonnet') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="provider-status' + (endpoint.enabled ? ' enabled' : ' disabled') + '">' +
|
||||
'<span class="status-dot"></span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select CLI Settings endpoint
|
||||
*/
|
||||
function selectCliSettings(endpointId) {
|
||||
selectedCliSettingsId = endpointId;
|
||||
renderCliSettingsList();
|
||||
renderCliSettingsDetail(endpointId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render CLI Settings detail panel
|
||||
*/
|
||||
function renderCliSettingsDetail(endpointId) {
|
||||
var container = document.getElementById('provider-detail-panel');
|
||||
if (!container) return;
|
||||
|
||||
var endpoint = null;
|
||||
if (cliSettingsData && cliSettingsData.endpoints) {
|
||||
endpoint = cliSettingsData.endpoints.find(function(e) { return e.id === endpointId; });
|
||||
}
|
||||
|
||||
if (!endpoint) {
|
||||
renderCliSettingsEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = endpoint.settings || {};
|
||||
var env = settings.env || {};
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="provider-detail-header">' +
|
||||
'<h2>' + escapeHtml(endpoint.name) + '</h2>' +
|
||||
'<div class="provider-detail-actions">' +
|
||||
'<button class="btn btn-ghost" onclick="editCliSettings(\'' + endpoint.id + '\')" title="' + t('common.edit') + '">' +
|
||||
'<i data-lucide="edit-2"></i>' +
|
||||
'</button>' +
|
||||
'<button class="btn btn-ghost btn-danger" onclick="deleteCliSettingsEndpoint(\'' + endpoint.id + '\')" title="' + t('common.delete') + '">' +
|
||||
'<i data-lucide="trash-2"></i>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="provider-detail-content">' +
|
||||
'<div class="detail-section">' +
|
||||
'<h3>' + t('apiSettings.basicInfo') + '</h3>' +
|
||||
'<div class="detail-grid">' +
|
||||
'<div class="detail-item">' +
|
||||
'<label>' + t('apiSettings.endpointId') + '</label>' +
|
||||
'<span class="mono">' + escapeHtml(endpoint.id) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="detail-item">' +
|
||||
'<label>' + t('apiSettings.model') + '</label>' +
|
||||
'<span>' + escapeHtml(settings.model || 'sonnet') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="detail-item">' +
|
||||
'<label>' + t('apiSettings.status') + '</label>' +
|
||||
'<span class="status-badge ' + (endpoint.enabled ? 'enabled' : 'disabled') + '">' +
|
||||
(endpoint.enabled ? t('common.enabled') : t('common.disabled')) +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="detail-section">' +
|
||||
'<h3>' + t('apiSettings.envSettings') + '</h3>' +
|
||||
'<div class="detail-grid">' +
|
||||
'<div class="detail-item">' +
|
||||
'<label>ANTHROPIC_AUTH_TOKEN</label>' +
|
||||
'<span class="mono">' + (env.ANTHROPIC_AUTH_TOKEN ? '••••••••' + env.ANTHROPIC_AUTH_TOKEN.slice(-8) : '-') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="detail-item">' +
|
||||
'<label>ANTHROPIC_BASE_URL</label>' +
|
||||
'<span class="mono">' + escapeHtml(env.ANTHROPIC_BASE_URL || '-') + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="detail-section">' +
|
||||
'<h3>' + t('apiSettings.settingsFilePath') + '</h3>' +
|
||||
'<div class="code-block">' +
|
||||
'<code>claude -p --settings ~/.ccw/cli-settings/' + endpoint.id + '.json</code>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render CLI Settings empty state
|
||||
*/
|
||||
function renderCliSettingsEmptyState() {
|
||||
var container = document.getElementById('provider-detail-panel');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="provider-empty-state">' +
|
||||
'<i data-lucide="settings" class="empty-icon"></i>' +
|
||||
'<h3>' + t('apiSettings.noCliSettingsSelected') + '</h3>' +
|
||||
'<p>' + t('apiSettings.cliSettingsHint') + '</p>' +
|
||||
'</div>';
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Add CLI Settings Modal
|
||||
*/
|
||||
function showAddCliSettingsModal(existingEndpoint) {
|
||||
var isEdit = !!existingEndpoint;
|
||||
var settings = existingEndpoint ? existingEndpoint.settings : { env: {}, model: 'sonnet' };
|
||||
var env = settings.env || {};
|
||||
|
||||
var modalHtml =
|
||||
'<div class="modal-overlay" onclick="closeModal(event)">' +
|
||||
'<div class="modal" onclick="event.stopPropagation()">' +
|
||||
'<div class="modal-header">' +
|
||||
'<h2>' + (isEdit ? t('apiSettings.editCliSettings') : t('apiSettings.addCliSettings')) + '</h2>' +
|
||||
'<button class="modal-close" onclick="closeCliSettingsModal()">×</button>' +
|
||||
'</div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<form id="cli-settings-form">' +
|
||||
(isEdit ? '<input type="hidden" id="cli-settings-id" value="' + existingEndpoint.id + '">' : '') +
|
||||
'<div class="form-group">' +
|
||||
'<label for="cli-settings-name">' + t('apiSettings.endpointName') + ' *</label>' +
|
||||
'<input type="text" id="cli-settings-name" class="cli-input" value="' + escapeHtml(existingEndpoint ? existingEndpoint.name : '') + '" required />' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="cli-settings-description">' + t('apiSettings.description') + '</label>' +
|
||||
'<input type="text" id="cli-settings-description" class="cli-input" value="' + escapeHtml(existingEndpoint ? (existingEndpoint.description || '') : '') + '" />' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="cli-settings-model">' + t('apiSettings.model') + '</label>' +
|
||||
'<select id="cli-settings-model" class="cli-select">' +
|
||||
'<option value="opus"' + (settings.model === 'opus' ? ' selected' : '') + '>Claude Opus</option>' +
|
||||
'<option value="sonnet"' + (settings.model === 'sonnet' ? ' selected' : '') + '>Claude Sonnet</option>' +
|
||||
'<option value="haiku"' + (settings.model === 'haiku' ? ' selected' : '') + '>Claude Haiku</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="cli-settings-token">ANTHROPIC_AUTH_TOKEN *</label>' +
|
||||
'<input type="password" id="cli-settings-token" class="cli-input" value="' + escapeHtml(env.ANTHROPIC_AUTH_TOKEN || '') + '" placeholder="sk-..." required />' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="cli-settings-base-url">ANTHROPIC_BASE_URL</label>' +
|
||||
'<input type="text" id="cli-settings-base-url" class="cli-input" value="' + escapeHtml(env.ANTHROPIC_BASE_URL || '') + '" placeholder="https://api.anthropic.com/v1" />' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label class="checkbox-label">' +
|
||||
'<input type="checkbox" id="cli-settings-enabled"' + (existingEndpoint ? (existingEndpoint.enabled ? ' checked' : '') : ' checked') + ' />' +
|
||||
' ' + t('common.enabled') +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'</form>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-ghost" onclick="closeCliSettingsModal()">' + t('common.cancel') + '</button>' +
|
||||
'<button class="btn btn-primary" onclick="submitCliSettings()">' + (isEdit ? t('common.save') : t('common.create')) + '</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Append modal to body
|
||||
var modalsContainer = document.getElementById('modals');
|
||||
if (!modalsContainer) {
|
||||
modalsContainer = document.createElement('div');
|
||||
modalsContainer.id = 'modals';
|
||||
document.body.appendChild(modalsContainer);
|
||||
}
|
||||
modalsContainer.innerHTML = modalHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit CLI Settings
|
||||
*/
|
||||
function editCliSettings(endpointId) {
|
||||
var endpoint = null;
|
||||
if (cliSettingsData && cliSettingsData.endpoints) {
|
||||
endpoint = cliSettingsData.endpoints.find(function(e) { return e.id === endpointId; });
|
||||
}
|
||||
if (endpoint) {
|
||||
showAddCliSettingsModal(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close CLI Settings Modal
|
||||
*/
|
||||
function closeCliSettingsModal() {
|
||||
var modalsContainer = document.getElementById('modals');
|
||||
if (modalsContainer) {
|
||||
modalsContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit CLI Settings Form
|
||||
*/
|
||||
async function submitCliSettings() {
|
||||
var name = document.getElementById('cli-settings-name').value.trim();
|
||||
var description = document.getElementById('cli-settings-description').value.trim();
|
||||
var model = document.getElementById('cli-settings-model').value;
|
||||
var token = document.getElementById('cli-settings-token').value.trim();
|
||||
var baseUrl = document.getElementById('cli-settings-base-url').value.trim();
|
||||
var enabled = document.getElementById('cli-settings-enabled').checked;
|
||||
var idInput = document.getElementById('cli-settings-id');
|
||||
var id = idInput ? idInput.value : null;
|
||||
|
||||
if (!name) {
|
||||
showRefreshToast(t('apiSettings.nameRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
showRefreshToast(t('apiSettings.tokenRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var data = {
|
||||
name: name,
|
||||
description: description,
|
||||
enabled: enabled,
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: token,
|
||||
DISABLE_AUTOUPDATER: '1'
|
||||
},
|
||||
model: model
|
||||
}
|
||||
};
|
||||
|
||||
if (baseUrl) {
|
||||
data.settings.env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
data.id = id;
|
||||
}
|
||||
|
||||
var result = await saveCliSettingsEndpoint(data);
|
||||
if (result && result.success) {
|
||||
closeCliSettingsModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Make CLI Settings functions globally accessible
|
||||
window.loadCliSettings = loadCliSettings;
|
||||
window.saveCliSettingsEndpoint = saveCliSettingsEndpoint;
|
||||
window.deleteCliSettingsEndpoint = deleteCliSettingsEndpoint;
|
||||
window.renderCliSettingsList = renderCliSettingsList;
|
||||
window.selectCliSettings = selectCliSettings;
|
||||
window.renderCliSettingsDetail = renderCliSettingsDetail;
|
||||
window.renderCliSettingsEmptyState = renderCliSettingsEmptyState;
|
||||
window.showAddCliSettingsModal = showAddCliSettingsModal;
|
||||
window.editCliSettings = editCliSettings;
|
||||
window.closeCliSettingsModal = closeCliSettingsModal;
|
||||
window.submitCliSettings = submitCliSettings;
|
||||
|
||||
|
||||
// ========== Utility Functions ==========
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ export const DEFAULT_CONFIG: CliConfig = {
|
||||
},
|
||||
opencode: {
|
||||
enabled: true,
|
||||
primaryModel: 'anthropic/claude-sonnet-4-20250514',
|
||||
secondaryModel: 'anthropic/claude-haiku'
|
||||
primaryModel: '', // Empty = use opencode's default config
|
||||
secondaryModel: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -308,7 +308,9 @@ async function executeCliTool(
|
||||
tool,
|
||||
resumeIds,
|
||||
customId,
|
||||
forcePromptConcat: noNative,
|
||||
// Force prompt-concat if noNative flag is set OR if tool doesn't support native resume
|
||||
// (e.g., codex resume requires TTY which spawn() doesn't provide)
|
||||
forcePromptConcat: noNative || !supportsNativeResume(tool),
|
||||
getNativeSessionId: (ccwId) => store.getNativeSessionId(ccwId),
|
||||
getConversation: (ccwId) => loadConversation(workingDir, ccwId),
|
||||
getConversationTool: (ccwId) => {
|
||||
|
||||
@@ -157,8 +157,10 @@ export function buildCommand(params: {
|
||||
dir?: string;
|
||||
include?: string;
|
||||
nativeResume?: NativeResumeConfig;
|
||||
/** Claude CLI settings file path (for --settings parameter) */
|
||||
settingsFile?: string;
|
||||
}): { command: string; args: string[]; useStdin: boolean } {
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume } = params;
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume, settingsFile } = params;
|
||||
|
||||
debugLog('BUILD_CMD', `Building command for tool: ${tool}`, {
|
||||
mode,
|
||||
@@ -238,7 +240,10 @@ export function buildCommand(params: {
|
||||
args.push('--add-dir', addDir);
|
||||
}
|
||||
}
|
||||
args.push('-');
|
||||
// codex resume uses positional prompt argument, not stdin
|
||||
// Format: codex resume <session-id> [prompt]
|
||||
useStdin = false;
|
||||
args.push(prompt);
|
||||
} else {
|
||||
args.push('exec');
|
||||
if (mode === 'write' || mode === 'auto') {
|
||||
@@ -262,6 +267,10 @@ export function buildCommand(params: {
|
||||
case 'claude':
|
||||
// Claude Code: claude -p "prompt" for non-interactive mode
|
||||
args.push('-p'); // Print mode (non-interactive)
|
||||
// Settings file: claude --settings <file-or-json>
|
||||
if (settingsFile) {
|
||||
args.push('--settings', settingsFile);
|
||||
}
|
||||
// Native resume: claude --resume <session-id> or --continue
|
||||
if (nativeResume?.enabled) {
|
||||
if (nativeResume.isLatest) {
|
||||
@@ -291,8 +300,10 @@ export function buildCommand(params: {
|
||||
break;
|
||||
|
||||
case 'opencode':
|
||||
// OpenCode: opencode run "prompt" for non-interactive mode
|
||||
// OpenCode: opencode run [message..] for non-interactive mode
|
||||
// https://opencode.ai/docs/cli/
|
||||
// Prompt is passed as positional arguments (NOT stdin)
|
||||
useStdin = false;
|
||||
args.push('run');
|
||||
// Native resume: opencode run --continue or --session <id>
|
||||
if (nativeResume?.enabled) {
|
||||
@@ -306,14 +317,11 @@ export function buildCommand(params: {
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
// Write mode: Use full-auto permission via environment or default permissive mode
|
||||
// OpenCode uses OPENCODE_PERMISSION env var for permission control
|
||||
// For now, we rely on project-level configuration
|
||||
// Output format for parsing
|
||||
args.push('--format', 'default');
|
||||
// Prompt is passed as positional argument after 'run'
|
||||
// Use stdin for prompt to avoid shell escaping issues
|
||||
useStdin = true;
|
||||
// Add prompt as positional argument at the end
|
||||
// OpenCode expects: opencode run [options] [message..]
|
||||
args.push(prompt);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -681,82 +681,124 @@ class ClaudeSessionDiscoverer extends SessionDiscoverer {
|
||||
|
||||
/**
|
||||
* OpenCode Session Discoverer
|
||||
* Path: ~/.config/opencode/sessions/ or ~/.opencode/sessions/ (fallback)
|
||||
* OpenCode stores sessions with UUID-based session IDs
|
||||
* https://opencode.ai/docs/cli/
|
||||
* Storage path: ~/.local/share/opencode/storage/ (all platforms)
|
||||
* Structure:
|
||||
* session/<project-hash>/<session-id>.json - Session metadata
|
||||
* message/<session-id>/<message-id>.json - Message content
|
||||
* part/<message-id>/<part-id>.json - Message parts
|
||||
* project/<project-hash>.json - Project metadata
|
||||
* https://opencode.ai/docs/config/
|
||||
*/
|
||||
class OpenCodeSessionDiscoverer extends SessionDiscoverer {
|
||||
tool = 'opencode';
|
||||
// Primary: XDG config path, fallback to .opencode in home
|
||||
// Storage base path: ~/.local/share/opencode/storage
|
||||
basePath = join(
|
||||
process.env.OPENCODE_CONFIG_DIR ||
|
||||
process.env.XDG_CONFIG_HOME ||
|
||||
join(getHomePath(), '.config'),
|
||||
'opencode'
|
||||
process.env.USERPROFILE || getHomePath(),
|
||||
'.local',
|
||||
'share',
|
||||
'opencode',
|
||||
'storage'
|
||||
);
|
||||
fallbackBasePath = join(getHomePath(), '.opencode');
|
||||
|
||||
private getSessionsDir(): string | null {
|
||||
// Check primary path first
|
||||
const primarySessionsDir = join(this.basePath, 'sessions');
|
||||
if (existsSync(primarySessionsDir)) {
|
||||
return primarySessionsDir;
|
||||
}
|
||||
// Fallback to ~/.opencode/sessions
|
||||
const fallbackSessionsDir = join(this.fallbackBasePath, 'sessions');
|
||||
if (existsSync(fallbackSessionsDir)) {
|
||||
return fallbackSessionsDir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
|
||||
const { limit, afterTimestamp } = options;
|
||||
const sessions: NativeSession[] = [];
|
||||
|
||||
const sessionsDir = this.getSessionsDir();
|
||||
if (!sessionsDir) return [];
|
||||
private getProjectHash(workingDir: string): string | null {
|
||||
// OpenCode uses SHA1 hash of the project directory path
|
||||
const sessionDir = join(this.basePath, 'session');
|
||||
if (!existsSync(sessionDir)) return null;
|
||||
|
||||
try {
|
||||
// OpenCode stores sessions as JSON/JSONL files with UUID names
|
||||
const sessionFiles = readdirSync(sessionsDir)
|
||||
.filter(f => f.endsWith('.json') || f.endsWith('.jsonl'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(sessionsDir, f),
|
||||
stat: statSync(join(sessionsDir, f))
|
||||
}))
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||
const projectHashes = readdirSync(sessionDir).filter(d => {
|
||||
const fullPath = join(sessionDir, d);
|
||||
return statSync(fullPath).isDirectory();
|
||||
});
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
|
||||
if (projectHashes.length === 0) return null;
|
||||
|
||||
try {
|
||||
// Try to extract session ID from filename (UUID pattern)
|
||||
const uuidMatch = file.name.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
||||
let sessionId: string;
|
||||
|
||||
if (uuidMatch) {
|
||||
sessionId = uuidMatch[1];
|
||||
} else {
|
||||
// Try reading first line for session metadata
|
||||
const firstLine = readFileSync(file.path, 'utf8').split('\n')[0];
|
||||
const meta = JSON.parse(firstLine);
|
||||
sessionId = meta.id || meta.session_id || basename(file.name, '.json').replace('.jsonl', '');
|
||||
// If workingDir provided, try to find matching project
|
||||
if (workingDir) {
|
||||
const normalizedWorkDir = resolve(workingDir);
|
||||
// Check project files for directory match
|
||||
const projectDir = join(this.basePath, 'project');
|
||||
if (existsSync(projectDir)) {
|
||||
for (const hash of projectHashes) {
|
||||
const projectFile = join(projectDir, `${hash}.json`);
|
||||
if (existsSync(projectFile)) {
|
||||
try {
|
||||
const projectData = JSON.parse(readFileSync(projectFile, 'utf8'));
|
||||
// Normalize path comparison for Windows
|
||||
const projectPath = projectData.directory?.replace(/\\/g, '/').toLowerCase();
|
||||
const targetPath = normalizedWorkDir.replace(/\\/g, '/').toLowerCase();
|
||||
if (projectPath === targetPath) {
|
||||
return hash;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid project files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
sessionId,
|
||||
tool: this.tool,
|
||||
filePath: file.path,
|
||||
createdAt: file.stat.birthtime,
|
||||
updatedAt: file.stat.mtime
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
|
||||
// Return first available project hash if no match
|
||||
return projectHashes[0];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
|
||||
const { workingDir, limit, afterTimestamp } = options;
|
||||
const sessions: NativeSession[] = [];
|
||||
|
||||
const sessionDir = join(this.basePath, 'session');
|
||||
if (!existsSync(sessionDir)) return [];
|
||||
|
||||
try {
|
||||
// Get all project directories or specific one
|
||||
let projectHashes: string[];
|
||||
if (workingDir) {
|
||||
const hash = this.getProjectHash(workingDir);
|
||||
projectHashes = hash ? [hash] : [];
|
||||
} else {
|
||||
projectHashes = readdirSync(sessionDir).filter(d => {
|
||||
const fullPath = join(sessionDir, d);
|
||||
return statSync(fullPath).isDirectory();
|
||||
});
|
||||
}
|
||||
|
||||
for (const projectHash of projectHashes) {
|
||||
const projectSessionDir = join(sessionDir, projectHash);
|
||||
if (!existsSync(projectSessionDir)) continue;
|
||||
|
||||
// Get all session files
|
||||
const sessionFiles = readdirSync(projectSessionDir)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(projectSessionDir, f),
|
||||
stat: statSync(join(projectSessionDir, f))
|
||||
}))
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
|
||||
|
||||
try {
|
||||
const sessionData = JSON.parse(readFileSync(file.path, 'utf8'));
|
||||
sessions.push({
|
||||
sessionId: sessionData.id || basename(file.name, '.json'),
|
||||
tool: this.tool,
|
||||
filePath: file.path,
|
||||
projectHash,
|
||||
createdAt: new Date(sessionData.time?.created || file.stat.birthtime),
|
||||
updatedAt: new Date(sessionData.time?.updated || file.stat.mtime)
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by updatedAt descending
|
||||
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||
return limit ? sessions.slice(0, limit) : sessions;
|
||||
} catch {
|
||||
@@ -770,42 +812,61 @@ class OpenCodeSessionDiscoverer extends SessionDiscoverer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first user message from OpenCode session file
|
||||
* Format may vary - try common patterns
|
||||
* Extract first user message from OpenCode session
|
||||
* Messages are stored in: message/<session-id>/<message-id>.json
|
||||
* Format: { id, sessionID, role, time }
|
||||
* Content is in parts: part/<message-id>/<part-id>.json
|
||||
*/
|
||||
extractFirstUserMessage(filePath: string): string | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
// filePath is the session JSON file
|
||||
const sessionData = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
const sessionId = sessionData.id;
|
||||
if (!sessionId) return null;
|
||||
|
||||
// Check if JSON or JSONL
|
||||
if (filePath.endsWith('.jsonl')) {
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Try common patterns for user message
|
||||
if (entry.role === 'user' && entry.content) {
|
||||
return entry.content;
|
||||
// Find messages for this session
|
||||
const messageDir = join(this.basePath, 'message', sessionId);
|
||||
if (!existsSync(messageDir)) return null;
|
||||
|
||||
// Get message files sorted by time
|
||||
const messageFiles = readdirSync(messageDir)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(messageDir, f)
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const msgFile of messageFiles) {
|
||||
try {
|
||||
const msgData = JSON.parse(readFileSync(msgFile.path, 'utf8'));
|
||||
if (msgData.role === 'user') {
|
||||
// Get content from parts
|
||||
const partDir = join(this.basePath, 'part', msgData.id);
|
||||
if (existsSync(partDir)) {
|
||||
const partFiles = readdirSync(partDir)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.sort();
|
||||
|
||||
for (const partFile of partFiles) {
|
||||
try {
|
||||
const partData = JSON.parse(readFileSync(join(partDir, partFile), 'utf8'));
|
||||
if (partData.type === 'text' && partData.text) {
|
||||
return partData.text;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid parts
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.type === 'user' && entry.message) {
|
||||
return typeof entry.message === 'string' ? entry.message : entry.message.content;
|
||||
}
|
||||
if (entry.type === 'user_message' && entry.content) {
|
||||
return entry.content;
|
||||
}
|
||||
} catch { /* skip invalid lines */ }
|
||||
}
|
||||
} else {
|
||||
// JSON format - look for messages array
|
||||
const data = JSON.parse(content);
|
||||
if (data.messages && Array.isArray(data.messages)) {
|
||||
const userMsg = data.messages.find((m: { role?: string; type?: string }) =>
|
||||
m.role === 'user' || m.type === 'user'
|
||||
);
|
||||
return userMsg?.content || null;
|
||||
// Fallback to title if available
|
||||
return msgData.summary?.title || sessionData.title || null;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid messages
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return sessionData.title || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -884,8 +945,14 @@ export function getNativeSessions(
|
||||
|
||||
/**
|
||||
* Check if a tool supports native resume
|
||||
* Note: codex is excluded because `codex resume` requires a TTY (terminal)
|
||||
* which doesn't work in spawn() context. Codex uses prompt-concat mode instead.
|
||||
*/
|
||||
export function supportsNativeResume(tool: string): boolean {
|
||||
// codex resume requires TTY - use prompt-concat mode instead
|
||||
if (tool === 'codex') {
|
||||
return false;
|
||||
}
|
||||
return tool in discoverers;
|
||||
}
|
||||
|
||||
|
||||
137
ccw/src/types/cli-settings.ts
Normal file
137
ccw/src/types/cli-settings.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* CLI Settings Type Definitions
|
||||
* Supports Claude CLI --settings parameter format
|
||||
*/
|
||||
|
||||
/**
|
||||
* Claude CLI Settings 文件格式
|
||||
* 对应 `claude --settings <file-or-json>` 参数
|
||||
*/
|
||||
export interface ClaudeCliSettings {
|
||||
/** 环境变量配置 */
|
||||
env: {
|
||||
/** Anthropic API Token */
|
||||
ANTHROPIC_AUTH_TOKEN?: string;
|
||||
/** Anthropic API Base URL */
|
||||
ANTHROPIC_BASE_URL?: string;
|
||||
/** 禁用自动更新 */
|
||||
DISABLE_AUTOUPDATER?: string;
|
||||
/** 其他自定义环境变量 */
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
/** 模型选择 */
|
||||
model?: 'opus' | 'sonnet' | 'haiku' | string;
|
||||
/** 是否包含 co-authored-by */
|
||||
includeCoAuthoredBy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 端点 Settings 配置(带元数据)
|
||||
*/
|
||||
export interface EndpointSettings {
|
||||
/** 端点唯一标识 */
|
||||
id: string;
|
||||
/** 端点显示名称 */
|
||||
name: string;
|
||||
/** 端点描述 */
|
||||
description?: string;
|
||||
/** Claude CLI Settings */
|
||||
settings: ClaudeCliSettings;
|
||||
/** 是否启用 */
|
||||
enabled: boolean;
|
||||
/** 创建时间 */
|
||||
createdAt: string;
|
||||
/** 更新时间 */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings 列表响应
|
||||
*/
|
||||
export interface SettingsListResponse {
|
||||
endpoints: EndpointSettings[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings 操作结果
|
||||
*/
|
||||
export interface SettingsOperationResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
endpoint?: EndpointSettings;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建/更新端点请求
|
||||
*/
|
||||
export interface SaveEndpointRequest {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
settings: ClaudeCliSettings;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 LiteLLM Provider 映射到 Claude CLI env
|
||||
*/
|
||||
export function mapProviderToClaudeEnv(provider: {
|
||||
apiKey?: string;
|
||||
apiBase?: string;
|
||||
}): ClaudeCliSettings['env'] {
|
||||
const env: ClaudeCliSettings['env'] = {};
|
||||
|
||||
if (provider.apiKey) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = provider.apiKey;
|
||||
}
|
||||
if (provider.apiBase) {
|
||||
env.ANTHROPIC_BASE_URL = provider.apiBase;
|
||||
}
|
||||
// 默认禁用自动更新
|
||||
env.DISABLE_AUTOUPDATER = '1';
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认 Settings
|
||||
*/
|
||||
export function createDefaultSettings(): ClaudeCliSettings {
|
||||
return {
|
||||
env: {
|
||||
DISABLE_AUTOUPDATER: '1'
|
||||
},
|
||||
model: 'sonnet',
|
||||
includeCoAuthoredBy: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Settings 格式
|
||||
*/
|
||||
export function validateSettings(settings: unknown): settings is ClaudeCliSettings {
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = settings as Record<string, unknown>;
|
||||
|
||||
// env 必须存在且为对象
|
||||
if (!s.env || typeof s.env !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// model 可选,但如果存在必须是字符串
|
||||
if (s.model !== undefined && typeof s.model !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// includeCoAuthoredBy 可选,但如果存在必须是布尔值
|
||||
if (s.includeCoAuthoredBy !== undefined && typeof s.includeCoAuthoredBy !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user