diff --git a/CLAUDE.md b/CLAUDE.md index 5a0eb7d..d454b96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,13 +169,13 @@ Each preset contains: Located in `packages/shared/src/preset/`: -- **export.ts**: Export current configuration as a preset (.ccrsets file) - - `exportPreset(presetName, config, options)`: Creates ZIP archive with manifest.json +- **export.ts**: Export current configuration as a preset directory + - `exportPreset(presetName, config, options)`: Creates preset directory with manifest.json - Automatically sanitizes sensitive data (api_key fields become `{{field}}` placeholders) - **install.ts**: Install and manage presets - `installPreset(preset, config, options)`: Install preset to config - - `loadPreset(source)`: Load preset from file, URL, or directory + - `loadPreset(source)`: Load preset from directory - `listPresets()`: List all installed presets - `isPresetInstalled(presetName)`: Check if preset is installed - `validatePreset(preset)`: Validate preset structure @@ -189,7 +189,7 @@ Located in `packages/shared/src/preset/`: ### Preset File Format -**manifest.json** (in ZIP archive or extracted directory): +**manifest.json** (in preset directory): ```json { "name": "my-preset", diff --git a/README.md b/README.md index 702955f..e362007 100644 --- a/README.md +++ b/README.md @@ -264,9 +264,8 @@ ccr preset export my-preset # Export with metadata ccr preset export my-preset --description "My OpenAI config" --author "Your Name" --tags "openai,production" -# Install a preset from file, URL, or registry -ccr preset install my-preset.ccrsets -ccr preset install https://example.com/preset.ccrsets +# Install a preset from local directory +ccr preset install /path/to/preset # List all installed presets ccr preset list @@ -279,8 +278,8 @@ ccr preset delete my-preset ``` **Preset Features:** -- **Export**: Save your current configuration as a `.ccrsets` file (ZIP archive with manifest.json) -- **Install**: Install presets from local files, URLs, or the preset registry +- **Export**: Save your current configuration as a preset directory (with manifest.json) +- **Install**: Install presets from local directories - **Sensitive Data Handling**: API keys and other sensitive data are automatically sanitized during export (marked as `{{field}}` placeholders) - **Dynamic Configuration**: Presets can include input schemas for collecting required information during installation - **Version Control**: Each preset includes version metadata for tracking updates diff --git a/README_zh.md b/README_zh.md index 8ecb847..eac4376 100644 --- a/README_zh.md +++ b/README_zh.md @@ -239,9 +239,8 @@ ccr preset export my-preset # 使用元数据导出 ccr preset export my-preset --description "我的 OpenAI 配置" --author "您的名字" --tags "openai,生产环境" -# 从文件、URL 或注册表安装预设 -ccr preset install my-preset.ccrsets -ccr preset install https://example.com/preset.ccrsets +# 从本地目录安装预设 +ccr preset install /path/to/preset # 列出所有已安装的预设 ccr preset list @@ -254,8 +253,8 @@ ccr preset delete my-preset ``` **预设功能:** -- **导出**:将当前配置保存为 `.ccrsets` 文件(包含 manifest.json 的 ZIP 存档) -- **安装**:从本地文件、URL 或预设注册表安装预设 +- **导出**:将当前配置保存为预设目录(包含 manifest.json) +- **安装**:从本地目录安装预设 - **敏感数据处理**:导出期间自动清理 API 密钥和其他敏感数据(标记为 `{{field}}` 占位符) - **动态配置**:预设可以包含输入架构,用于在安装期间收集所需信息 - **版本控制**:每个预设包含版本元数据,用于跟踪更新 diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9fc57e9..94ec407 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -58,7 +58,7 @@ Commands: -h, help Show help information Presets: - Any preset-name defined in ~/.claude-code-router/presets/*.ccrsets + Any preset directory in ~/.claude-code-router/presets/ Examples: ccr start @@ -66,7 +66,7 @@ Examples: ccr my-preset "Write a Hello World" # Use preset configuration ccr model ccr preset export my-config # Export current config as preset - ccr preset install my-config.ccrsets # Install a preset + ccr preset install /path/to/preset # Install a preset from directory ccr preset list # List all presets eval "$(ccr activate)" # Set environment variables globally ccr ui diff --git a/packages/cli/src/utils/preset/export.ts b/packages/cli/src/utils/preset/export.ts index 14d1844..064ec89 100644 --- a/packages/cli/src/utils/preset/export.ts +++ b/packages/cli/src/utils/preset/export.ts @@ -72,7 +72,7 @@ export async function exportPresetCli( // 4. Display summary console.log(`\n${BOLDGREEN}✓ Preset exported successfully${RESET}\n`); - console.log(`${BOLDCYAN}Location:${RESET} ${result.outputPath}\n`); + console.log(`${BOLDCYAN}Location:${RESET} ${result.presetDir}\n`); console.log(`${BOLDCYAN}Summary:${RESET}`); console.log(` - Providers: ${result.sanitizedConfig.Providers?.length || 0}`); console.log(` - Router rules: ${Object.keys(result.sanitizedConfig.Router || {}).length}`); @@ -93,9 +93,9 @@ export async function exportPresetCli( // Display sharing tips console.log(`\n${BOLDCYAN}To share this preset:${RESET}`); - console.log(` 1. Share the file: ${result.outputPath}`); + console.log(` 1. Share the directory: ${result.presetDir}`); console.log(` 2. Upload to GitHub Gist or your repository`); - console.log(` 3. Others can install with: ${GREEN}ccr preset install ${RESET}\n`); + console.log(` 3. Others can install with: ${GREEN}ccr preset install ${RESET}\n`); } catch (error: any) { console.error(`\n${YELLOW}Error exporting preset:${RESET} ${error.message}`); diff --git a/packages/cli/src/utils/preset/install.ts b/packages/cli/src/utils/preset/install.ts index 822e22f..37c8888 100644 --- a/packages/cli/src/utils/preset/install.ts +++ b/packages/cli/src/utils/preset/install.ts @@ -13,8 +13,6 @@ import { readManifestFromDir, manifestToPresetFile, saveManifest, - extractPreset, - findPresetFile, isPresetInstalled, ManifestFile, PresetFile, @@ -151,53 +149,39 @@ export async function installPresetCli( try { // Determine preset name let presetName = options.name; - let sourceZip: string | undefined; + let sourceDir: string | undefined; let isReconfigure = false; // Whether to reconfigure installed preset - // Determine source type and get ZIP file path + // Determine source type and get directory path if (source.startsWith('http://') || source.startsWith('https://')) { - // URL: download to temp file - if (!presetName) { - const urlParts = source.split('/'); - const filename = urlParts[urlParts.length - 1]; - presetName = filename.replace('.ccrsets', ''); - } - // downloadPresetToTemp imported from shared package will return temp file - // but we'll auto-cleanup in loadPreset, so no need to handle here - // Re-download to temp file for extractPreset usage - // Since loadPreset already downloaded and deleted, special handling needed here - throw new Error('URL installation not fully implemented yet'); + // URL installation not supported + throw new Error('URL installation is not supported. Please download the preset directory and install from local path.'); } else if (source.includes('/') || source.includes('\\')) { - // File path + // Directory path if (!presetName) { - const filename = path.basename(source); - presetName = filename.replace('.ccrsets', ''); + presetName = path.basename(source); } - // Verify file exists + // Verify directory exists try { - await fs.access(source); + const stats = await fs.stat(source); + if (!stats.isDirectory()) { + throw new Error(`Source is not a directory: ${source}`); + } } catch { - throw new Error(`Preset file not found: ${source}`); + throw new Error(`Preset directory not found: ${source}`); } - sourceZip = source; + sourceDir = source; } else { // Preset name (without path) presetName = source; - // Search files by priority: current directory -> presets directory - const presetFile = await findPresetFile(source); - - if (presetFile) { - sourceZip = presetFile; + // Check if already installed (directory exists) + if (await isPresetInstalled(source)) { + // Already installed, reconfigure + isReconfigure = true; } else { - // Check if already installed (directory exists) - if (await isPresetInstalled(source)) { - // Already installed, reconfigure - isReconfigure = true; - } else { - // Neither exists, error - throw new Error(`Preset '${source}' not found in current directory or presets directory.`); - } + // Not found, error + throw new Error(`Preset '${source}' not found. Please provide a valid preset directory path.`); } } @@ -212,17 +196,16 @@ export async function installPresetCli( // Apply preset (will ask for sensitive info) await applyPresetCli(presetName, preset); } else { - // New installation: extract to target directory - if (!sourceZip) { - throw new Error('Source ZIP file is required for installation'); + // New installation: read from source directory + if (!sourceDir) { + throw new Error('Source directory is required for installation'); } - const targetDir = getPresetDir(presetName); - console.log(`${BOLDCYAN}Extracting preset to:${RESET} ${targetDir}`); - await extractPreset(sourceZip, targetDir); - console.log(`${GREEN}✓${RESET} Extracted successfully\n`); - // Read manifest from extracted directory - const manifest = await readManifestFromDir(targetDir); + console.log(`${BOLDCYAN}Reading preset from:${RESET} ${sourceDir}`); + console.log(`${GREEN}✓${RESET} Read successfully\n`); + + // Read manifest from source directory + const manifest = await readManifestFromDir(sourceDir); const preset = manifestToPresetFile(manifest); // Apply preset (ask user info, etc.) diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index ea4a2af..5e49b8e 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -8,14 +8,14 @@ import { getPresetDir, readManifestFromDir, manifestToPresetFile, - extractPreset, saveManifest, isPresetInstalled, - downloadPresetToTemp, - getTempDir, + extractPreset, HOME_DIR, extractMetadata, loadConfigFromManifest, + downloadPresetToTemp, + getTempDir, type PresetFile, type ManifestFile, type PresetMetadata, @@ -288,103 +288,6 @@ export const createServer = async (config: any): Promise => { } }); - // Upload and install preset (supports file upload) - app.post("/api/presets/install", async (req: any, reply: any) => { - try { - const { source, name, url } = req.body; - - // If URL is provided, download from URL - if (url) { - const tempFile = await downloadPresetToTemp(url); - const preset = await loadPresetFromZip(tempFile); - - // Determine preset name - const presetName = name || preset.metadata?.name || `preset-${Date.now()}`; - - // Check if already installed - if (await isPresetInstalled(presetName)) { - reply.status(409).send({ error: "Preset already installed" }); - return; - } - - // Extract to target directory - const targetDir = getPresetDir(presetName); - await extractPreset(tempFile, targetDir); - - // Clean up temp file - unlinkSync(tempFile); - - return { - success: true, - presetName, - preset: { - ...preset.metadata, - installed: true, - } - }; - } - - // If no URL, need to handle file upload (using multipart/form-data) - // This part requires FormData upload on client side - reply.status(400).send({ error: "Please provide a URL or upload a file" }); - } catch (error: any) { - console.error("Failed to install preset:", error); - reply.status(500).send({ error: error.message || "Failed to install preset" }); - } - }); - - // Upload preset file (multipart/form-data) - app.post("/api/presets/upload", async (req: any, reply: any) => { - try { - const data = await req.file(); - if (!data) { - reply.status(400).send({ error: "No file uploaded" }); - return; - } - - const tempDir = getTempDir(); - mkdirSync(tempDir, { recursive: true }); - - const tempFile = join(tempDir, `preset-${Date.now()}.ccrsets`); - - // Save uploaded file to temp location - const buffer = await data.toBuffer(); - writeFileSync(tempFile, buffer); - - // Load preset - const preset = await loadPresetFromZip(tempFile); - - // Determine preset name - const presetName = data.fields.name?.value || preset.metadata?.name || `preset-${Date.now()}`; - - // Check if already installed - if (await isPresetInstalled(presetName)) { - unlinkSync(tempFile); - reply.status(409).send({ error: "Preset already installed" }); - return; - } - - // Extract to target directory - const targetDir = getPresetDir(presetName); - await extractPreset(tempFile, targetDir); - - // Clean up temp file - unlinkSync(tempFile); - - return { - success: true, - presetName, - preset: { - ...preset.metadata, - installed: true, - } - }; - } catch (error: any) { - console.error("Failed to upload preset:", error); - reply.status(500).send({ error: error.message || "Failed to upload preset" }); - } - }); - // Apply preset (configure sensitive information) app.post("/api/presets/:name/apply", async (req: any, reply: any) => { try { diff --git a/packages/shared/src/preset/export.ts b/packages/shared/src/preset/export.ts index 230ec73..f001d04 100644 --- a/packages/shared/src/preset/export.ts +++ b/packages/shared/src/preset/export.ts @@ -4,18 +4,15 @@ */ import * as fs from 'fs/promises'; -import * as fsSync from 'fs'; import * as path from 'path'; -import archiver from 'archiver'; import { sanitizeConfig } from './sensitiveFields'; -import { PresetFile, PresetMetadata, ManifestFile } from './types'; +import { PresetMetadata, ManifestFile } from './types'; import { HOME_DIR } from '../constants'; /** * Export options */ export interface ExportOptions { - output?: string; includeSensitive?: boolean; description?: string; author?: string; @@ -26,7 +23,7 @@ export interface ExportOptions { * Export result */ export interface ExportResult { - outputPath: string; + presetDir: string; sanitizedConfig: any; metadata: PresetMetadata; requiredInputs: any[]; @@ -93,42 +90,31 @@ export async function exportPreset( requiredInputs: options.includeSensitive ? undefined : requiredInputs, }; - // 4. Determine output path - const presetsDir = path.join(HOME_DIR, 'presets'); + // 4. Create preset directory + const presetDir = path.join(HOME_DIR, 'presets', presetName); - // Ensure presets directory exists - await fs.mkdir(presetsDir, { recursive: true }); + // Check if preset directory already exists + try { + await fs.access(presetDir); + throw new Error(`Preset directory already exists: ${presetName}`); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + } - const outputPath = options.output || path.join(presetsDir, `${presetName}.ccrsets`); + // Create preset directory + await fs.mkdir(presetDir, { recursive: true }); - // 5. Create archive - const output = fsSync.createWriteStream(outputPath); - const archive = archiver('zip', { - zlib: { level: 9 } // Highest compression level - }); + // 5. Write manifest.json to preset directory + const manifestPath = path.join(presetDir, 'manifest.json'); + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); - return new Promise((resolve, reject) => { - output.on('close', () => { - resolve({ - outputPath, - sanitizedConfig, - metadata, - requiredInputs, - sanitizedCount, - }); - }); - - archive.on('error', (err: Error) => { - reject(err); - }); - - // Connect output stream - archive.pipe(output); - - // Add manifest.json to archive - archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' }); - - // Finalize archive - archive.finalize(); - }); + return { + presetDir, + sanitizedConfig, + metadata, + requiredInputs, + sanitizedCount, + }; } diff --git a/packages/shared/src/preset/install.ts b/packages/shared/src/preset/install.ts index d1de1bf..8a03b70 100644 --- a/packages/shared/src/preset/install.ts +++ b/packages/shared/src/preset/install.ts @@ -7,7 +7,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import JSON5 from 'json5'; import AdmZip from 'adm-zip'; -import { PresetFile, MergeStrategy, RequiredInput, ManifestFile, PresetInfo, PresetMetadata } from './types'; +import { PresetFile, ManifestFile, PresetInfo, PresetMetadata } from './types'; import { HOME_DIR, PRESETS_DIR } from '../constants'; import { loadConfigFromManifest } from './schema'; @@ -236,48 +236,27 @@ export async function downloadPresetToTemp(url: string): Promise { const tempDir = getTempDir(); await fs.mkdir(tempDir, { recursive: true }); - const tempFile = path.join(tempDir, `preset-${Date.now()}.ccrsets`); + const tempFile = path.join(tempDir, `preset-${Date.now()}.zip`); await fs.writeFile(tempFile, Buffer.from(buffer)); return tempFile; } -/** - * Load preset from local ZIP file - * @param zipFile ZIP file path - * @returns PresetFile - */ -export async function loadPresetFromZip(zipFile: string): Promise { - const zip = new AdmZip(zipFile); - const entry = zip.getEntry('manifest.json'); - if (!entry) { - throw new Error('Invalid preset file: manifest.json not found'); - } - const manifest = JSON5.parse(entry.getData().toString('utf-8')) as ManifestFile; - return manifestToPresetFile(manifest); -} - /** * Load preset file - * @param source Preset source (file path, URL, or preset name) + * @param source Preset source (preset name or directory path) */ export async function loadPreset(source: string): Promise { - // Check if it's a URL - if (source.startsWith('http://') || source.startsWith('https://')) { - const tempFile = await downloadPresetToTemp(source); - const preset = await loadPresetFromZip(tempFile); - // Delete temp file - await fs.unlink(tempFile).catch(() => {}); - return preset; - } - // Check if it's absolute or relative path (contains / or \) if (source.includes('/') || source.includes('\\')) { - // File path - return await loadPresetFromZip(source); + // Directory path - read manifest from directory + const manifestPath = path.join(source, 'manifest.json'); + const content = await fs.readFile(manifestPath, 'utf-8'); + const manifest = JSON5.parse(content) as ManifestFile; + return manifestToPresetFile(manifest); } - // Otherwise treat as preset name (read from extracted directory) + // Otherwise treat as preset name (read from presets directory) const presetDir = getPresetDir(source); const manifest = await readManifestFromDir(presetDir); return manifestToPresetFile(manifest); @@ -370,33 +349,6 @@ export async function saveManifest(presetName: string, manifest: ManifestFile): await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); } -/** - * Find preset file - * @param source Preset source - * @returns File path or null - */ -export async function findPresetFile(source: string): Promise { - // Current directory file - const currentDirFile = path.join(process.cwd(), `${source}.ccrsets`); - - // presets directory file - const presetsDirFile = path.join(HOME_DIR, 'presets', `${source}.ccrsets`); - - // Check current directory - try { - await fs.access(currentDirFile); - return currentDirFile; - } catch { - // Check presets directory - try { - await fs.access(presetsDirFile); - return presetsDirFile; - } catch { - return null; - } - } -} - /** * Check if preset is already installed * @param presetName Preset name diff --git a/packages/ui/src/components/Presets.tsx b/packages/ui/src/components/Presets.tsx index 618098e..b9fc94e 100644 --- a/packages/ui/src/components/Presets.tsx +++ b/packages/ui/src/components/Presets.tsx @@ -274,19 +274,15 @@ export function Presets() { } // 确定预设名称 - const presetName = installName || ( - installMethod === 'file' - ? installFile!.name.replace('.ccrsets', '') - : installUrl!.split('/').pop()!.replace('.ccrsets', '') - ); + const presetName = installName || ''; - // Step 1: Install preset (extract to directory) + // Step 1: Install preset from GitHub repository let installResult; if (installMethod === 'url' && installUrl) { - installResult = await api.installPresetFromUrl(installUrl, presetName); - } else if (installMethod === 'file' && installFile) { - installResult = await api.uploadPresetFile(installFile, presetName); + // Install from GitHub repository + installResult = await api.installPresetFromGitHub(installUrl, presetName); } else { + setToast({ message: t('presets.please_provide_url'), type: 'warning' }); return; } @@ -488,15 +484,7 @@ export function Presets() {
-
- {installMethod === 'file' ? ( -
- - setInstallFile(e.target.files?.[0] || null)} - /> -
- ) : ( -
- - setInstallUrl(e.target.value)} - /> -
- )} +
+ + setInstallUrl(e.target.value)} + /> +

