From 5ac38d3d0f2a0432adb8bf71b07ebb7c7d669853 Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 1 Jan 2026 17:53:26 +0800 Subject: [PATCH] fix install preset error --- .../advanced/preset-format.md | 1184 +++++++++++++++++ packages/cli/src/cli.ts | 8 + packages/cli/src/utils/installCommand.ts | 50 + .../cli/src/utils/preset/install-github.ts | 163 +++ packages/cli/src/utils/preset/install.ts | 37 +- packages/server/src/server.ts | 91 +- packages/shared/src/index.ts | 1 + packages/shared/src/preset/marketplace.ts | 56 + packages/shared/src/preset/types.ts | 1 + packages/ui/src/components/Presets.tsx | 130 +- packages/ui/src/lib/api.ts | 12 +- packages/ui/src/locales/en.json | 2 + packages/ui/src/locales/zh.json | 3 +- pnpm-lock.yaml | 21 +- 14 files changed, 1665 insertions(+), 94 deletions(-) create mode 100644 docs/i18n/zh-CN/docusaurus-plugin-content-docs/advanced/preset-format.md create mode 100644 packages/cli/src/utils/installCommand.ts create mode 100644 packages/cli/src/utils/preset/install-github.ts create mode 100644 packages/shared/src/preset/marketplace.ts diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/advanced/preset-format.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/advanced/preset-format.md new file mode 100644 index 0000000..e34304f --- /dev/null +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/advanced/preset-format.md @@ -0,0 +1,1184 @@ +--- +id: advanced/preset-format +title: Preset 格式规范 +sidebar_position: 4 +--- + +# Preset 格式规范 + +本文档详细说明了 Preset 配置文件的格式规范、字段定义和使用方法。 + +## 概述 + +Preset 是一个预定义的配置包,用于快速配置 Claude Code Router。Preset 以目录形式存储,内部包含一个 `manifest.json` 文件。 + +### 文件结构 + +``` +~/.claude-code-router/presets// +└── manifest.json +``` + +### 存储位置 + +- **预设目录**: `~/.claude-code-router/presets//` + +## manifest.json 结构 + +`manifest.json` 是一个扁平化的 JSON 文件(支持 JSON5 格式),包含三个主要部分: + +1. **元数据(Metadata)**: 描述预设的基本信息 +2. **配置(Configuration)**: 实际的配置内容 +3. **动态配置系统**: Schema、Template 和 ConfigMappings + +```json +{ + // === 元数据字段 === + "name": "my-preset", + "version": "1.0.0", + "description": "我的预设配置", + "author": "作者名", + "homepage": "https://example.com", + "repository": "https://github.com/user/repo", + "license": "MIT", + "keywords": ["openai", "production"], + "ccrVersion": "2.0.0", + + // === 配置字段 === + "Providers": [...], + "Router": {...}, + "transformers": [...], + "StatusLine": {...}, + "PROXY_URL": "...", + "PORT": 8080, + + // === 动态配置系统 === + "schema": [...], + "template": {...}, + "configMappings": [...], + "requiredInputs": [...], + "userValues": {...} +} +``` + +## 元数据字段 + +### 必填字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `name` | string | Preset 名称,唯一标识符 | +| `version` | string | 版本号(遵循 semver 规范) | + +### 可选字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `description` | string | Preset 描述 | +| `author` | string | 作者信息 | +| `homepage` | string | 项目主页 URL | +| `repository` | string | 源代码仓库 URL | +| `license` | string | 许可证类型 | +| `keywords` | string[] | 关键词标签 | +| `ccrVersion` | string | 兼容的 CCR 版本 | +| `source` | string | Preset 来源 URL | +| `sourceType` | string | 来源类型(`local`/`gist`/`registry`) | +| `checksum` | string | 内容校验和(SHA256) | + +### 元数据示例 + +```json +{ + "name": "openai-production", + "version": "1.2.0", + "description": "OpenAI 生产环境配置,包含代理和多模型支持", + "author": "Your Name", + "homepage": "https://github.com/yourname/ccr-presets", + "repository": "https://github.com/yourname/ccr-presets.git", + "license": "MIT", + "keywords": ["openai", "production", "proxy"], + "ccrVersion": "2.0.0" +} +``` + +## 配置字段 + +配置字段直接对应 CCR 的配置文件结构(`config.json`)。 + +### Providers + +Provider 配置数组,定义 LLM 服务提供商。 + +```json +{ + "Providers": [ + { + "name": "openai", + "api_base_url": "https://api.openai.com/v1", + "api_key": "${OPENAI_API_KEY}", + "models": ["gpt-4o", "gpt-4o-mini"], + "transformer": "anthropic", + "timeout": 60000, + "max_retries": 3 + } + ] +} +``` + +#### Provider 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | string | 是 | Provider 名称(唯一标识) | +| `api_base_url` | string | 是 | API 基础地址 | +| `api_key` | string | 是 | API 密钥(可以是环境变量) | +| `models` | string[] | 是 | 支持的模型列表 | +| `transformer` | string | 否 | 使用的转换器 | +| `timeout` | number | 否 | 超时时间(毫秒) | +| `max_retries` | number | 否 | 最大重试次数 | +| `headers` | object | 否 | 自定义 HTTP 头 | + +### Router + +路由配置,定义请求如何路由到不同的模型。 + +```json +{ + "Router": { + "default": "openai/gpt-4o", + "background": "openai/gpt-4o-mini", + "think": "openai/gpt-4o", + "longContext": "openai/gpt-4o", + "longContextThreshold": 100000, + "webSearch": "openai/gpt-4o", + "image": "openai/gpt-4o" + } +} +``` + +#### Router 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `default` | string | 默认路由(格式:`provider/model`) | +| `background` | string | 后台任务路由 | +| `think` | string | 思考模式路由 | +| `longContext` | string | 长上下文路由 | +| `longContextThreshold` | number | 长上下文阈值(token 数) | +| `webSearch` | string | 网络搜索路由 | +| `image` | string | 图像处理路由 | + +### Transformers + +转换器配置数组,用于处理不同 Provider 的 API 差异。 + +```json +{ + "transformers": [ + { + "path": "./transformers/custom-transformer.js", + "use": ["provider1", "provider2"], + "options": { + "max_tokens": 4096 + } + }, + { + "use": [ + ["provider3", { "option": "value" }] + ] + } + ] +} +``` + +#### Transformer 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `path` | string | 自定义转换器路径(相对或绝对) | +| `use` | array | 应用到哪些 Provider | +| `options` | object | 转换器选项 | + +### StatusLine + +状态栏配置,自定义终端状态显示。 + +```json +{ + "StatusLine": { + "default": { + "modules": [ + { + "type": "text", + "text": "CCR", + "color": "cyan" + }, + { + "type": "provider", + "showModel": true + }, + { + "type": "script", + "scriptPath": "./scripts/status.js" + } + ] + } + } +} +``` + +### 其他配置字段 + +支持所有 `config.json` 中的字段: + +```json +{ + "PORT": 8080, + "HOST": "0.0.0.0", + "PROXY_URL": "http://127.0.0.1:7890", + "LOG_LEVEL": "info", + "NON_INTERACTIVE_MODE": false +} +``` + +## 动态配置系统 + +动态配置系统是 CCR 2.0 的核心功能,允许创建可交互的配置模板。 + +### Schema(配置输入表单) + +Schema 定义了安装时需要用户输入的字段。 + +#### Schema 字段类型 + +| 类型 | 说明 | 使用场景 | +|------|------|----------| +| `password` | 密码输入(隐藏) | API Key、密钥 | +| `input` | 单行文本输入 | URL、名称 | +| `number` | 数字输入 | 端口号、超时时间 | +| `select` | 单选下拉框 | 选择 Provider、模型 | +| `multiselect` | 多选框 | 启用功能列表 | +| `confirm` | 确认框 | 是否启用某功能 | +| `editor` | 多行文本编辑器 | 自定义配置、脚本 | + +#### Schema 字段定义 + +```json +{ + "schema": [ + { + "id": "apiKey", + "type": "password", + "label": "API Key", + "prompt": "请输入您的 OpenAI API Key", + "placeholder": "sk-...", + "required": true, + "validator": "^sk-.*" + }, + { + "id": "provider", + "type": "select", + "label": "选择 Provider", + "prompt": "选择您主要使用的 LLM 提供商", + "options": { + "type": "static", + "options": [ + { + "label": "OpenAI", + "value": "openai", + "description": "使用 OpenAI 的 GPT 模型" + }, + { + "label": "DeepSeek", + "value": "deepseek", + "description": "使用 DeepSeek 的高性价比模型" + } + ] + }, + "defaultValue": "openai", + "required": true + }, + { + "id": "model", + "type": "select", + "label": "模型", + "prompt": "选择默认使用的模型", + "options": { + "type": "models", + "providerField": "#{provider}" + }, + "when": { + "field": "provider", + "operator": "exists" + }, + "required": true + }, + { + "id": "maxTokens", + "type": "number", + "label": "最大 Token 数", + "prompt": "设置请求的最大 token 数", + "min": 1, + "max": 128000, + "defaultValue": 4096 + }, + { + "id": "useProxy", + "type": "confirm", + "label": "使用代理", + "prompt": "是否通过代理访问 API?", + "defaultValue": false + }, + { + "id": "proxyUrl", + "type": "input", + "label": "代理地址", + "prompt": "输入代理服务器地址", + "placeholder": "http://127.0.0.1:7890", + "required": true, + "when": { + "field": "useProxy", + "operator": "eq", + "value": true + } + }, + { + "id": "customConfig", + "type": "editor", + "label": "自定义配置", + "prompt": "输入 JSON 格式的自定义配置", + "rows": 10 + } + ] +} +``` + +#### Schema 字段详细说明 + +##### 基础字段 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | string | 是 | 字段唯一标识符(用于变量引用) | +| `type` | string | 否 | 字段类型(默认 `password`) | +| `label` | string | 否 | 显示标签 | +| `prompt` | string | 否 | 提示信息/描述 | +| `placeholder` | string | 否 | 占位符文本 | + +##### 验证字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `required` | boolean | 是否必填(默认 `true`) | +| `validator` | RegExp/string/function | 验证规则 | +| `min` | number | 最小值(number 类型) | +| `max` | number | 最大值(number 类型) | + +##### 选项字段(select/multiselect) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `options` | array/object | 静态选项数组或动态选项配置 | + +##### 条件字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `when` | object/object[] | 显示条件(支持 AND 逻辑) | +| `defaultValue` | any | 默认值 | +| `dependsOn` | string[] | 显式声明依赖字段 | + +#### 条件运算符 + +| 运算符 | 说明 | 示例 | +|--------|------|------| +| `eq` | 等于 | `{"field": "type", "operator": "eq", "value": "openai"}` | +| `ne` | 不等于 | `{"field": "advanced", "operator": "ne", "value": true}` | +| `in` | 包含于(数组) | `{"field": "feature", "operator": "in", "value": ["a", "b"]}` | +| `nin` | 不包含于(数组) | `{"field": "type", "operator": "nin", "value": ["x", "y"]}` | +| `exists` | 字段存在 | `{"field": "apiKey", "operator": "exists"}` | +| `gt` | 大于 | `{"field": "count", "operator": "gt", "value": 0}` | +| `lt` | 小于 | `{"field": "count", "operator": "lt", "value": 100}` | +| `gte` | 大于等于 | `{"field": "count", "operator": "gte", "value": 1}` | +| `lte` | 小于等于 | `{"field": "count", "operator": "lte", "value": 99}` | + +#### 动态选项类型 + +##### static - 静态选项 + +```json +{ + "options": { + "type": "static", + "options": [ + {"label": "选项1", "value": "value1"}, + {"label": "选项2", "value": "value2"} + ] + } +} +``` + +##### providers - 从 Providers 配置提取 + +```json +{ + "options": { + "type": "providers" + } +} +``` +自动从 `Providers` 数组中提取 `name` 作为选项。 + +##### models - 从指定 Provider 的 models 提取 + +```json +{ + "options": { + "type": "models", + "providerField": "#{selectedProvider}" + } +} +``` +根据用户选择的 Provider,动态显示该 Provider 的 models。 + +### Template(配置模板) + +Template 定义了如何根据用户输入生成配置。 + +#### 变量语法 + +使用 `#{变量名}` 语法引用用户输入: + +```json +{ + "template": { + "Providers": [ + { + "name": "#{providerName}", + "api_base_url": "#{baseUrl}", + "api_key": "#{apiKey}", + "models": ["#{defaultModel}"] + } + ], + "Router": { + "default": "#{providerName}/#{defaultModel}" + } + } +} +``` + +#### Template 示例 + +```json +{ + "template": { + "Providers": [ + { + "name": "#{primaryProvider}", + "api_base_url": "#{baseUrl}", + "api_key": "#{apiKey}", + "models": ["#{defaultModel}"], + "timeout": #{timeout} + } + ], + "Router": { + "default": "#{primaryProvider}/#{defaultModel}", + "background": "#{primaryProvider}/#{backgroundModel}" + }, + "PROXY_URL": "#{proxyUrl}", + "PORT": #{port} + } +} +``` + +### ConfigMappings(配置映射) + +ConfigMappings 用于精确控制用户输入值如何映射到配置的特定位置。 + +#### ConfigMapping 结构 + +```json +{ + "configMappings": [ + { + "target": "Providers[0].api_key", + "value": "#{apiKey}" + }, + { + "target": "PROXY_URL", + "value": "#{proxyUrl}", + "when": { + "field": "useProxy", + "operator": "eq", + "value": true + } + }, + { + "target": "PORT", + "value": 8080 + } + ] +} +``` + +#### 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `target` | string | 目标字段路径(支持数组语法) | +| `value` | string/any | 值来源(变量引用或固定值) | +| `when` | object/object[] | 应用条件 | + +#### 目标路径语法 + +- `Providers[0].api_key` - 第一个 Provider 的 api_key +- `Router.default` - Router 的 default 字段 +- `PORT` - 顶层配置字段 + +### userValues(用户值存储) + +userValues 存储用户在安装时填写的值,运行时自动应用。 + +```json +{ + "userValues": { + "apiKey": "sk-xxx...", + "provider": "openai", + "defaultModel": "gpt-4o", + "useProxy": true, + "proxyUrl": "http://127.0.0.1:7890" + } +} +``` + +### requiredInputs(必填输入) + +requiredInputs 是导出预设时自动生成的字段列表,用于提示用户需要填写哪些信息。 + +```json +{ + "requiredInputs": [ + { + "id": "Providers[0].api_key", + "prompt": "Enter api_key", + "placeholder": "OPENAI_API_KEY" + }, + { + "id": "PROXY_URL", + "prompt": "Enter proxy URL", + "placeholder": "PROXY_URL" + } + ] +} +``` + +## 敏感字段处理 + +CCR 会自动识别敏感字段(如 `api_key`、`secret`、`password` 等),并将其替换为环境变量占位符。 + +### 自动识别的敏感字段 + +- `api_key`, `apiKey`, `apikey` +- `api_secret`, `apiSecret` +- `secret`, `SECRET` +- `token`, `TOKEN` +- `password`, `PASSWORD` +- `private_key`, `privateKey` +- `access_key`, `accessKey` + +### 环境变量占位符格式 + +```bash +# 推荐格式 +${VARIABLE_NAME} + +# 也支持 +$VARIABLE_NAME +``` + +### 示例 + +**原始配置:** +```json +{ + "Providers": [ + { + "name": "openai", + "api_key": "sk-abc123..." + } + ] +} +``` + +**导出后:** +```json +{ + "Providers": [ + { + "name": "openai", + "api_key": "${OPENAI_API_KEY}" + } + ], + "requiredInputs": [ + { + "id": "Providers[0].api_key", + "prompt": "Enter api_key", + "placeholder": "OPENAI_API_KEY" + } + ] +} +``` + +## 完整示例 + +### 简单预设(无动态配置) + +```json +{ + "name": "simple-openai", + "version": "1.0.0", + "description": "简单的 OpenAI 配置", + "author": "Your Name", + + "Providers": [ + { + "name": "openai", + "api_base_url": "https://api.openai.com/v1", + "api_key": "${OPENAI_API_KEY}", + "models": ["gpt-4o", "gpt-4o-mini"] + } + ], + + "Router": { + "default": "openai/gpt-4o", + "background": "openai/gpt-4o-mini" + }, + + "requiredInputs": [ + { + "id": "Providers[0].api_key", + "prompt": "Enter OpenAI API Key", + "placeholder": "OPENAI_API_KEY" + } + ] +} +``` + +### 高级预设(动态配置) + +```json +{ + "name": "multi-provider-advanced", + "version": "2.0.0", + "description": "多 Provider 高级配置,支持动态选择和代理", + "author": "Your Name", + "keywords": ["openai", "deepseek", "proxy", "multi-provider"], + "ccrVersion": "2.0.0", + + "schema": [ + { + "id": "primaryProvider", + "type": "select", + "label": "主要 Provider", + "prompt": "选择您主要使用的 LLM 提供商", + "options": { + "type": "static", + "options": [ + { + "label": "OpenAI", + "value": "openai", + "description": "使用 OpenAI 的 GPT 模型,质量高" + }, + { + "label": "DeepSeek", + "value": "deepseek", + "description": "使用 DeepSeek 的高性价比模型" + } + ] + }, + "required": true, + "defaultValue": "openai" + }, + { + "id": "apiKey", + "type": "password", + "label": "API Key", + "prompt": "请输入您的 API Key", + "placeholder": "sk-...", + "required": true, + "validator": "^sk-.+" + }, + { + "id": "defaultModel", + "type": "select", + "label": "默认模型", + "prompt": "选择默认使用的模型", + "options": { + "type": "static", + "options": [ + {"label": "GPT-4o", "value": "gpt-4o"}, + {"label": "GPT-4o-mini", "value": "gpt-4o-mini"} + ] + }, + "required": true, + "defaultValue": "gpt-4o", + "when": { + "field": "primaryProvider", + "operator": "eq", + "value": "openai" + } + }, + { + "id": "backgroundModel", + "type": "select", + "label": "后台任务模型", + "prompt": "选择用于后台任务的轻量级模型", + "options": { + "type": "static", + "options": [ + {"label": "GPT-4o-mini", "value": "gpt-4o-mini"} + ] + }, + "required": true, + "defaultValue": "gpt-4o-mini", + "when": { + "field": "primaryProvider", + "operator": "eq", + "value": "openai" + } + }, + { + "id": "maxTokens", + "type": "number", + "label": "最大 Token 数", + "prompt": "设置单次请求的最大 token 数", + "min": 1, + "max": 128000, + "defaultValue": 4096 + }, + { + "id": "timeout", + "type": "number", + "label": "超时时间(秒)", + "prompt": "设置 API 请求超时时间", + "min": 10, + "max": 300, + "defaultValue": 60 + }, + { + "id": "enableProxy", + "type": "confirm", + "label": "启用代理", + "prompt": "是否通过代理访问 API?", + "defaultValue": false + }, + { + "id": "proxyUrl", + "type": "input", + "label": "代理地址", + "prompt": "输入代理服务器地址", + "placeholder": "http://127.0.0.1:7890", + "required": true, + "when": { + "field": "enableProxy", + "operator": "eq", + "value": true + }, + "validator": "^https?://.+" + }, + { + "id": "features", + "type": "multiselect", + "label": "启用功能", + "prompt": "选择要启用的额外功能", + "options": { + "type": "static", + "options": [ + {"label": "长上下文支持", "value": "longContext"}, + {"label": "网络搜索", "value": "webSearch"}, + {"label": "图像处理", "value": "image"} + ] + }, + "defaultValue": [] + } + ], + + "template": { + "Providers": [ + { + "name": "#{primaryProvider}", + "api_base_url": "#{primaryProvider === 'openai' ? 'https://api.openai.com/v1' : 'https://api.deepseek.com'}", + "api_key": "#{apiKey}", + "models": [ + "#{defaultModel}", + "#{backgroundModel}" + ], + "timeout": #{timeout * 1000} + } + ], + "Router": { + "default": "#{primaryProvider}/#{defaultModel}", + "background": "#{primaryProvider}/#{backgroundModel}" + }, + "NON_INTERACTIVE_MODE": false + }, + + "configMappings": [ + { + "target": "PROXY_URL", + "value": "#{proxyUrl}", + "when": { + "field": "enableProxy", + "operator": "eq", + "value": true + } + }, + { + "target": "Router.longContext", + "value": "#{primaryProvider}/#{defaultModel}", + "when": { + "field": "features", + "operator": "in", + "value": ["longContext"] + } + }, + { + "target": "Router.webSearch", + "value": "#{primaryProvider}/#{defaultModel}", + "when": { + "field": "features", + "operator": "in", + "value": ["webSearch"] + } + }, + { + "target": "Router.image", + "value": "#{primaryProvider}/#{defaultModel}", + "when": { + "field": "features", + "operator": "in", + "value": ["image"] + } + } + ] +} +``` + +## 验证规则 + +### Preset 验证检查项 + +1. **元数据验证** + - ✓ `name` 字段存在 + - ✓ `version` 字段存在(警告) + +2. **配置验证** + - ✓ `config` 部分存在 + - ✓ 每个 Provider 有 `name` 字段 + - ✓ 每个 Provider 有 `api_base_url` 字段 + - ✓ 每个 Provider 有 `models` 数组(警告) + +3. **Schema 验证** + - ✓ 字段 `id` 唯一 + - ✓ 条件字段引用存在 + - ✓ 动态选项配置正确 + +### 错误和警告 + +**错误(Error):** +- 缺少必填字段 +- Provider 配置不完整 +- Schema 字段重复 + +**警告(Warning):** +- 缺少可选字段 +- Provider 没有 models +- 未使用的 schema 字段 + +## 最佳实践 + +### 1. 使用动态配置系统 + +```json +{ + "schema": [ + { + "id": "apiKey", + "type": "password", + "label": "API Key", + "required": true + } + ], + "template": { + "Providers": [ + { + "api_key": "#{apiKey}" + } + ] + } +} +``` + +### 2. 提供合理的默认值 + +```json +{ + "id": "timeout", + "type": "number", + "label": "超时时间", + "defaultValue": 60, + "min": 10, + "max": 300 +} +``` + +### 3. 使用条件显示减少不必要的输入 + +```json +{ + "id": "proxyUrl", + "type": "input", + "label": "代理地址", + "when": { + "field": "useProxy", + "operator": "eq", + "value": true + } +} +``` + +### 4. 清晰的标签和提示 + +```json +{ + "id": "apiKey", + "type": "password", + "label": "OpenAI API Key", + "prompt": "请输入您的 OpenAI API Key(以 sk- 开头)", + "placeholder": "sk-...", + "validator": "^sk-.+" +} +``` + +### 5. 使用验证确保数据质量 + +```json +{ + "id": "port", + "type": "number", + "label": "端口号", + "min": 1024, + "max": 65535, + "validator": (value) => { + if (value < 1024 || value > 65535) { + return "端口号必须在 1024-65535 之间"; + } + return true; + } +} +``` + +### 6. 版本控制 + +遵循 semver 规范: +- `1.0.0` - 初始版本 +- `1.1.0` - 新增功能(向后兼容) +- `1.0.1` - Bug 修复 +- `2.0.0` - 破坏性变更 + +### 7. 文档化 + +```json +{ + "name": "my-preset", + "version": "1.0.0", + "description": "详细的预设描述,说明用途和特点", + "author": "作者名 ", + "homepage": "https://github.com/user/preset", + "repository": "https://github.com/user/preset.git", + "keywords": ["openai", "production", "proxy"], + "license": "MIT" +} +``` + +### 8. 使用相对路径 + +对于预设中的自定义文件(如转换器、脚本),使用相对路径: + +```json +{ + "transformers": [ + { + "path": "./transformers/custom.js" + } + ], + "StatusLine": { + "default": { + "modules": [ + { + "type": "script", + "scriptPath": "./scripts/status.js" + } + ] + } + } +} +``` + +相对路径会在安装时自动转换为绝对路径。 + +## 导出和导入 + +### 导出当前配置 + +```bash +ccr preset export my-preset +``` + +可选项: +```bash +ccr preset export my-preset \ + --description "我的预设" \ + --author "Your Name" \ + --tags "openai,production" +``` + +### 导入预设 + +```bash +# 从本地目录安装 +ccr preset install /path/to/preset + +# 从预设名称重新配置(已安装的) +ccr preset install my-preset +``` + +### 管理预设 + +```bash +# 列出所有预设 +ccr preset list + +# 查看预设信息 +ccr preset info my-preset + +# 删除预设 +ccr preset delete my-preset +``` + +## 常见问题 + +### Q: 如何处理多个 Provider? + +A: 在 template 中定义多个 Provider,使用条件逻辑: + +```json +{ + "schema": [ + { + "id": "useSecondary", + "type": "confirm", + "label": "启用备用 Provider" + }, + { + "id": "secondaryKey", + "type": "password", + "label": "备用 API Key", + "when": { + "field": "useSecondary", + "operator": "eq", + "value": true + } + } + ], + "template": { + "Providers": [ + { + "name": "primary", + "api_key": "#{primaryKey}" + }, + { + "name": "secondary", + "api_key": "#{secondaryKey}" + } + ] + }, + "configMappings": [ + { + "target": "Providers", + "value": [ + { + "name": "primary", + "api_key": "#{primaryKey}" + } + ], + "when": { + "field": "useSecondary", + "operator": "ne", + "value": true + } + } + ] +} +``` + +### Q: 如何支持条件配置? + +A: 使用 `when` 条件和 `configMappings`: + +```json +{ + "configMappings": [ + { + "target": "PROXY_URL", + "value": "#{proxyUrl}", + "when": { + "field": "useProxy", + "operator": "eq", + "value": true + } + } + ] +} +``` + +### Q: 如何验证用户输入? + +A: 使用 `validator` 字段: + +```json +{ + "id": "url", + "type": "input", + "label": "API 地址", + "validator": "^https?://.+" +} +``` + +### Q: 如何创建多语言预设? + +A: 使用条件选择语言: + +```json +{ + "schema": [ + { + "id": "language", + "type": "select", + "label": "语言", + "options": [ + {"label": "中文", "value": "zh"}, + {"label": "English", "value": "en"} + ] + } + ] +} +``` + +## 相关文档 + +- [预设配置使用指南](/zh/docs/advanced/presets) +- [配置基础](/zh/docs/config/basic) +- [Provider 配置](/zh/docs/config/providers) +- [路由配置](/zh/docs/config/routing) +- [转换器配置](/zh/docs/config/transformers) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 94ec407..64a771a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -17,6 +17,7 @@ import fs, { existsSync, readFileSync } from "fs"; import { join } from "path"; import { parseStatusLineData, StatusLineInput } from "./utils/statusline"; import {handlePresetCommand} from "./utils/preset"; +import { handleInstallCommand } from "./utils/installCommand"; const command = process.argv[2]; @@ -31,6 +32,7 @@ const KNOWN_COMMANDS = [ "code", "model", "preset", + "install", "activate", "env", "ui", @@ -52,6 +54,7 @@ Commands: code Execute claude command model Interactive model selection and configuration preset Manage presets (export, install, list, delete) + install Install preset from GitHub marketplace activate Output environment variables for shell integration ui Open the web UI in browser -v, version Show version information @@ -68,6 +71,7 @@ Examples: ccr preset export my-config # Export current config as preset ccr preset install /path/to/preset # Install a preset from directory ccr preset list # List all presets + ccr install my-preset # Install preset from marketplace eval "$(ccr activate)" # Set environment variables globally ccr ui `; @@ -264,6 +268,10 @@ async function main() { case "preset": await handlePresetCommand(process.argv.slice(3)); break; + case "install": + const presetName = process.argv[3]; + await handleInstallCommand(presetName); + break; case "activate": case "env": await activateCommand(); diff --git a/packages/cli/src/utils/installCommand.ts b/packages/cli/src/utils/installCommand.ts new file mode 100644 index 0000000..d1df1f6 --- /dev/null +++ b/packages/cli/src/utils/installCommand.ts @@ -0,0 +1,50 @@ +/** + * Install preset from GitHub marketplace + * ccr install {presetname} + */ + +import { installPresetFromMarket } from './preset/install-github'; +import { applyPresetCli } from './preset/install'; + +// ANSI color codes +const RESET = "\x1B[0m"; +const GREEN = "\x1B[32m"; +const YELLOW = "\x1B[33m"; +const BOLDGREEN = "\x1B[1m\x1B[32m"; +const BOLDYELLOW = "\x1B[1m\x1B[33m"; +const BOLDCYAN = "\x1B[1m\x1B[36m"; +const DIM = "\x1B[2m"; + +/** + * Install preset from marketplace by preset name + * @param presetName Preset name (must exist in marketplace) + */ +export async function handleInstallCommand(presetName: string): Promise { + try { + if (!presetName) { + console.error(`\n${BOLDYELLOW}Error:${RESET} Preset name is required\n`); + console.error('Usage: ccr install \n'); + console.error('Examples:'); + console.error(' ccr install my-preset'); + console.error(' ccr install awesome-preset\n'); + console.error(`${DIM}Note: Preset must exist in the official marketplace.${RESET}\n`); + process.exit(1); + } + + console.log(`${BOLDCYAN}Installing preset:${RESET} ${presetName}\n`); + + // Install preset (download and extract) + const { name: installedName, preset } = await installPresetFromMarket(presetName); + + if (installedName && preset) { + // Apply preset configuration (interactive setup) + await applyPresetCli(installedName, preset); + + console.log(`\n${BOLDGREEN}✓ Preset installation completed!${RESET}\n`); + } + + } catch (error: any) { + console.error(`\n${BOLDYELLOW}Failed to install preset:${RESET} ${error.message}\n`); + process.exit(1); + } +} diff --git a/packages/cli/src/utils/preset/install-github.ts b/packages/cli/src/utils/preset/install-github.ts new file mode 100644 index 0000000..e57f392 --- /dev/null +++ b/packages/cli/src/utils/preset/install-github.ts @@ -0,0 +1,163 @@ +/** + * Install preset from GitHub marketplace by preset name + */ + +import * as fs from 'fs/promises'; +import { + findMarketPresetByName, + getPresetDir, + readManifestFromDir, + saveManifest, + isPresetInstalled, + downloadPresetToTemp, + extractPreset, + manifestToPresetFile, + type PresetFile, +} from '@CCR/shared'; +import AdmZip from 'adm-zip'; + +// ANSI color codes +const RESET = "\x1B[0m"; +const GREEN = "\x1B[32m"; +const BOLDCYAN = "\x1B[1m\x1B[36m"; +const BOLDYELLOW = "\x1B[1m\x1B[33m"; + +/** + * Parse GitHub repository URL or name + * Supports: + * - owner/repo (short format) + * - github.com/owner/repo + * - https://github.com/owner/repo + * - https://github.com/owner/repo.git + * - git@github.com:owner/repo.git + */ +function parseGitHubRepo(input: string): { owner: string; repoName: string } | null { + const match = input.match(/(?:github\.com[:/]|^)([^/]+)\/([^/\s#]+?)(?:\.git)?$/); + if (!match) { + return null; + } + + const [, owner, repoName] = match; + return { owner, repoName }; +} + +/** + * Load preset from ZIP file + */ +async function loadPresetFromZip(zipFile: string): Promise { + const zip = new AdmZip(zipFile); + + // First try to find manifest.json in root directory + let entry = zip.getEntry('manifest.json'); + + // If not in root, try to find in subdirectories (handle GitHub repo archive structure) + if (!entry) { + const entries = zip.getEntries(); + // Find any manifest.json file + entry = entries.find(e => e.entryName.includes('manifest.json')) || null; + } + + if (!entry) { + throw new Error('Invalid preset file: manifest.json not found'); + } + + const manifest = JSON.parse(entry.getData().toString('utf-8')); + return manifestToPresetFile(manifest); +} + +/** + * Install preset from marketplace by preset name + * @param presetName Preset name (must exist in marketplace) + * @returns Object containing installed preset name and PresetFile + */ +export async function installPresetFromMarket(presetName: string): Promise<{ name: string; preset: PresetFile }> { + // Check if preset is in the marketplace + console.log(`${BOLDCYAN}Checking marketplace...${RESET}`); + + const marketPreset = await findMarketPresetByName(presetName); + + if (!marketPreset) { + throw new Error( + `Preset '${presetName}' not found in marketplace. ` + + `Please check the available presets at: https://github.com/claude-code-router/presets` + ); + } + + console.log(`${GREEN}✓${RESET} Found in marketplace\n`); + + // Get repository from market preset + if (!marketPreset.repo) { + throw new Error(`Preset '${presetName}' does not have repository information`); + } + + // Parse GitHub repository URL + const githubRepo = parseGitHubRepo(marketPreset.repo); + if (!githubRepo) { + throw new Error(`Invalid repository format: ${marketPreset.repo}`); + } + + const { owner, repoName } = githubRepo; + + // Use preset name from market (or the preset's id) + const installedPresetName = marketPreset.name || presetName; + + // Check if already installed BEFORE downloading + if (await isPresetInstalled(installedPresetName)) { + throw new Error( + `Preset '${installedPresetName}' is already installed.\n` + + `To delete and reinstall, use: ccr preset delete ${installedPresetName}\n` + + `To reconfigure without deleting, use: ccr preset install ${installedPresetName}` + ); + } + + // Download GitHub repository ZIP file + console.log(`${BOLDCYAN}Downloading preset...${RESET}`); + + const downloadUrl = `https://github.com/${owner}/${repoName}/archive/refs/heads/main.zip`; + const tempFile = await downloadPresetToTemp(downloadUrl); + + console.log(`${GREEN}✓${RESET} Downloaded\n`); + + try { + // Load preset to validate structure + console.log(`${BOLDCYAN}Validating preset...${RESET}`); + const preset = await loadPresetFromZip(tempFile); + console.log(`${GREEN}✓${RESET} Valid\n`); + + // Double-check if already installed (in case of race condition) + if (await isPresetInstalled(installedPresetName)) { + throw new Error( + `Preset '${installedPresetName}' was installed while downloading. ` + + `Please try again.` + ); + } + + // Extract to target directory + console.log(`${BOLDCYAN}Installing preset...${RESET}`); + const targetDir = getPresetDir(installedPresetName); + await extractPreset(tempFile, targetDir); + console.log(`${GREEN}✓${RESET} Installed\n`); + + // Read manifest and add repo information + const manifest = await readManifestFromDir(targetDir); + + // Add repo information to manifest + manifest.repository = marketPreset.repository; + if (marketPreset.url) { + manifest.source = marketPreset.url; + } + + // Save updated manifest + await saveManifest(installedPresetName, manifest); + + // Return preset name and PresetFile for further configuration + return { name: installedPresetName, preset }; + } finally { + // Clean up temp file + try { + await fs.unlink(tempFile); + } catch { + // Ignore cleanup errors + } + } +} diff --git a/packages/cli/src/utils/preset/install.ts b/packages/cli/src/utils/preset/install.ts index 37c8888..2e5c114 100644 --- a/packages/cli/src/utils/preset/install.ts +++ b/packages/cli/src/utils/preset/install.ts @@ -56,7 +56,7 @@ export async function applyPresetCli( if (!validation.valid) { console.log(`\n${YELLOW}Validation errors:${RESET}`); for (const error of validation.errors) { - console.log(` ${YELLOW}✗${RESET} ${error}`); + console.log(`${YELLOW}✗${RESET} ${error}`); } throw new Error('Invalid preset file'); } @@ -78,7 +78,17 @@ export async function applyPresetCli( userInputs = await collectUserInputs(preset.schema, preset.config); } - // Build manifest, keep original config, store user values in userValues + // Read existing manifest to preserve fields like repository, source, etc. + const presetDir = getPresetDir(presetName); + let existingManifest: ManifestFile | null = null; + + try { + existingManifest = await readManifestFromDir(presetDir); + } catch { + // Manifest doesn't exist yet, this is a new installation + } + + // Build manifest, preserve existing fields const manifest: ManifestFile = { name: presetName, version: preset.metadata?.version || '1.0.0', @@ -86,6 +96,22 @@ export async function applyPresetCli( ...preset.config, // Keep original config (may contain placeholders) }; + // Preserve fields from existing manifest (repository, source, etc.) + if (existingManifest) { + if (existingManifest.repository) { + manifest.repository = existingManifest.repository; + } + if (existingManifest.source) { + manifest.source = existingManifest.source; + } + if (existingManifest.sourceType) { + manifest.sourceType = existingManifest.sourceType; + } + if (existingManifest.checksum) { + manifest.checksum = existingManifest.checksum; + } + } + // Save schema (if exists) if (preset.schema) { manifest.schema = preset.schema; @@ -107,8 +133,6 @@ export async function applyPresetCli( // Save to manifest.json in extracted directory await saveManifest(presetName, manifest); - const presetDir = getPresetDir(presetName); - // Display summary console.log(`\n${BOLDGREEN}✓ Preset configured successfully!${RESET}\n`); console.log(`${BOLDCYAN}Preset directory:${RESET} ${presetDir}`); @@ -171,6 +195,11 @@ export async function installPresetCli( throw new Error(`Preset directory not found: ${source}`); } sourceDir = source; + + // Check if preset with this name already exists BEFORE installing + if (await isPresetInstalled(presetName)) { + throw new Error(`Preset '${presetName}' is already installed. To reconfigure, use: ccr preset install ${presetName}\nTo delete and reinstall, use: ccr preset delete ${presetName}`); + } } else { // Preset name (without path) presetName = source; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 5e49b8e..fcbf361 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -16,6 +16,8 @@ import { loadConfigFromManifest, downloadPresetToTemp, getTempDir, + findMarketPresetByName, + getMarketPresets, type PresetFile, type ManifestFile, type PresetMetadata, @@ -349,14 +351,8 @@ export const createServer = async (config: any): Promise => { // Get preset market list app.get("/api/presets/market", async (req: any, reply: any) => { try { - const marketUrl = "https://pub-0dc3e1677e894f07bbea11b17a29e032.r2.dev/presets.json"; - - const response = await fetch(marketUrl); - if (!response.ok) { - throw new Error(`Failed to fetch market presets: ${response.status} ${response.statusText}`); - } - - const marketPresets = await response.json(); + // Use market presets function + const marketPresets = await getMarketPresets(); return { presets: marketPresets }; } catch (error: any) { console.error("Failed to get market presets:", error); @@ -364,24 +360,37 @@ export const createServer = async (config: any): Promise => { } }); - // Install preset from GitHub repository + // Install preset from GitHub repository by preset name app.post("/api/presets/install/github", async (req: any, reply: any) => { try { - const { repo, name } = req.body; + const { presetName } = req.body; - if (!repo) { - reply.status(400).send({ error: "Repository URL is required" }); + if (!presetName) { + reply.status(400).send({ error: "Preset name is required" }); + return; + } + + // Check if preset is in the marketplace + const marketPreset = await findMarketPresetByName(presetName); + if (!marketPreset) { + reply.status(400).send({ + error: "Preset not found in marketplace", + message: `Preset '${presetName}' is not available in the official marketplace. Please check the available presets.` + }); + return; + } + + // Get repository from market preset + if (!marketPreset.repo) { + reply.status(400).send({ + error: "Invalid preset data", + message: `Preset '${presetName}' does not have repository information` + }); return; } // Parse GitHub repository URL - // Supported formats: - // - owner/repo (short format, from market) - // - github.com/owner/repo - // - https://github.com/owner/repo - // - https://github.com/owner/repo.git - // - git@github.com:owner/repo.git - const githubRepoMatch = repo.match(/(?:github\.com[:/]|^)([^/]+)\/([^/\s#]+?)(?:\.git)?$/); + const githubRepoMatch = marketPreset.repo.match(/(?:github\.com[:/]|^)([^/]+)\/([^/\s#]+?)(?:\.git)?$/); if (!githubRepoMatch) { reply.status(400).send({ error: "Invalid GitHub repository URL" }); return; @@ -389,33 +398,59 @@ export const createServer = async (config: any): Promise => { const [, owner, repoName] = githubRepoMatch; + // Use preset name from market + const installedPresetName = marketPreset.name || presetName; + + // Check if already installed BEFORE downloading + if (await isPresetInstalled(installedPresetName)) { + reply.status(409).send({ + error: "Preset already installed", + message: `Preset '${installedPresetName}' is already installed. To update or reconfigure, please delete it first using the delete button.`, + presetName: installedPresetName + }); + return; + } + // Download GitHub repository ZIP file const downloadUrl = `https://github.com/${owner}/${repoName}/archive/refs/heads/main.zip`; const tempFile = await downloadPresetToTemp(downloadUrl); - // Load preset + // Load preset to validate structure const preset = await loadPresetFromZip(tempFile); - // Determine preset name - const presetName = name || preset.metadata?.name || repoName; - - // Check if already installed - if (await isPresetInstalled(presetName)) { + // Double-check if already installed (in case of race condition) + if (await isPresetInstalled(installedPresetName)) { unlinkSync(tempFile); - reply.status(409).send({ error: "Preset already installed" }); + reply.status(409).send({ + error: "Preset already installed", + message: `Preset '${installedPresetName}' was installed while downloading. Please try again.`, + presetName: installedPresetName + }); return; } // Extract to target directory - const targetDir = getPresetDir(presetName); + const targetDir = getPresetDir(installedPresetName); await extractPreset(tempFile, targetDir); + // Read manifest and add repo information + const manifest = await readManifestFromDir(targetDir); + + // Add repo information to manifest from market data + manifest.repository = marketPreset.repo; + if (marketPreset.url) { + manifest.source = marketPreset.url; + } + + // Save updated manifest + await saveManifest(installedPresetName, manifest); + // Clean up temp file unlinkSync(tempFile); return { success: true, - presetName, + presetName: installedPresetName, preset: { ...preset.metadata, installed: true, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a00fa61..efae490 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,4 +8,5 @@ export * from './preset/install'; export * from './preset/export'; export * from './preset/readPreset'; export * from './preset/schema'; +export * from './preset/marketplace'; diff --git a/packages/shared/src/preset/marketplace.ts b/packages/shared/src/preset/marketplace.ts new file mode 100644 index 0000000..e2b5b48 --- /dev/null +++ b/packages/shared/src/preset/marketplace.ts @@ -0,0 +1,56 @@ +/** + * Preset marketplace management + * Fetches preset market data directly from remote without caching + */ + +import { PresetIndexEntry } from './types'; + +// Preset market URL +const MARKET_URL = 'https://pub-0dc3e1677e894f07bbea11b17a29e032.r2.dev/presets.json'; + +/** + * Fetch preset market data from remote URL + */ +async function fetchMarketData(): Promise { + const response = await fetch(MARKET_URL); + + if (!response.ok) { + throw new Error(`Failed to fetch preset market: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as PresetIndexEntry[]; + return data; +} + +/** + * Get preset market data (always fetches from remote) + * @returns Array of preset market entries + */ +export async function getMarketPresets(): Promise { + return await fetchMarketData(); +} + +/** + * Find a preset in the market by preset name (id or name field) + * @param presetName Preset name to search for + * @returns Preset entry if found, null otherwise + */ +export async function findMarketPresetByName(presetName: string): Promise { + const marketPresets = await getMarketPresets(); + + // First try exact match by id + let preset = marketPresets.find(p => p.id === presetName); + + // If not found, try exact match by name + if (!preset) { + preset = marketPresets.find(p => p.name === presetName); + } + + // If still not found, try case-insensitive match by name + if (!preset) { + const lowerName = presetName.toLowerCase(); + preset = marketPresets.find(p => p.name.toLowerCase() === lowerName); + } + + return preset || null; +} diff --git a/packages/shared/src/preset/types.ts b/packages/shared/src/preset/types.ts index 728e396..6551dd8 100644 --- a/packages/shared/src/preset/types.ts +++ b/packages/shared/src/preset/types.ts @@ -214,6 +214,7 @@ export interface PresetIndexEntry { stars?: number; // Star count tags?: string[]; // Tags url: string; // Download address + repo?: string; // Repository (e.g., 'owner/repo') checksum?: string; // SHA256 checksum ccrVersion?: string; // Compatible version } diff --git a/packages/ui/src/components/Presets.tsx b/packages/ui/src/components/Presets.tsx index b9fc94e..2f28415 100644 --- a/packages/ui/src/components/Presets.tsx +++ b/packages/ui/src/components/Presets.tsx @@ -14,7 +14,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Upload, Link, Trash2, Info, Download, CheckCircle2, AlertCircle, Loader2, ArrowLeft, Store, Search, Package } from "lucide-react"; +import { Upload, Link, Trash2, Info, Download, Check, CheckCircle2, AlertCircle, Loader2, ArrowLeft, Store, Search, Package } from "lucide-react"; import { Toast } from "@/components/ui/toast"; import { DynamicConfigForm } from "./preset/DynamicConfigForm"; @@ -193,7 +193,13 @@ export function Presets() { } } catch (error: any) { console.error('Failed to install preset:', error); - setToast({ message: t('presets.preset_install_failed', { error: error.message }), type: 'error' }); + // Check if it's an "already installed" error + const errorMessage = error.message || ''; + if (errorMessage.includes('already installed') || errorMessage.includes('已安装')) { + setToast({ message: t('presets.preset_already_installed'), type: 'warning' }); + } else { + setToast({ message: t('presets.preset_install_failed', { error: errorMessage }), type: 'error' }); + } } finally { setInstallingFromMarket(null); } @@ -345,7 +351,13 @@ export function Presets() { } } catch (error: any) { console.error('Failed to install preset:', error); - setToast({ message: t('presets.preset_install_failed', { error: error.message }), type: 'error' }); + // Check if it's an "already installed" error + const errorMessage = error.message || ''; + if (errorMessage.includes('already installed') || errorMessage.includes('已安装')) { + setToast({ message: t('presets.preset_already_installed'), type: 'warning' }); + } else { + setToast({ message: t('presets.preset_install_failed', { error: errorMessage }), type: 'error' }); + } } finally { setIsInstalling(false); } @@ -636,56 +648,76 @@ export function Presets() { ) : (
- {filteredMarketPresets.map((preset) => ( -
-
-
-
-

{preset.name}

-
- {preset.description && ( -

{preset.description}

- )} -
- {preset.author && ( -
- {t('presets.by', { author: preset.author })} - - - -
+ {filteredMarketPresets.map((preset) => { + // Check if this preset is already installed by repo + const isInstalled = presets.some(p => { + // Extract repo from repository field (handle both formats) + let installedRepo = ''; + if (p.repository) { + // Remove GitHub URL prefix if present + installedRepo = p.repository.replace(/^https:\/\/github\.com\//, '').replace(/\.git$/, ''); + } + // Match by repo (preferred), or name as fallback + return installedRepo === preset.repo || p.name === preset.name; + }); + + return ( +
+
+
+
+

{preset.name}

+
+ {preset.description && ( +

{preset.description}

)} +
+ {preset.author && ( +
+ {t('presets.by', { author: preset.author })} + + + +
+ )} +
+
-
-
- ))} + ); + })}
)}
diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts index dee1799..b51715f 100644 --- a/packages/ui/src/lib/api.ts +++ b/packages/ui/src/lib/api.ts @@ -99,7 +99,17 @@ class ApiClient { } if (!response.ok) { - throw new Error(`API request failed: ${response.status} ${response.statusText}`); + // Try to get detailed error message from response body + let errorMessage = `API request failed: ${response.status} ${response.statusText}`; + try { + const errorData = await response.json(); + if (errorData.error || errorData.message) { + errorMessage = errorData.message || errorData.error || errorMessage; + } + } catch { + // If parsing fails, use default error message + } + throw new Error(errorMessage); } if (response.status === 204) { diff --git a/packages/ui/src/locales/en.json b/packages/ui/src/locales/en.json index 9ea45f7..2f4de88 100644 --- a/packages/ui/src/locales/en.json +++ b/packages/ui/src/locales/en.json @@ -255,6 +255,7 @@ "view_details": "View Details", "install": "Install", "installing": "Installing...", + "installed_label": "Installed", "apply": "Apply Preset", "applying": "Applying...", "close": "Close", @@ -274,6 +275,7 @@ "delete_dialog_description": "Are you sure you want to delete preset \"{{name}}\"? This action cannot be undone.", "preset_installed": "Preset installed successfully", "preset_install_failed": "Failed to install preset: {{error}}", + "preset_already_installed": "Preset already installed. Please delete it first if you want to reinstall.", "preset_applied": "Preset applied successfully", "preset_apply_failed": "Failed to apply preset: {{error}}", "preset_deleted": "Preset deleted successfully", diff --git a/packages/ui/src/locales/zh.json b/packages/ui/src/locales/zh.json index b148f4f..40635bf 100644 --- a/packages/ui/src/locales/zh.json +++ b/packages/ui/src/locales/zh.json @@ -255,6 +255,7 @@ "view_details": "查看详情", "install": "安装", "installing": "安装中...", + "installed_label": "已安装", "apply": "应用预设", "applying": "应用中...", "close": "关闭", @@ -262,7 +263,6 @@ "install_dialog_title": "安装预设", "install_dialog_description": "从 GitHub 仓库安装预设", "from_url": "从 GitHub", - "github_repository": "GitHub 仓库", "preset_url": "仓库 URL", "preset_url_placeholder": "https://github.com/owner/repo", "preset_name": "预设名称 (可选)", @@ -274,6 +274,7 @@ "delete_dialog_description": "您确定要删除预设 \"{{name}}\" 吗?此操作无法撤销。", "preset_installed": "预设安装成功", "preset_install_failed": "预设安装失败:{{error}}", + "preset_already_installed": "预设已经安装。如需重新安装,请先删除现有预设。", "preset_applied": "预设应用成功", "preset_apply_failed": "预设应用失败:{{error}}", "preset_deleted": "预设删除成功", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5d5bcd..feda336 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,7 +65,7 @@ importers: version: 3.4.19(tsx@4.21.0) packages/cli: - dependencies: + devDependencies: '@CCR/server': specifier: workspace:* version: link:../server @@ -75,12 +75,21 @@ importers: '@inquirer/prompts': specifier: ^5.0.0 version: 5.5.0 + '@types/archiver': + specifier: ^7.0.0 + version: 7.0.0 + '@types/node': + specifier: ^24.0.15 + version: 24.7.0 adm-zip: specifier: ^0.5.16 version: 0.5.16 archiver: specifier: ^7.0.1 version: 7.0.1 + esbuild: + specifier: ^0.25.1 + version: 0.25.10 find-process: specifier: ^2.0.0 version: 2.0.0 @@ -90,16 +99,6 @@ importers: openurl: specifier: ^1.1.1 version: 1.1.1 - devDependencies: - '@types/archiver': - specifier: ^7.0.0 - version: 7.0.0 - '@types/node': - specifier: ^24.0.15 - version: 24.7.0 - esbuild: - specifier: ^0.25.1 - version: 0.25.10 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@24.7.0)(typescript@5.8.3)