{t('presets.github_url_hint')}

+
diff --git a/packages/ui/src/locales/en.json b/packages/ui/src/locales/en.json index 185b24e..9ea45f7 100644 --- a/packages/ui/src/locales/en.json +++ b/packages/ui/src/locales/en.json @@ -260,15 +260,14 @@ "close": "Close", "delete": "Delete", "install_dialog_title": "Install Preset", - "install_dialog_description": "Install a preset from a file or URL", - "upload_file": "Upload File", - "from_url": "From URL", - "preset_file": "Preset File (.ccrsets)", - "preset_url": "Preset URL", - "preset_url_placeholder": "https://example.com/preset.ccrsets", + "install_dialog_description": "Install a preset from a GitHub repository", + "from_url": "From GitHub", + "github_repository": "GitHub Repository", + "preset_url": "Repository URL", + "preset_url_placeholder": "https://github.com/owner/repo", "preset_name": "Preset Name (Optional)", - "preset_name_placeholder": "Auto-generated from file", - "please_provide_file_or_url": "Please provide a file or URL", + "preset_name_placeholder": "Auto-generated from repository", + "github_url_hint": "Enter GitHub repository URL (e.g., https://github.com/owner/repo)", "detail_dialog_title": "Preset Details", "required_information": "Required Information", "delete_dialog_title": "Delete Preset", @@ -284,8 +283,8 @@ "please_fill_field": "Please fill in {{field}}", "load_market_failed": "Failed to load market presets", "preset_installed_config_required": "Preset installed, please complete configuration", - "please_provide_file": "Please provide a preset file", - "please_provide_url": "Please provide a preset URL", + "please_provide_file": "Please provide a preset directory", + "please_provide_url": "Please provide a valid GitHub repository URL", "form": { "field_required": "{{field}} is required", "must_be_number": "{{field}} must be a number", diff --git a/packages/ui/src/locales/zh.json b/packages/ui/src/locales/zh.json index 08b0074..b148f4f 100644 --- a/packages/ui/src/locales/zh.json +++ b/packages/ui/src/locales/zh.json @@ -260,15 +260,14 @@ "close": "关闭", "delete": "删除", "install_dialog_title": "安装预设", - "install_dialog_description": "从文件或URL安装预设", - "upload_file": "上传文件", - "from_url": "从 URL", - "preset_file": "预设文件 (.ccrsets)", - "preset_url": "预设 URL", - "preset_url_placeholder": "https://example.com/preset.ccrsets", + "install_dialog_description": "从 GitHub 仓库安装预设", + "from_url": "从 GitHub", + "github_repository": "GitHub 仓库", + "preset_url": "仓库 URL", + "preset_url_placeholder": "https://github.com/owner/repo", "preset_name": "预设名称 (可选)", - "preset_name_placeholder": "从文件自动生成", - "please_provide_file_or_url": "请提供文件或 URL", + "preset_name_placeholder": "从仓库自动生成", + "github_url_hint": "输入 GitHub 仓库 URL(例如:https://github.com/owner/repo)", "detail_dialog_title": "预设详情", "required_information": "必需信息", "delete_dialog_title": "删除预设", @@ -284,8 +283,8 @@ "please_fill_field": "请填写 {{field}}", "load_market_failed": "加载市场预设失败", "preset_installed_config_required": "预设已安装,请完成配置", - "please_provide_file": "请提供预设文件", - "please_provide_url": "请提供预设 URL", + "please_provide_file": "请提供预设目录", + "please_provide_url": "请提供有效的 GitHub 仓库 URL", "form": { "field_required": "{{field}} 为必填项", "must_be_number": "{{field}} 必须是数字",